Java Guild course · Reference card

Reference · testing

TDD Cycle & JUnit 5

The loop, the syntax, and the three kinds of test in this submission.

The loop

StepWhat you doHow you know you did it right
REDWrite the smallest test for behaviour that does not exist.It fails, and the message names the behaviour — not a typo, not a null fixture.
GREENWrite the least code that passes.The whole suite is green, not just the new test.
REFACTORImprove the design. Change no behaviour.No assertion changed. If you edited an expectation, that was not a refactor.
Red for the right reason

A red bar is not the goal. These are the reds that count:

FailureVerdict
expected: <AlreadyBorrowedException>
but was: <java.io.IOException>
Good — names the exact design defect.
expected: <Coming Home; A Novel>
but was: <Coming Home>
Good — shows the data being lost.
cannot find symbol:
class LibraryStorageException
Acceptable — the legitimate first red for a type that does not exist yet. Resolve it to a behavioural red next.
NullPointerException in setUpBad — your fixture is broken, and you have learned nothing about the code.

The three kinds of test in this submission

KindAssertsGreen on arrival?Files
Golden master
(approval / characterisation)
What the program doesYes, by constructionGoldenMasterTest
GoldenMasterTest.
behavesAsApproved.approved.txt
Characterisation unit testWhat existing units doYes — the code already workedBookTest
Driving unit test (real TDD)What the code should doNo — must fail firstLibraryTest (exceptions)
LibraryPersistenceTest
Say this precisely in interview

“The existing behaviour was characterised — those tests were green when written. The new behaviour was driven test-first, and you can see the red in the commit sequence.” Claiming full TDD over a pre-existing implementation is an overclaim anyone can check.

JUnit 5 syntax you need here

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("A library")
class LibraryTest {

    @Test
    @DisplayName("refuses to borrow a book that is already out")
    void refusesToBorrowABookAlreadyOut() { ... }

    @Nested @DisplayName("when borrowed")
    class WhenBorrowed { ... }

    @TempDir Path directory;          // fresh dir per test, auto-deleted

    @BeforeEach void setUp() { ... }
}

Assertions

assertEquals(expected, actual);           // note the order
assertTrue(cond, "message shown on failure");
assertFalse(cond);
assertThrows(BookNotFoundException.class, () -> library.borrowBook("x"));
assertAll(() -> assertEquals(...), () -> assertEquals(...));   // all, not first-fail
The pattern that carries this submission
AlreadyBorrowedException thrown = assertThrows(AlreadyBorrowedException.class,
        () -> library.borrowBook("Dune"));
assertEquals("'Dune' is already borrowed", thrown.getMessage());

assertThrows returns the exception. Asserting the message as well as the type is what proves you pulled the two conflated cases apart — type alone would not.

Capturing console I/O

ByteArrayOutputStream captured = new ByteArrayOutputStream();
new LibraryConsole(
    new Library(),
    new Scanner(new ByteArrayInputStream(input.getBytes(UTF_8))),
    new PrintStream(captured, true, UTF_8)
).run();
return captured.toString(UTF_8);

Always name the charset. PrintStream(OutputStream, boolean) uses the platform default, so a test can pass on your Mac and fail on a build server.

ApprovalTests cheat sheet

<dependency>
    <groupId>com.approvaltests</groupId>
    <artifactId>approvaltests</artifactId>
    <version>24.22.0</version>
    <scope>test</scope>
</dependency>
@UseReporter(QuietReporter.class)   // headless - never opens a diff tool
class GoldenMasterTest {
    @Test
    void behavesAsApproved() {
        Approvals.verify(transcript);   // the whole comparison, one line
    }
}
ThingDetail
File naming<TestClass>.<testMethod>.approved.txt
LocationBeside the test source, in src/test/java/…/ — not in target/
On failureWrites .received.txt, prints both paths and the mv -f command to approve
To approvemv -f the received file over the approved file — after reading it
gitCommit .approved.txt. Add *.received.* to .gitignore
BytecodeJava 8, verified — safe with release 11
Reporters

QuietReporter for anything you hand over. DiffReporter pops your diff tool open and is lovely while working — but change it back before you package, or an assessor running mvn test gets a window on their machine and a build server hangs.

When a test fails for a reason that makes no sense

Stale class files

Symptom: a test fails naming behaviour you have already changed — an old exception type, an old message — and the stack trace points at a line number that, in your current source, is a comment or a blank line.

Expected :class ...AlreadyBorrowedException
Actual   :class java.io.IOException
  at ...Library.borrowBook(Library.java:75)   ← line 75 is javadoc now

Cause: incremental compilation left a .class from before your edit. Most likely after a change to a method signature, a return type or a throws clause.

Fix: mvn clean test. Make clean your default whenever a signature changed.

In IntelliJ: Build → Rebuild Project, and after any pom.xml edit, right-click pom.xml → Maven → Reload Project. When the IDE and Maven disagree, believe Maven — it is what the assessor runs.

Running it

export JAVA_HOME=$(/usr/libexec/java_home -v 25)
~/.local/maven/bin/mvn test                          # everything
~/.local/maven/bin/mvn test -Dtest=LibraryTest       # one class
~/.local/maven/bin/mvn test -Dtest=LibraryTest#refusesToBorrowAnUnknownBook
Surefire and @Nested

Surefire mis-attributes nested tests in its per-class summary. Observed on this project:

Tests run: 5 ... in BookTest$WhenReturned   // actually has 2
Tests run: 2 ... in BookTest$WhenBorrowed
Tests run: 0 ... in BookTest                // actually has 3
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0   // the total is correct

Trust the total. IDEs render the tree correctly.

Primary source

JUnit 5 User Guide — §2 Writing Tests, and the @TempDir section.