Java Guild course · Lesson 0002

Lesson 0002 · approval testing

The Golden Master

Pin the whole program’s behaviour before you touch it. Follow the eight steps below exactly; everything you need is on this page.

Do this first, before any other change

You are about to change almost every class in this project. There is exactly one moment when you can protect yourself against breaking something in a corner you never run, and it is now — before the first edit. Once you have modified production code, the window has closed permanently.

What you are building, in one paragraph

A golden master (also called an approval test or a characterisation test) runs the program, captures everything it prints, and compares that against a file you previously blessed. It makes no claim that the behaviour is correct — only that it has not changed. That is what lets you restructure code you do not fully understand yet.

This is the technique Emily Bache’s Gilded Rose kata teaches, and you are going to use the same library the kata uses: ApprovalTests.Java by Llewellyn Falco.

This lesson is the exception to the rule

Everywhere else in this course your tests start RED. A golden master is generated from the behaviour it asserts, so it is GREEN the moment you approve it. 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.


The eight steps

Step 1 · Add the ApprovalTests dependency

Open pom.xml. Find the <dependencies> block — it already contains junit-jupiter, sqlite-jdbc and mockito-core. Paste this in as a new entry, immediately after the junit-jupiter block:

<dependency>
    <groupId>com.approvaltests</groupId>
    <artifactId>approvaltests</artifactId>
    <version>24.22.0</version>
    <scope>test</scope>
</dependency>

Now confirm it downloads:

cd ~/Desktop/java-guild-candidate-test-intermediate-main
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
~/.local/maven/bin/mvn dependency:resolve | grep -i approval

You should see two jars resolve: approvaltests and approvaltests-util.

Why version 24.22.0, and why this is safe

24.22.0 is the current release (May 2025). Its class files are Java 8 bytecode, verified by inspecting the jar — so it works perfectly with this project’s release 11 target and adds no pressure to raise the Java version.

You are adding a dependency to a pom you were handed. That is a decision an interviewer may well probe, so have the answer ready: “It is test-scoped, so it ships with nothing. Approval testing is the standard technique for putting untested legacy code under test, and hand-rolling the file comparison would have been reinventing a solved problem for no benefit.” Note it in your README under design decisions.

Step 2 · Ignore the received files

ApprovalTests writes a .received.txt file whenever a test fails. Those are transient scratch files and must never be committed — only the .approved.txt file belongs in git.

cd ~/Desktop/java-guild-candidate-test-intermediate-main
echo "" >> .gitignore
echo "# ApprovalTests scratch output - never commit these" >> .gitignore
echo "*.received.*" >> .gitignore
echo "libraryState.txt" >> .gitignore

The last line matters too: the application writes libraryState.txt into whatever directory it runs from, and you do not want that in the submission.

Step 2b · Watch it write the state file

Before you write a test about this behaviour, see it happen. Four minutes, and it makes the whole persistence story concrete — including the bug you are going to record in the golden master.

cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn -q clean compile

1. Confirm there is no state file yet.

ls libraryState.txt
ls: libraryState.txt: No such file or directory

2. Add two books, borrow one, and exit cleanly with option 6. The clean exit is the important part — that is the only path that saves.

printf '1\nDune\nFrank Herbert\n1\nEmma\nJane Austen\n4\nDune\n6\n' \
  | java -cp target/classes com.solirius.intermediate.library.Main | tail -4
Enter your choice:
Saving library state...
Library state saved successfully.
Thank you for using the Library Management System!

3. Look at the file it just wrote.

cat libraryState.txt
Dune;Frank Herbert;true
Emma;Jane Austen;false

There is the whole format, in two lines: title;author;isBorrowed, one book per line. Dune is true because you borrowed it. This is the entire persistence layer the brief calls a requirement.

4. Restart and watch it reload.

printf '2\n6\n' | java -cp target/classes com.solirius.intermediate.library.Main | head -3
Library state loaded successfully.
Welcome to the Library Management System!
5. Now break it, in three commands

Same thing, but with a book whose title contains the separator character. This is a real book — Coming Home; A Novel by Rosamunde Pilcher.

rm -f libraryState.txt

printf '1\nComing Home; A Novel\nRosamunde Pilcher\n6\n' \
  | java -cp target/classes com.solirius.intermediate.library.Main > /dev/null

cat libraryState.txt

The file is written faithfully — every character is there:

Coming Home; A Novel;Rosamunde Pilcher;false

Now read it back:

printf '2\n6\n' | java -cp target/classes com.solirius.intermediate.library.Main \
  | grep -A2 'Available books:'
Available books:
Coming Home by  A Novel (Available)

Rosamunde Pilcher has vanished. The title is truncated, the author is half a title, and nothing anywhere reported an error. The save was perfect; the information was destroyed on the way back in, because split(";") found four fields where the reader expected three.

This is the bug your golden master is about to record as current behaviour — and it is the one that produces the most satisfying line in the final diff.

6. Tidy up, and check Step 2 did its job.

rm -f libraryState.txt
git status --short

You should see no mention of libraryState.txt at all. If you had skipped Step 2, git would be offering to commit it as ?? libraryState.txt every time you ran the application. Confirm the rule is what is hiding it:

git check-ignore -v libraryState.txt
.gitignore:35:libraryState.txt	libraryState.txt
What you just learned that the tests will not tell you

