Java Guild course · Lesson 0002

Lesson 0002 · approval testing

The Golden Master

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.

The technique, named

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.

This lesson is the exception to the rule

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.

How an approval test differs from an assertion

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 library, and whether to use it

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.

The judgement call — and how to defend it

Adding it means adding a dependency to a pom.xml you were handed. That is a decision, not a detail. Two defensible answers:

ChoiceThe 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 ApprovalTestsDon'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.

Do this now

Task · 30 minutes · this must happen BEFORE any other change

Write src/test/java/…/library/GoldenMasterTest.java. It needs four pieces:

  1. A list of scenarios, each one a name plus the keystrokes a user would type. Include the ugly ones: borrowing twice, an absent title, a title with a ; in it, a non-numeric menu choice.
  2. A capture harness that swaps System.in and System.out for byte arrays, calls Main.main, and puts them back in a finally block.
  3. A 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.
  4. A comparison against 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.

The two details that will bite you

// 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
Why that restart scenario earns its keep

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.

What it will tell you

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)
Look at what you just approved

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.

The payoff

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.

The discipline that makes the diff readable

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.

Drill

Why must the golden master be taken before any refactoring, rather than after?

  1. Approval files become invalid whenever the production sources are modified
  2. The test framework caches the first result it sees and reuses it later on
  3. Taken afterwards it pins your changes, so it can prove nothing about them
  4. Approved files must be committed before any other file for git to diff them

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?

  1. Skip that scenario, since a crashing program cannot produce valid output
  2. Catch it and write the type and message into the captured output stream
  3. Fail the test immediately, because a crash means the baseline is invalid
  4. Retry the scenario with different input until the program exits normally

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?

  1. “It let me refactor safely; the unit tests are what specify behaviour”
  2. “It is the main test suite, so unit tests would mostly be redundant”
  3. “I wrote it test-first, so the refactoring followed red-green-refactor”
  4. “It proves the original implementation was behaving correctly throughout”

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.

Commit it

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.

Read this

Primary source

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.

Carry on

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.