Java Guild course · Lesson 0002
Lesson 0002 · approval testing
You cannot safely refactor code you cannot characterise. Gilded Rose taught us how; this is that technique, on this repository.
You are about to change almost every class in this project. Main
will be taken apart. Library's exceptions will change type.
The file format will change. Each of those is a chance to break something that
worked, silently, in a corner you never ran.
There is exactly one moment when you can protect yourself against that, and it is now — before the first edit.
A golden master (also: approval test, characterisation test, snapshot test) captures what a program currently does and asserts that it keeps doing it. It makes no claim that the behaviour is correct. That is the entire point: it lets you restructure code you do not yet understand, without needing to understand it first.
This is the technique Emily Bache's Gilded Rose kata
is built to teach, and the reason the kata ships with a text-based
“TextTest” fixture. Michael Feathers calls it a
characterisation test in Working Effectively with Legacy Code.
Everywhere else in this course, your tests start RED. A golden master starts GREEN by construction — it is generated from the behaviour it asserts. That is not TDD and you must never call it TDD in an interview. It is the safety net that makes the TDD in Lesson 0004 onwards safe.
A normal test states an expectation in code:
assertEquals("Dune by Frank Herbert (Available)", book.toString());
An approval test writes the actual output to a file and compares against a previously approved copy of that file:
src/test/resources/golden-master.approved.txt ← committed, is the expectation
src/test/resources/golden-master.received.txt ← written on failure, for diffing
The advantage is leverage. One approval test can pin hundreds of lines
of console output across a dozen scenarios, which would be unbearable to write as
individual assertions. And when it fails, you do not read an assertion message —
you diff two files and see precisely what moved.
The real tool is ApprovalTests.Java by Llewellyn Falco (approvaltests.com) — the one the Gilded Rose kata uses. With it, the whole test body is one line:
Approvals.verify(allScenarioOutput);
It manages the .approved/.received files, and pops
your diff tool open on failure. It is genuinely excellent.
Adding it means adding a dependency to a pom.xml you
were handed. That is a decision, not a detail. Two defensible answers:
| Choice | The argument for it |
|---|---|
| Hand-roll it (~25 lines) | No new dependency in a fixed pom. The mechanism is visible to the assessor rather than hidden behind a library they may not know. You demonstrably understand the technique rather than the API. |
| Use ApprovalTests | Don't reinvent a solved thing. Signals that you know the ecosystem and Falco's work. Better failure ergonomics. |
For this submission, hand-roll it. The pom is part of what you were given, the assessor may build offline, and 25 readable lines in a test is a smaller ask than an unfamiliar dependency. Then say in your README that you chose not to add ApprovalTests and why — that sentence proves you knew the option existed, which is worth more than the dependency would have been.
Write src/test/java/…/library/GoldenMasterTest.java. It needs
four pieces:
; in it, a non-numeric menu choice.System.in and
System.out for byte arrays, calls Main.main, and puts
them back in a finally block.catch (Throwable) that records the crash into the
output. This is the part people get wrong. A crash is behaviour. If
you let it propagate you lose the most interesting scenarios.golden-master.approved.txt, writing
.received.txt when they differ.Run it once. It will fail — there is no approved file yet. Read the
received file before you approve it, then rename it to
.approved.txt and commit it.
// 1. Main writes libraryState.txt into the working directory.
// Delete it between scenarios or run N is polluted by run N-1.
@BeforeEach @AfterEach
void removeStateFile() throws Exception { Files.deleteIfExists(STATE); }
// 2. A scenario is an ARRAY of sessions, not one string. Running
// Main twice against the same state file is the only way to cover
// the save-and-reload requirement through the front door.
new String[]{"a borrowed book is still borrowed after a restart",
"1\nDune\nFrank Herbert\n4\nDune\n6\n", // session 1: add, borrow, exit
"2\n3\nDune\n6\n"}, // session 2: list, search, exit
Persistence is an explicit Intermediate requirement. A unit test of
saveLibraryState proves a method works; a restart
scenario proves the feature works. It is the difference between
testing a part and testing the promise.
Here is what the approved file actually contained when taken against the unmodified starter — menus elided for readability:
=== borrow the same book twice ===
--- session 1: 1⏎Dune⏎Frank Herbert⏎4⏎Dune⏎4⏎Dune⏎6⏎
<menu> Enter book title: Enter book author: Book added successfully!
<menu> Enter the title of the book to borrow: Book borrowed successfully!
<menu> Enter the title of the book to borrow:
!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
=== a choice that is not a number ===
--- session 1: banana⏎6⏎
<menu>
!! java.util.InputMismatchException: null
=== a title containing a semicolon survives a restart ===
--- session 2: 2⏎6⏎
Library state loaded successfully.
<menu> Available books:
Coming Home by A Novel (Available)
You have approved four crashes and a data-corruption bug as correct behaviour. That feels wrong, and it is supposed to. Read the last one again: a book called Coming Home; A Novel by Rosamunde Pilcher went into the file, and came back as a book called Coming Home by A Novel. Pilcher is gone. Nothing warned anybody.
Notice also what is missing from every crash scenario:
Saving library state... never runs. Each of those crashes throws
away everything the user did in that session.
Once the fixes from Lessons 0004–0006 are in, the golden master goes red and you diff it. This is the entire diff that the finished work produced against the approved baseline — nothing has been trimmed:
-Enter the title of the book to borrow:
-!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
+Enter the title of the book to borrow: 'Dune' is already borrowed
+<menu> Saving library state...
+Library state saved successfully.
+Thank you for using the Library Management System!
-!! java.lang.RuntimeException: java.io.IOException: Book not found: Gone With The Wind
+Enter the title of the book to borrow: No book titled 'Gone With The Wind'
-!! java.lang.RuntimeException: java.io.IOException: Book not found or not borrowed: Dune
+Enter the title of the book to return: 'Dune' is not currently borrowed
-Coming Home by A Novel (Available)
+Coming Home; A Novel by Rosamunde Pilcher (Available)
-!! java.util.InputMismatchException: null
+Invalid choice. Please try again.
Five hunks. Every one an intended improvement. No noise. Commit that diff and you have handed the assessor a single artifact that proves, through the program's own front door, exactly what you improved. Nothing you write in a README will be as convincing.
When first taken, that diff had forty extra hunks in it, because a
refactor had quietly changed println(MENU) to print(…)
and dropped an “Available books:” header. Both were
accidents. Both were reverted.
A golden-master diff is only evidence if every hunk in it is deliberate. When one appears that you did not intend, the answer is almost always to undo the incidental change — not to shrug and re-approve. If you do want the change, make it its own commit with its own message.
Why must the golden master be taken before any refactoring, rather than after?
They stay perfectly valid — staying valid across changes is the whole job. They become red, which is the signal you want.
There is no caching. The approved file is just a text file you wrote and committed.
Exactly. The golden master's only power is that it was recorded from behaviour you had not yet touched. Take it after refactoring and you have carefully written down whatever you just broke, then asserted that it stays broken.
Git has no such ordering requirement. The ordering that matters is against the code changes, not against other files.
Your golden master captures a crash. What should the test do with it?
Skipping loses the most valuable scenarios. The crashes are exactly the behaviour you are about to change, so they are the hunks you most want in the final diff.
Right — catch (Throwable) and append the class name and message. A crash is observable behaviour, and recording it is what turns “I fixed some bugs” into a diff that shows a stack trace becoming a polite message.
Then you could never take a baseline of this program at all, since four of its scenarios crash. The baseline records reality; reality includes crashing.
Changing the input changes the scenario. You would end up with a baseline that avoids every interesting case.
Which statement about the golden master is safe to make in an interview?
This is the honest, accurate framing, and it shows you know what each kind of test is for. The golden master is a net; the unit tests are the specification. Both belong in the submission.
A single opaque blob of approved text is a poor specification — it says what happens, never why. It also fails all at once, telling you little about which behaviour broke.
It was generated from existing behaviour, so it was green the moment it existed. Calling that TDD in front of someone who knows the difference is the worst possible answer here.
It proves the opposite is possible: this baseline approved four crashes. A golden master records behaviour and takes no view on whether it is right.
git add src/test/java/**/GoldenMasterTest.java src/test/resources/golden-master.approved.txt
git commit
Pin existing behaviour with a golden master test
The brief asks for changes across every class, including replacing the
exception types and the persistence format. Before making any of them,
capture what the program does today so that unintended changes show up
as a diff rather than as a surprise.
The approved baseline deliberately records four crashes and one data
corruption bug as current behaviour. It asserts what the program does,
not what it should do; the unit tests added next specify that.
Emily Bache — the Gilded Rose Refactoring Kata, with the kata description at Samman Coaching. Do the Java version of the kata end to end once — an hour, maybe two. It is the same shape as this tech test in miniature, and having done it is the difference between describing the technique and having used it.
Then: Michael Feathers, Working Effectively with Legacy Code, chapter 13, “I Need to Make a Change but I Don't Know What Tests to Write” — the definition of a characterisation test.
If you would rather use ApprovalTests.Java than hand-roll it, say so and I will show you that version — the argument for it is real, and being able to make it is the point.