Step 3 · Create the test file

Create this file, at exactly this path:

src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java

The directory does not exist yet, so make it first:

mkdir -p src/test/java/com/solirius/intermediate/library

Then paste in the whole file below. It is long, but there is nothing to work out — read the four commented sections as you paste, because they are what you will be asked about.

package com.solirius.intermediate.library;

import org.approvaltests.Approvals;
import org.approvaltests.reporters.QuietReporter;
import org.approvaltests.reporters.UseReporter;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

/**
 * Pins the observable behaviour of the whole application before any of it is
 * refactored.
 * <p>
 * This is a golden master: it asserts what the program currently DOES, not what
 * it SHOULD do. The approved file therefore records several crashes and one data
 * corruption bug as current behaviour. Fixing those is the work of later
 * commits, and the change will show up here as a diff.
 * </p>
 */
@UseReporter(QuietReporter.class)
@DisplayName("The application as a whole")
class GoldenMasterTest {

    /** The application writes its state here, relative to the working directory. */
    private static final Path STATE_FILE = Path.of("libraryState.txt");

    // PIECE 1 - the scenarios.
    // A Scenario is a name plus one or more sessions. A session is the exact
    // keystrokes a user would type, newline-separated. Two sessions means the
    // application is started twice against the same state file, which is how the
    // save-and-reload requirement gets covered through the front door.
    private static final class Scenario {
        /** What this scenario demonstrates; becomes a heading in the transcript. */
        private final String name;
        /** One entry per run of the application, in order. */
        private final String[] sessions;

        Scenario(final String theName, final String... theSessions) {
            this.name = theName;
            this.sessions = theSessions;
        }
    }

    private static final List<Scenario> SCENARIOS = List.of(
        new Scenario("add a book then list it",
            "1\nDune\nFrank Herbert\n2\n6\n"),
        new Scenario("search for a book that is present",
            "1\nDune\nFrank Herbert\n3\nDune\n6\n"),
        new Scenario("search for a book that is absent",
            "3\nGone With The Wind\n6\n"),
        new Scenario("borrow a book then list",
            "1\nDune\nFrank Herbert\n4\nDune\n2\n6\n"),
        new Scenario("borrow the same book twice",
            "1\nDune\nFrank Herbert\n4\nDune\n4\nDune\n6\n"),
        new Scenario("borrow a book that is absent",
            "4\nGone With The Wind\n6\n"),
        new Scenario("return a book that was never borrowed",
            "1\nDune\nFrank Herbert\n5\nDune\n6\n"),
        new Scenario("borrow then return",
            "1\nDune\nFrank Herbert\n4\nDune\n5\nDune\n2\n6\n"),
        new Scenario("a plain title survives a restart",
            "1\nDune\nFrank Herbert\n6\n", "2\n6\n"),
        new Scenario("a title containing a semicolon survives a restart",
            "1\nComing Home; A Novel\nRosamunde Pilcher\n6\n", "2\n6\n"),
        new Scenario("a borrowed book is still borrowed after a restart",
            "1\nDune\nFrank Herbert\n4\nDune\n6\n", "2\n3\nDune\n6\n"),
        new Scenario("a choice that is not on the menu",
            "9\n6\n"),
        new Scenario("a choice that is not a number",
            "banana\n6\n")
    );

    // PIECE 2 - isolation.
    // Main writes libraryState.txt into the working directory. Without this,
    // scenario N would be polluted by whatever scenario N-1 left behind.
    @BeforeEach
    @AfterEach
    void removeStateFile() throws Exception {
        Files.deleteIfExists(STATE_FILE);
    }

    @Test
    @DisplayName("behaves exactly as it did when the golden master was taken")
    void behavesAsApproved() throws Exception {
        StringBuilder transcript = new StringBuilder();
        for (Scenario scenario : SCENARIOS) {
            Files.deleteIfExists(STATE_FILE);
            transcript.append("=== ").append(scenario.name).append(" ===\n");
            for (int i = 0; i < scenario.sessions.length; i++) {
                String session = scenario.sessions[i];
                transcript.append("--- session ").append(i + 1).append(": ")
                          .append(session.replace("\n", "|"))
                          .append('\n')
                          .append(runApplication(session));
            }
            transcript.append('\n');
        }
        // PIECE 4 - one line does the whole comparison. ApprovalTests writes
        // GoldenMasterTest.behavesAsApproved.received.txt on failure and compares
        // it against GoldenMasterTest.behavesAsApproved.approved.txt.
        Approvals.verify(transcript.toString());
    }

    // PIECE 3 - the capture harness.
    // Swap System.in and System.out for byte arrays, run the real main(), and
    // always put them back in the finally block. The catch(Throwable) is the part
    // people get wrong: a crash IS behaviour, and recording it is exactly how the
    // final diff comes to show a stack trace turning into a polite message.
    private String runApplication(final String keystrokes) {
        InputStream originalIn = System.in;
        PrintStream originalOut = System.out;
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        PrintStream sink = new PrintStream(buffer, true, StandardCharsets.UTF_8);
        try {
            System.setIn(new ByteArrayInputStream(
                    keystrokes.getBytes(StandardCharsets.UTF_8)));
            System.setOut(sink);
            Main.main(new String[0]);
        } catch (Throwable crash) {
            sink.println();
            sink.println("!! " + crash.getClass().getName() + ": " + crash.getMessage());
        } finally {
            System.setIn(originalIn);
            System.setOut(originalOut);
        }
        return buffer.toString(StandardCharsets.UTF_8);
    }
}

