Java Guild course · The Rebuild · Phase 2
Phase 2 · commits 6–11
Six commits, no production code touched. By the end, every defect in the supplied program is written down in a file that fails if it silently changes — including four crashes and one case of silent data loss.
Every commit below is a numbered list of edits, then a verification, then the
commit. mvn means ~/.local/maven/bin/mvn with
JAVA_HOME set — see the setup block.
Where a step says find this / replace with this, the “find”
block is the code exactly as it stands after the previous commit.
test · 8a0032e · ADR 0001 →
The project ships with no tests, and the changes that follow touch every class in it. This drives the real main() with scripted keystrokes and approves the transcript, so an unintended change appears as a diff.
Step 1. Create docs/adr/0001-pin-behaviour-with-a-golden-master-first.md:
# 1. Pin behaviour with a golden master before changing anything
Date: 2026-09-02
## Status
Accepted
## Context
The supplied project has no tests at all — `mvn test` reports *No tests to
run*. The work ahead touches every class in it: the exception types, the
persistence format, and the whole menu loop.
Unit tests alone are not enough to start with. Writing them requires deciding
what each unit *should* do, and those decisions are exactly what the refactor
is going to change. I need something that tells me when I have changed
behaviour I did not mean to change, before I have any opinion about what the
behaviour ought to be.
## Decision
Drive the real `main()` with scripted keystrokes, capture everything the user
would see, and approve that transcript as the baseline.
The approved file records current behaviour, defects included. Where the
application crashes, the crash is captured in the transcript rather than
failing the test. It asserts *what the program does*, not what it should do.
ApprovalTests is used at test scope, with `QuietReporter` so a failing run
writes a `.received.txt` file instead of trying to launch a diff tool on the
reviewer's machine.
## Consequences
- Any change to observable behaviour shows up as a diff I must consciously
re-approve, which becomes the record of every behavioural fix that follows.
- Pure refactorings are provably pure: the approved file does not move.
- The baseline is deliberately *wrong* in places. Approving a crash feels
strange, but it is the point — the crash is what exists, and the commit that
removes it is the one that has to justify itself.
- The transcript is coarse. It catches "something changed" and not "this
method is wrong", so unit tests are still needed underneath it.
Step 2. In pom.xml, find:
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.47.2.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.15.2</version>and replace it with:
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.47.2.0</version>
</dependency>
<dependency>
<groupId>com.approvaltests</groupId>
<artifactId>approvaltests</artifactId>
<version>24.22.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.15.2</version>
Step 3. Create src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java:
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;
/**
* Records what the application does today, before any of it is changed.
*
* <p>This asserts nothing about what the program <em>should</em> do. Its only
* job is to make an unintended change show up as a diff.
*/
@UseReporter(QuietReporter.class)
@DisplayName("The application as a whole")
class GoldenMasterTest {
private static final Path STATE_FILE = Path.of("libraryState.txt");
private static final List<Scenario> SCENARIOS = List.of(
new Scenario("add a book then list it",
"1\nDune\nFrank Herbert\n2\n6\n")
);
@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() {
Approvals.verify(transcriptOf(SCENARIOS));
}
private String transcriptOf(final List<Scenario> scenarios) {
StringBuilder transcript = new StringBuilder();
for (Scenario scenario : scenarios) {
transcript.append("=== ").append(scenario.name).append(" ===\n")
.append(runApplication(scenario.keystrokes));
transcript.append('\n');
}
return transcript.toString();
}
/**
* Runs main() to completion against a scripted session and returns
* everything the user would have seen, including a crash if one happens.
*/
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);
}
private static final class Scenario {
private final String name;
private final String keystrokes;
Scenario(final String theName, final String theKeystrokes) {
this.name = theName;
this.keystrokes = theKeystrokes;
}
}
}
Step 4. Take the baseline. The first run has nothing to compare against, so it fails and writes a .received.txt. Read it, satisfy yourself it is what the program really does, then approve it:
mvn clean test # fails: no approved file yet
cd src/test/java/com/solirius/intermediate/library
mv GoldenMasterTest.behavesAsApproved.received.txt \
GoldenMasterTest.behavesAsApproved.approved.txt
cd -
mvn clean test # now green
Step 5. Verify:
mvn clean test
# Tests run: 1, Failures: 0, Errors: 0
Step 6. Commit:
git add -A
git commit -F- <<'EOF'
test: pin the add-and-list journey with a golden master
The project ships with no tests, and the changes that follow touch every
class in it. This drives the real main() with scripted keystrokes and
approves the transcript, so an unintended change appears as a diff.
The transcript records what the application does today, defects and all;
it is not a statement of what it should do. Unit tests specify that.
Starts with one scenario so the harness itself is reviewable. The
remaining journeys are added next, one commit at a time.
Decision recorded in docs/adr/0001-pin-behaviour-with-a-golden-master-first.md
EOF
test · 1d35fb6
Seven more journeys, one per menu option plus the failure paths.
Step 1. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
private static final Path STATE_FILE = Path.of("libraryState.txt");
private static final List<Scenario> SCENARIOS = List.of(
new Scenario("add a book then list it",
"1\nDune\nFrank Herbert\n2\n6\n")
);
@BeforeEach
@AfterEachand replace it with:
private static final Path STATE_FILE = Path.of("libraryState.txt");
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")
);
@BeforeEach
@AfterEach
Step 2. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
void behavesAsApproved() {
Approvals.verify(transcriptOf(SCENARIOS));
}
private String transcriptOf(final List<Scenario> scenarios) {
StringBuilder transcript = new StringBuilder();
for (Scenario scenario : scenarios) {
transcript.append("=== ").append(scenario.name).append(" ===\n")
.append(runApplication(scenario.keystrokes));
transcript.append('\n');
}and replace it with:
void behavesAsApproved() {
Approvals.verify(transcriptOf(SCENARIOS));
}
private void deleteStateFile() {
try {
Files.deleteIfExists(STATE_FILE);
} catch (java.io.IOException e) {
throw new IllegalStateException("could not clear " + STATE_FILE, e);
}
}
private String transcriptOf(final List<Scenario> scenarios) {
StringBuilder transcript = new StringBuilder();
for (Scenario scenario : scenarios) {
deleteStateFile();
transcript.append("=== ").append(scenario.name).append(" ===\n")
.append(runApplication(scenario.keystrokes));
transcript.append('\n');
}
Step 3. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:
mvn clean test # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
GoldenMasterTest.behavesAsApproved.approved.txt
cd -
Step 4. Verify:
mvn clean test
# Tests run: 1, Failures: 0, Errors: 0
Step 5. Commit:
git add -A
git commit -F- <<'EOF'
test: extend the golden master to every menu option
Seven more journeys, one per menu option plus the failure paths.
Three of them record a crash rather than a message:
!! RuntimeException: IOException: Book not found: Dune
!! RuntimeException: IOException: Book not found: Gone With The Wind
!! RuntimeException: IOException: Book not found or not borrowed: Dune
The first is the interesting one. Borrowing an already-borrowed book
reports "Book not found" for a book the library is holding, because
Library.borrowBook collapses "absent" and "already out" into one
branch. Freezing it here means the commit that fixes it has to show the
message changing.
Adding a second scenario also required clearing the state file between
scenarios: the first journey saves libraryState.txt on exit, which the
next would otherwise load.
EOF
test · eb7ac89
Persistence is only observable across two runs of the application, so a scenario is now a list of sessions sharing one state file rather than a single session.
Step 1. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
"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")
);
@BeforeEach
@AfterEachand replace it with:
"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")
);
@BeforeEach
@AfterEach
Step 2. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
private String transcriptOf(final List<Scenario> scenarios) {
StringBuilder transcript = new StringBuilder();
for (Scenario scenario : scenarios) {
deleteStateFile();
transcript.append("=== ").append(scenario.name).append(" ===\n")
.append(runApplication(scenario.keystrokes));
transcript.append('\n');
}
return transcript.toString();
}and replace it with:
private String transcriptOf(final List<Scenario> scenarios) {
StringBuilder transcript = new StringBuilder();
for (Scenario scenario : scenarios) {
deleteStateFile();
transcript.append("=== ").append(scenario.name).append(" ===\n");
for (int run = 0; run < scenario.sessions.length; run++) {
transcript.append("--- run ").append(run + 1).append('\n')
.append(runApplication(scenario.sessions[run]));
}
transcript.append('\n');
}
return transcript.toString();
}
Step 3. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
}
private static final class Scenario {
private final String name;
private final String keystrokes;
Scenario(final String theName, final String theKeystrokes) {
this.name = theName;
this.keystrokes = theKeystrokes;
}
}
}and replace it with:
}
private static final class Scenario {
private final String name;
private final String[] sessions;
Scenario(final String theName, final String... theSessions) {
this.name = theName;
this.sessions = theSessions;
}
}
}
Step 4. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:
mvn clean test # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
GoldenMasterTest.behavesAsApproved.approved.txt
cd -
Step 5. Verify:
mvn clean test
# Tests run: 1, Failures: 0, Errors: 0
Step 6. Commit:
git add -A
git commit -F- <<'EOF'
test: extend the golden master across a restart
Persistence is only observable across two runs of the application, so a
scenario is now a list of sessions sharing one state file rather than a
single session.
Three journeys: a plain title, a borrowed book, and a title containing
the ';' that the file format uses as its field separator. The third
records silent data loss —
added: "Coming Home; A Novel" by Rosamunde Pilcher
reloaded: Coming Home by A Novel
The author is gone and the title is truncated, with no error on either
side. Nothing in the application reports a problem.
EOF
test · eaa95a9
Scanner.nextInt throws InputMismatchException on any non-digit, which is uncaught, so a single typo terminates the application.
Step 1. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
"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")
);
@BeforeEach
@AfterEachand replace it with:
"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"),
new Scenario("work is lost when the choice is not a number",
"1\nDune\nFrank Herbert\nbanana\n", "2\n6\n")
);
@BeforeEach
@AfterEach
Step 2. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:
mvn clean test # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
GoldenMasterTest.behavesAsApproved.approved.txt
cd -
Step 3. Verify:
mvn clean test
# Tests run: 1, Failures: 0, Errors: 0
Step 4. Commit:
git add -A
git commit -F- <<'EOF'
test: extend the golden master to unparseable menu input
Scanner.nextInt throws InputMismatchException on any non-digit, which is
uncaught, so a single typo terminates the application.
The third scenario records what that costs. A book is added, the user
mistypes the next menu choice, and on restart the library is empty:
Available books:
<nothing>
State is only written on a clean exit through option 6, so everything
added during the session is discarded. This is the most damaging defect
in the supplied code and it is now pinned.
EOF
test · 1ccdcd0
Covers the Graduate requirement for Book. These pass against the code as supplied: they record what it does rather than drive a change.
Step 1. Create src/test/java/com/solirius/intermediate/library/BookTest.java:
package com.solirius.intermediate.library;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("A book")
class BookTest {
private Book aBook() {
return new Book("Dune", "Frank Herbert");
}
@Test
@DisplayName("is available when newly created")
void isAvailableWhenNewlyCreated() {
assertFalse(aBook().isBorrowed());
}
@Nested
@DisplayName("when borrowed")
class WhenBorrowed {
@Test
@DisplayName("reports success the first time")
void reportsSuccessTheFirstTime() {
assertTrue(aBook().borrowBook());
}
@Test
@DisplayName("refuses a second borrow")
void refusesASecondBorrow() {
Book book = aBook();
book.borrowBook();
assertFalse(book.borrowBook(),
"a book already on loan must not be borrowable again");
}
@Test
@DisplayName("describes itself as borrowed")
void describesItselfAsBorrowed() {
Book book = aBook();
book.borrowBook();
assertEquals("Dune by Frank Herbert (Borrowed)", book.toString());
}
}
@Nested
@DisplayName("when returned")
class WhenReturned {
@Test
@DisplayName("refuses if it was never borrowed")
void refusesIfItWasNeverBorrowed() {
assertFalse(aBook().returnBook(),
"returning a book nobody borrowed must not report success");
}
@Test
@DisplayName("becomes available again")
void becomesAvailableAgain() {
Book book = aBook();
book.borrowBook();
book.returnBook();
assertFalse(book.isBorrowed());
}
@Test
@DisplayName("describes itself as available")
void describesItselfAsAvailable() {
assertEquals("Dune by Frank Herbert (Available)",
aBook().toString());
}
}
}
Step 2. Verify:
mvn clean test
# Tests run: 8, Failures: 0, Errors: 0
Step 3. Commit:
git add -A
git commit -F- <<'EOF'
test: characterise Book
Covers the Graduate requirement for Book. These pass against the code as
supplied: they record what it does rather than drive a change.
Each case is chosen so a plausible wrong implementation fails it.
Checked by hand:
- removing the !isBorrowed guard in borrowBook fails refusesASecondBorrow
- removing the isBorrowed guard in returnBook fails
refusesIfItWasNeverBorrowed
- inverting the toString ternary fails both describesItself... cases
A test that only borrows a fresh book and asserts true would pass
against an implementation that ignores the borrowed flag entirely, so
it would not have been worth writing.
EOF
test · 4fc6103
Covers the Graduate requirement for Library. Scope is deliberately the collection behaviour only — adding, listing, hiding what is on loan and looking up by title.
Step 1. Create src/test/java/com/solirius/intermediate/library/LibraryTest.java:
package com.solirius.intermediate.library;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("A library")
class LibraryTest {
private final Library library = new Library();
@Test
@DisplayName("has nothing available when it is empty")
void hasNothingAvailableWhenEmpty() {
assertTrue(library.viewAvailableBooks().isEmpty());
}
@Test
@DisplayName("offers a book that has just been added")
void offersABookThatHasJustBeenAdded() {
library.addBook(new Book("Dune", "Frank Herbert"));
List<Book> available = library.viewAvailableBooks();
assertEquals(1, available.size());
assertEquals("Dune", available.get(0).getTitle());
}
@Test
@DisplayName("hides a borrowed book, and only that book")
void hidesABorrowedBookAndOnlyThatBook() throws Exception {
library.addBook(new Book("Dune", "Frank Herbert"));
library.addBook(new Book("Emma", "Jane Austen"));
library.borrowBook("Dune");
List<Book> available = library.viewAvailableBooks();
assertEquals(1, available.size(), "exactly one of the two is on loan");
assertEquals("Emma", available.get(0).getTitle());
}
@Test
@DisplayName("matches a title regardless of case")
void matchesATitleRegardlessOfCase() {
library.addBook(new Book("Dune", "Frank Herbert"));
assertEquals("Frank Herbert", library.searchBook("dune").getAuthor());
assertEquals("Frank Herbert", library.searchBook("DUNE").getAuthor());
}
@Test
@DisplayName("returns null for a title it does not have")
void returnsNullForATitleItDoesNotHave() {
assertNull(library.searchBook("Gone With The Wind"));
}
}
Step 2. Verify:
mvn clean test
# Tests run: 13, Failures: 0, Errors: 0
Step 3. Commit:
git add -A
git commit -F- <<'EOF'
test: characterise the library as a collection of books
Covers the Graduate requirement for Library. Scope is deliberately the
collection behaviour only — adding, listing, hiding what is on loan and
looking up by title.
The failure behaviour of borrowBook and returnBook is left to the golden
master for now. It is about to change: those methods signal every
failure with IOException, and unit tests written against that would be
deleted two commits later. Each fix brings its own test instead.
searchBook returning null is recorded as current behaviour, not endorsed.
The commit that replaces it with Optional changes this test, which is
the point of writing it down first.
Checked by hand: making viewAvailableBooks skip its filter fails
hidesABorrowedBookAndOnlyThatBook; making searchBook case-sensitive
fails matchesATitleRegardlessOfCase.
EOF