Step 3b · What every line of that file does

Paste the file, then read this. You will be asked about this code, so the goal is not that it compiles — it is that you can defend every decision in it.

The two class-level annotations

@UseReporter(QuietReporter.class)
@DisplayName("The application as a whole")
class GoldenMasterTest {

A reporter is ApprovalTests’ answer to “what should happen when the comparison fails?” The library ships about thirty of them. The default tries to launch a graphical diff tool — lovely on your laptop, and a disaster in a submission: an assessor running mvn test could get a window opening on their machine, and a build server would hang or error.

QuietReporter just writes the .received.txt file and fails the test. Deterministic, headless, safe to hand over. It also prints the mv command you need to approve the result, so you lose nothing.

Worth saying out loud in interview

“I picked QuietReporter deliberately, because the default one opens a diff tool and I did not want a test in a submission doing something to the reviewer’s machine.” It is a small decision that shows you thought about who runs your code, not just whether it passes.

While you are working, you can temporarily swap in @UseReporter(DiffReporter.class) to get the graphical diff — just change it back before you package.

PIECE 1 · the scenarios

private static final class Scenario {
    private final String name;        // heading in the transcript
    private final String[] sessions;  // one entry per run of the application

    Scenario(final String theName, final String... theSessions) {
        this.name = theName;
        this.sessions = theSessions;
    }
}

private static final List<Scenario> SCENARIOS = List.of(
    new Scenario("borrow the same book twice",
        "1\nDune\nFrank Herbert\n4\nDune\n4\nDune\n6\n"),
    new Scenario("a borrowed book is still borrowed after a restart",
        "1\nDune\nFrank Herbert\n4\nDune\n6\n", "2\n3\nDune\n6\n"),
    ...
);

A tiny private class holding a name and a varargs list of sessions. The name becomes a heading in the transcript, so the approved file reads like a document rather than a wall of text.

The keystroke strings are literally what a user would type, with \n for each press of Enter. Decode the “borrow twice” one:

KeystrokeWhat the menu does with it
1choose “Add a new book”
Duneanswer the “Enter book title” prompt
Frank Herbertanswer the “Enter book author” prompt
4choose “Borrow a book”
Duneanswer “title of the book to borrow”
4choose “Borrow a book” again — this is the bug
Dunethe same title, which is now already on loan
6Exit
Why some scenarios have two sessions

new Scenario("...", "1\nDune\nFrank Herbert\n4\nDune\n6\n", "2\n3\nDune\n6\n") runs the application twice against the same state file: session 1 adds a book, borrows it, and exits — which saves; session 2 starts fresh, lists, and searches.

That is the only way to cover the Persistence requirement through the front door. A unit test of saveLibraryState proves a method works; a restart scenario proves the feature works. It is also what catches the semicolon corruption, because the damage only becomes visible on the way back in.

Why not List<String[]>? Because of a real Java trap

The obvious shortcut is to skip the class and make each scenario a String[] where slot 0 is the name:

List<String[]> SCENARIOS = List.of(
    new String[]{"borrow twice", "1\nDune\n..."},
    new String[]{"restart",      "1\n...", "2\n..."});

It compiles — with more than one entry. But List.of is varargs, so a single array argument gets swallowed as the varargs array itself rather than treated as one element. Verified:

List.of(new String[]{"x","y"}, new String[]{"p","q"})   → List<String[]>, size 2
List.of(new String[]{"x","y"})                         → List<String>,   size 2 (!)

So the moment you cut the list down to one scenario while debugging, it stops compiling:

error: incompatible types: inference variable E has incompatible bounds
    equality constraints: String[]
    lower bounds: String

That is why IntelliJ suggests List<String>. It is not wrong — it is warning you the element type is ambiguous. The Scenario class removes the ambiguity entirely, and it also removes the “slot 0 is secretly the name” convention, which nobody reading the code could have guessed.

Why a class and not a record?

A record Scenario(String name, String... sessions) would be three lines. But the pom targets Java 11 and records arrived in Java 16 — javac --release 11 rejects them outright. This is the constraint from Lesson 0001 biting in practice.

Good interview answer: “I’d have used a record, but the project targets Java 11 and records need 16. So it is a small private static class with final fields, which is what a record compiles down to anyway.”

PIECE 2 · isolation

@BeforeEach
@AfterEach
void removeStateFile() throws Exception {
    Files.deleteIfExists(STATE_FILE);
}

Main writes libraryState.txt into whatever directory it runs from — under Maven, the project root. It is real state on real disk, shared by every scenario.

Without this method, scenario 5 would start with whatever scenario 4 saved. The transcript would then depend on the order the scenarios ran in, and the test would be non-deterministic — the worst possible property for a golden master, because it would fail on changes you did not make.

AnnotationRunsWhat it protects against
@BeforeEachBefore the test methodA file left behind by a previous crashed run, or one you created by running Main by hand.
@AfterEachAfter the test methodLeaving the file in your working tree, where you might commit it by accident.

Putting both on one method is legal and deliberate. JUnit 5 is perfectly happy for a method to carry both lifecycle annotations; it simply gets called twice. Belt and braces, four lines.

Why also delete inside the loop?

Look at behavesAsApproved: it calls Files.deleteIfExists(STATE_FILE) again at the top of every scenario. That is not redundancy — @BeforeEach runs once per test method, and there is only one test method containing all thirteen scenarios. The in-loop delete is what isolates the scenarios from each other; the annotations isolate the test method from the outside world.

And note the exception: the two-session scenarios deliberately do not delete between session 1 and session 2, because the surviving state file is the whole point of a restart test.

Building the transcript

StringBuilder transcript = new StringBuilder();
for (Scenario scenario : SCENARIOS) {
    Files.deleteIfExists(STATE_FILE);                       // isolate scenarios
    transcript.append("=== ").append(scenario.name).append(" ===\n");
    for (int i = 0; i < scenario.sessions.length; i++) {
        String session = scenario.sessions[i];
        transcript.append("--- session ").append(i + 1).append(": ")
                  .append(session.replace("\n", "|"))       // keystrokes on one line
                  .append('\n')
                  .append(runApplication(session));         // then what it printed
    }
    transcript.append('\n');
}

One StringBuilder accumulates every scenario into a single document, which is what gets approved. Two details worth understanding:

PIECE 3 · the capture harness — the part worth understanding properly

This is the only genuinely tricky code in the file. The problem it solves: you want to run the real main(), but main() reads the keyboard and writes the terminal, and a test has neither.

Normally the wiring is:

keyboard  →  System.in   →  Scanner  →  Main
terminal  ←  System.out  ←  Main

The harness re-points both ends at byte arrays in memory:

your String  →  ByteArrayInputStream   →  System.in   →  Scanner  →  Main
your String  ←  ByteArrayOutputStream  ←  System.out  ←  Main

Now line by line:

InputStream  originalIn  = System.in;      // 1. remember the real ones
PrintStream  originalOut = System.out;

ByteArrayOutputStream buffer = new ByteArrayOutputStream();   // 2. a growable byte[]
PrintStream sink = new PrintStream(buffer, true, StandardCharsets.UTF_8);

try {
    System.setIn(new ByteArrayInputStream(                    // 3. feed it the keystrokes
            keystrokes.getBytes(StandardCharsets.UTF_8)));
    System.setOut(sink);                                      // 4. capture everything printed
    Main.main(new String[0]);                                 // 5. run the REAL application
} catch (Throwable crash) {                                   // 6. a crash is behaviour too
    sink.println();
    sink.println("!! " + crash.getClass().getName() + ": " + crash.getMessage());
} finally {
    System.setIn(originalIn);                                 // 7. ALWAYS put them back
    System.setOut(originalOut);
}
return buffer.toString(StandardCharsets.UTF_8);               // 8. the transcript
#Why it is there
1System.in/out are global mutable state. You are borrowing them, so you must be able to give them back.
2ByteArrayOutputStream is an output stream that writes into a growable byte array in memory instead of a file or the terminal.
3ByteArrayInputStream is the mirror image: an input stream that reads from a byte array. Scanner cannot tell it apart from a keyboard.
4The true in the PrintStream constructor is autoflush. Without it, output can still be sitting in a buffer when you read the array. The explicit UTF_8 matters too — the two-argument constructor uses the platform default, so a test could pass on your Mac and fail on a build server.
5Calling Main.main directly is what makes this an end-to-end test. It exercises the menu loop, the Library, the file format, everything — through the same door a user comes in.
6The line most people get wrong. See below.
7finally runs whether or not something was thrown. Miss this and one crashing scenario leaves System.out pointing at a dead buffer, and every later test in the whole suite prints into the void.
8Turn the captured bytes back into the string that becomes part of the transcript.
Why catch (Throwable), and why that is not the usual advice

Catching Throwable is normally a code smell — it swallows Errors like OutOfMemoryError that you have no business handling. Here it is correct, and you should be ready to explain why:

A crash is behaviour. Four of these thirteen scenarios kill the application. If the exception propagated, the test would abort at the first one and you would capture nothing — losing precisely the scenarios you are about to fix. By writing the exception’s type and message into the transcript, the approved file records “this input crashes with this exception” as the current behaviour. When you later fix it, the diff shows a stack trace becoming a polite message, which is the most persuasive artifact in the whole submission.

Throwable rather than Exception because you want it to be true of anything the application can die from, including Errors. This is test-harness code whose job is to observe failure, not production code that must decide what to do about it.

PIECE 4 · building the transcript, and the one line that asserts it

for (String[] scenario : SCENARIOS) {
    Files.deleteIfExists(STATE_FILE);                         // between scenarios too
    transcript.append("=== ").append(scenario[0]).append(" ===\n");
    for (int session = 1; session < scenario.length; session++) {   // note: starts at 1
        transcript.append("--- session ").append(session).append(": ")
                  .append(scenario[session].replace("\n", "|"))
                  .append('\n')
                  .append(runApplication(scenario[session]));
    }
    transcript.append('\n');
}
Approvals.verify(transcript.toString());

The inner loop starts at 1 because slot 0 is the name, not a session. The replace("\n", "|") renders the keystrokes on a single line in the transcript — without it, the input would be indistinguishable from the output it produced.

Then Approvals.verify does all of this for you:

  1. Works out the file names from the class and method: GoldenMasterTest.behavesAsApproved.approved.txt.
  2. If no approved file exists, writes .received.txt and fails.
  3. If one exists, compares. Identical → pass, and any stale received file is removed. Different → write .received.txt, fail, and hand both paths to the reporter.

Why approval testing, and not just assertEquals?

This is the question to have an answer for. There are three parts to it.

1. The volume makes ordinary assertions impractical

Thirteen scenarios, each printing the six-line menu several times: the approved file is over five hundred lines. Writing that as assertEquals calls would mean hand-typing five hundred lines of expected text, and re-typing them every time a prompt changes. Nobody does that, which is why untested legacy code stays untested.

Approval testing inverts who writes the expectation. The machine produces it; your job is to read it once and decide whether it is acceptable. That is a far better use of a human.

2. It tests through the front door

Because it drives Main.main with keystrokes, it cannot be fooled by a refactor. You are about to extract LibraryConsole, replace the exception types, introduce an enum, and change the file format — and this one test keeps checking that the thing the user sees is unchanged, no matter how the insides are rearranged. A unit test bound to Library.borrowBook would have to be rewritten by the very refactor it was meant to protect.

3. It is the standard technique for exactly this situation

Michael Feathers calls it a characterisation test in Working Effectively with Legacy Code: a test that pins down what code currently does so you can change it safely, when you do not yet have a specification. This is the textbook case — code you did not write, no tests, and a brief that asks you to change it.

The thirty-second answer
“The repo had no tests at all, so before changing anything I wanted a regression net. Unit-testing the classes first would not have helped much, because I was about to restructure them — the tests would have needed rewriting by the same refactor. So I took a golden master instead: thirteen scenarios driven through main(), capturing everything printed. I used ApprovalTests because the transcript is five hundred lines and hand-writing that as assertions is not realistic. It is a characterisation test — it records what the program does, not what it should do, which is why the baseline includes four crashes. Then the unit tests specify the correct behaviour, and when I fixed the bugs the golden-master diff showed exactly what changed for the user.”
And the follow-up you should expect

“Why add a dependency to a pom we gave you?”

“It is test-scoped, so nothing ships with the application. Approval testing is the standard way to get untested legacy code under test, and ApprovalTests is the reference implementation — it is what the Gilded Rose kata uses. Hand-rolling the file comparison would have been about twenty-five lines reinventing something solved. I checked its bytecode is Java 8, so it does not push the project off its Java 11 target, and I noted the addition in the README.”

Step 3c · Every scenario, and what it proves

Thirteen scenarios. Five expose a bug; eight do not. The eight are not padding — they are controls, and the section after them explains why the golden master would be far weaker without them.

5 expose a defect 8 are controls 3 restart the application twice

Every command below was run against the real starter before publishing. Run any of them yourself — they are the same keystrokes the test feeds in, just typed at a terminal. Set this up once:

cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn -q clean compile

# shorthand used by every command below - define it once per terminal
LMS() { java -cp target/classes com.solirius.intermediate.library.Main; }
Why a function, and not a variable

The obvious shorthand is LMS="java -cp target/classes ...Main". In bash that works. In zsh — the default shell on macOS — it does not:

$ printf '1\nDune\nFrank Herbert\n2\n6\n' | $LMS
zsh: no such file or directory: java -cp target/classes com.solirius...Main

zsh does not word-split unquoted parameter expansions, so $LMS is read as a single command name containing spaces, rather than a command followed by arguments. bash splits it; zsh does not. This is one of the handful of places the two shells genuinely disagree.

A shell function behaves identically in both, so that is what this page uses. If you prefer the variable, zsh needs ${=LMS} — the = forces splitting.

#ScenarioWhat it showsVerdict
1add a book then list itThe happy path works at all.ok
2search for a book that is presentSearch finds what is there.ok
3search for a book that is absentAbsence handled politely.ok
4borrow a book then listBorrowed books are filtered out.ok
5borrow the same book twiceCrashes, and the message is a lie.bug
6borrow a book that is absentA typo kills the application.bug
7return a book that was never borrowedCrashes; “not found or not borrowed”.bug
8borrow then returnThe round trip restores availability.ok
9a plain title survives a restartPersistence works.ok
10a title containing a semicolon survives a restartSilent data corruption.bug
11a borrowed book is still borrowed after a restartThe borrowed flag persists.ok
12a choice that is not on the menuValidation exists and works.ok
13a choice that is not a numberCrashes on non-numeric input.bug

1 · add a book then list it  ok

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n2\n6\n' | LMS
Enter book title: Enter book author: Book added successfully!
Available books:
Dune by Frank Herbert (Available)

Proves: The happy path works at all. Every other scenario is meaningless if this one is broken.

2 · search for a book that is present  ok

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n3\nDune\n6\n' | LMS
Enter the title of the book to search: Dune by Frank Herbert (Available)

Proves: Search finds what is there, and Book.toString() renders it as the brief specifies.

3 · search for a book that is absent  ok

rm -f libraryState.txt
printf '3\nGone With The Wind\n6\n' | LMS
Enter the title of the book to search: Book not found.

Proves: Absence is handled gracefully here. Remember this one — scenario 6 asks the same question a different way and gets a very different answer.

4 · borrow a book then list  ok

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n4\nDune\n2\n6\n' | LMS
Enter the title of the book to borrow: Book borrowed successfully!
Available books:          ← empty: the borrowed book is filtered out

Proves: viewAvailableBooks() really does filter on the borrowed flag, rather than returning everything.

5 · borrow the same book twice  bug

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n4\nDune\n4\nDune\n6\n' | LMS
Enter the title of the book to borrow: Exception in thread "main"
  java.lang.RuntimeException: java.io.IOException: Book not found: Dune
	at com.solirius.intermediate.library.Library.borrowBook(Library.java:75)

Proves: Two defects at once. The application crashes, and the message is false — the library is holding Dune; it is merely on loan. Someone reading this in a log would search the catalogue, find the book, and conclude the logs are unreliable.

6 · borrow a book that is absent  bug

rm -f libraryState.txt
printf '4\nGone With The Wind\n6\n' | LMS
Enter the title of the book to borrow: Exception in thread "main"
  java.lang.RuntimeException: java.io.IOException: Book not found: Gone With The Wind

Proves: A user typo kills the application. Compare with scenario 3: the same missing book, asked about politely, produced a polite answer.

7 · return a book that was never borrowed  bug

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n5\nDune\n6\n' | LMS
Enter the title of the book to return: Exception in thread "main"
  java.lang.RuntimeException: java.io.IOException:
  Book not found or not borrowed: Dune          ← which of the two is it?

Proves: The word “or” in that message is the smell. Two distinct situations have been collapsed into one branch, so the message cannot tell you which happened.

8 · borrow then return  ok

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n4\nDune\n5\nDune\n2\n6\n' | LMS
Enter the title of the book to return: Book returned successfully!
Available books:
Dune by Frank Herbert (Available)

Proves: The full round trip: borrowing then returning restores availability. This is the control for scenarios 5 and 7 — it proves borrow and return work when used correctly.

9 · a plain title survives a restart  ok

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n6\n' | LMS          # session 1
printf '2\n6\n' | LMS          # session 2
Library state loaded successfully.
Available books:
Dune by Frank Herbert (Available)

Proves: Persistence works. The file is written on exit and read on startup. This is the control that makes scenario 10 a precise accusation instead of a vague one.

10 · a title containing a semicolon survives a restart  bug

rm -f libraryState.txt
printf '1\nComing Home; A Novel\nRosamunde Pilcher\n6\n' | LMS          # session 1
printf '2\n6\n' | LMS          # session 2
Library state loaded successfully.
Available books:
Coming Home by  A Novel (Available)          ← Rosamunde Pilcher has vanished

Proves: Silent data corruption, the worst category there is. No exception, no warning, no log line — just a book with a truncated title and an author that is half a title. The save was faithful; the loss happened on the way back in.

Trace it, because the consequence is worse than it looks

Borrow the book first, so the saved flag is true:

rm -f libraryState.txt
printf '1\nComing Home; A Novel\nRosamunde Pilcher\n4\nComing Home; A Novel\n6\n' | LMS
cat libraryState.txt
Coming Home; A Novel;Rosamunde Pilcher;true

The file is perfect. Every character the user typed is there, and the book is correctly recorded as on loan. Now watch split(";") take it apart on the way back in:

parts[0] = "Coming Home"         → title
parts[1] = " A Novel"            → author
parts[2] = "Rosamunde Pilcher"   → Boolean.parseBoolean(...) → false
parts[3] = "true"                ← the real borrowed flag, silently discarded

Every field has shifted one place left. The author became the borrowed flag, and the borrowed flag fell off the end of a three-field read.

printf '2\n6\n' | LMS
Available books:
Coming Home by  A Novel (Available)

A book that is out on loan has come back marked available. That is no longer just a display problem — the library has lost track of a loan, and will now happily lend the same copy to a second person. Losing the author is embarrassing; losing a loan is a correctness failure in the core domain.

Why nothing complained — the lenient parser

Boolean.parseBoolean("Rosamunde Pilcher") returns false. It does not throw. The method returns true only for a case-insensitive match on "true", and treats every other string in existence as false — including obvious garbage.

That is what makes the bug silent. A stricter parser would have thrown on "Rosamunde Pilcher" and this would have been found the first time anyone saved a book with a semicolon in the title. Lenient parsers turn loud bugs into quiet ones.

Worth having ready: it is a good, specific answer to “what would you change?” that is not simply “escape the delimiter”.

11 · a borrowed book is still borrowed after a restart  ok

rm -f libraryState.txt
printf '1\nDune\nFrank Herbert\n4\nDune\n6\n' | LMS          # session 1
printf '2\n3\nDune\n6\n' | LMS          # session 2
Available books:          ← empty, so the flag survived
Enter the title of the book to search: Dune by Frank Herbert (Borrowed)

Proves: The third field of the saved format survives too, not just the title and author. Combined with scenario 10, this narrows the corruption to fields one and two.

12 · a choice that is not on the menu  ok

rm -f libraryState.txt
printf '9\n6\n' | LMS
Enter your choice: Invalid choice. Please try again.

Proves: The menu does have validation. There is a working default: branch. Hold on to this — scenario 13 shows where that validation sits.

13 · a choice that is not a number  bug

rm -f libraryState.txt
printf 'banana\n6\n' | LMS
Enter your choice:
Exception in thread "main" java.util.InputMismatchException
	at java.base/java.util.Scanner.nextInt(Scanner.java:2251)
	at com.solirius.intermediate.library.Main.main(Main.java:72)

Proves: Same class of user mistake as scenario 12, opposite outcome. scanner.nextInt() throws before the switch is ever reached, so the validation in scenario 12 can only catch mistakes that happen to parse as an integer.

Run all thirteen in one go

If you would rather watch the lot in sequence, paste this. It labels each scenario and strips the repeated menu so the output stays readable.

cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn -q clean compile

RUN() { java -cp target/classes com.solirius.intermediate.library.Main 2>&1 \
        | grep -vE '^[0-9]\. |^Menu:|^$|^Welcome|^Thank you|^Could not load|^Library state|^Saving'; }
one() { echo; echo "===== $1 ====="; rm -f libraryState.txt; shift
        for k in "$@"; do printf "%b" "$k" | RUN; done; }

one "1. add a book then list it"                    '1\nDune\nFrank Herbert\n2\n6\n'
one "2. search for a book that is present"          '1\nDune\nFrank Herbert\n3\nDune\n6\n'
one "3. search for a book that is absent"           '3\nGone With The Wind\n6\n'
one "4. borrow a book then list"                    '1\nDune\nFrank Herbert\n4\nDune\n2\n6\n'
one "5. borrow the same book twice"                 '1\nDune\nFrank Herbert\n4\nDune\n4\nDune\n6\n'
one "6. borrow a book that is absent"               '4\nGone With The Wind\n6\n'
one "7. return a book that was never borrowed"      '1\nDune\nFrank Herbert\n5\nDune\n6\n'
one "8. borrow then return"                         '1\nDune\nFrank Herbert\n4\nDune\n5\nDune\n2\n6\n'
one "9. a plain title survives a restart"           '1\nDune\nFrank Herbert\n6\n' '2\n6\n'
one "10. a semicolon title survives a restart"      '1\nComing Home; A Novel\nRosamunde Pilcher\n6\n' '2\n6\n'
one "11. a borrowed book survives a restart"        '1\nDune\nFrank Herbert\n4\nDune\n6\n' '2\n3\nDune\n6\n'
one "12. a choice that is not on the menu"          '9\n6\n'
one "13. a choice that is not a number"             'banana\n6\n'

rm -f libraryState.txt

The three pairs that make this list worth having

The scenarios were not chosen one at a time. They were chosen in pairs, so that each bug has a control sitting beside it. That is what turns “this crashes” into a precise statement about what is broken.

Pair one · scenarios 3 and 6

Both ask the application about a book it does not have.

3. search for it  → "Book not found."       handled politely
6. borrow it      → crash                  same absence, app dies

The conclusion you can draw only from the pair: the codebase knows how to handle a missing book — searchBook does it correctly. So this is not an oversight about absence in general; it is borrowBook specifically choosing to throw where its neighbour returns. Scenario 3 alone proves nothing; scenario 6 alone might mean “nobody thought about missing books”. Together they say the codebase is inconsistent with itself, which is a much sharper criticism.

Pair two · scenarios 9 and 10

Both save a book and restart.

 9. title "Dune"                 → round-trips perfectly
10. title "Coming Home; A Novel" → author destroyed

The conclusion you can draw only from the pair: persistence works. The file is written, read, and the flag survives. So the defect is not “save/load is broken” — it is precisely and only the separator character appearing in the data. Without scenario 9 you could not rule out the simpler explanation, and your bug report would be vaguer.

Scenario 11 is the third leg of the same control: it proves the isBorrowed flag persists too, so field three is fine and only fields one and two shift.

Pair three · scenarios 12 and 13 — the sharpest one

Both are a user fumbling the menu.

12. type "9"      → "Invalid choice. Please try again."
13. type "banana" → InputMismatchException, application dies

The conclusion you can draw only from the pair: the menu does have validation — there is a working default: branch in the switch. The problem is that the guard is on the wrong thing. It validates the value (is this number one of 1–6?) but never the type (is this a number at all?), because scanner.nextInt() throws before the switch is ever reached.

That is a genuinely good thing to say in an interview. “It crashes on bad input” is an observation. “There is validation, but it sits after the parse instead of before it, so it can only ever catch the mistakes that happen to parse” is a diagnosis.

Why the eight healthy scenarios must be in the approved file

It is tempting to record only the broken ones. Do not. The controls do two jobs that matter later:

What is deliberately NOT covered, and why

Worth knowing, because “what did you not test?” is a fair question:

Step 4 · Run it and watch it fail

~/.local/maven/bin/mvn test

It will fail, and that is correct — there is no approved file yet, so ApprovalTests has nothing to compare against. You will see:

[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 <<< FAILURE!
   -- in com.solirius.intermediate.library.GoldenMasterTest

Failed Approval
  Approved:.../library/GoldenMasterTest.behavesAsApproved.approved.txt
  Received:.../library/GoldenMasterTest.behavesAsApproved.received.txt

And just above the error, QuietReporter prints the exact command you need to approve it:

[Aug 31, 2026, 12:26:46 PM ~000000ms] mv -f \
  .../GoldenMasterTest.behavesAsApproved.received.txt \
  .../GoldenMasterTest.behavesAsApproved.approved.txt
Do not run that command yet

Step 5 first. Approving a file you have not read is how you enshrine something you did not mean to.

Step 5 · Read what you are about to approve

The file landed next to the test source, not in target/:

cd ~/Desktop/java-guild-candidate-test-intermediate-main
less src/test/java/com/solirius/intermediate/library/GoldenMasterTest.behavesAsApproved.received.txt

It is long, because it contains the menu thirteen times over. To see only the interesting lines, run this:

grep -E '^===|^!!|Coming Home|Available books:|by ' \
  src/test/java/com/solirius/intermediate/library/GoldenMasterTest.behavesAsApproved.received.txt

Four things in there should stop you:

=== borrow the same book twice ===
!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune

=== borrow a book that is absent ===
!! java.lang.RuntimeException: java.io.IOException: Book not found: Gone With The Wind

=== return a book that was never borrowed ===
!! java.lang.RuntimeException: java.io.IOException: Book not found or not borrowed: Dune

=== a choice that is not a number ===
!! java.util.InputMismatchException: null

=== a title containing a semicolon survives a restart ===
Available books:
Coming Home by  A Novel (Available)
Understand what you are signing off

You are about to approve 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 and 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.

Step 6 · Approve it

Now rename the received file to approved. Copy the mv -f line the test printed, or run this:

cd ~/Desktop/java-guild-candidate-test-intermediate-main
D=src/test/java/com/solirius/intermediate/library
mv -f $D/GoldenMasterTest.behavesAsApproved.received.txt \
      $D/GoldenMasterTest.behavesAsApproved.approved.txt

Step 7 · Confirm it is green

~/.local/maven/bin/mvn test

Expected, exactly:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

If it is still red, the most likely cause is that the received file changed between runs — usually a stray libraryState.txt left in the project root. Delete it and try again:

rm -f libraryState.txt && ~/.local/maven/bin/mvn test

Step 8 · Commit it

Copy this whole block. The git commit -F - form reads the message from the heredoc below it, so the formatting is preserved exactly.

cd ~/Desktop/java-guild-candidate-test-intermediate-main

git add pom.xml .gitignore
git add src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java
git add src/test/java/com/solirius/intermediate/library/GoldenMasterTest.behavesAsApproved.approved.txt

git commit -F - <<'EOF'
test: pin existing behaviour with a golden master

The changes that follow touch every class, including the exception types
and the persistence format. This captures what the application does today
across thirteen scenarios, driven through main(), so that unintended
changes appear 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.

Uses ApprovalTests (test scope) with QuietReporter, so a failing run
writes a .received.txt file and never tries to open a diff tool on the
reviewer's machine.
EOF

git log --oneline

Check the last command printed your commit on top of chore: add starter project as supplied. If you have not made that baseline commit yet, stop and do Lesson 0007 step 1 now — it is the most important commit in the repository.


The payoff, so you know what this was for

Once the fixes from Lessons 0004–0006 land, this test goes red and you diff the two files. Here is the actual diff the finished work produces — menus elided, nothing else trimmed:

-!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
+Enter the title of the book to borrow: 'Dune' is already borrowed
+Saving library state...
+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, every crash replaced by a message, and the application now surviving to save its state. Commit that diff and you have handed the assessor one artifact that proves, through the program’s own front door, exactly what you improved.

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 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, undo the incidental change rather than shrugging and re-approving. If you do want the change, give it its own commit and its own message.

How to re-approve later, when the change IS intended

D=src/test/java/com/solirius/intermediate/library
# 1. Look at what moved.
diff -u $D/GoldenMasterTest.behavesAsApproved.approved.txt \
        $D/GoldenMasterTest.behavesAsApproved.received.txt

# 2. Only if every hunk is deliberate:
mv -f $D/GoldenMasterTest.behavesAsApproved.received.txt \
      $D/GoldenMasterTest.behavesAsApproved.approved.txt

# 3. Commit the re-approval WITH the change that caused it, never on its own.

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 renamed 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.

Why QuietReporter rather than the default reporter?

  1. The default reporter cannot write received files when a test has failed
  2. QuietReporter is the only reporter compatible with JUnit 5 and Surefire
  3. It suppresses the failure message, keeping the build output much shorter
  4. The default tries to open a diff tool, which is wrong on someone else’s machine

Both write the received file. The difference is what they do after writing it.

Several reporters work with JUnit 5, including JunitReporter and DiffReporter. Compatibility is not the issue.

It still fails loudly and still prints both file paths — and it prints the mv command too. It is not quieter about failing, only about launching things.

This is the reason. An assessor running mvn test should never have a GUI window open on their machine, and a build server would hang or error. Choosing a headless reporter is a small decision that shows you thought about who runs your code.

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 you approved it. 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.

Read this

Primary source

Emily Bache — the Gilded Rose Refactoring Kata, with the kata description at Samman Coaching. Do the Java version 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 approvaltests.com for the library’s own documentation, and Michael Feathers, Working Effectively with Legacy Code, chapter 13 — the definition of a characterisation test.

Carry on

If any step above does not do what it says it will, tell me what you saw and I will fix the lesson. Every command on this page was run against the real starter project before it was published.