Java Guild course · Reference card
Reference · testing
The loop, the syntax, and the three kinds of test in this submission.
| Step | What you do | How you know you did it right |
|---|---|---|
| RED | Write the smallest test for behaviour that does not exist. | It fails, and the message names the behaviour — not a typo, not a null fixture. |
| GREEN | Write the least code that passes. | The whole suite is green, not just the new test. |
| REFACTOR | Improve the design. Change no behaviour. | No assertion changed. If you edited an expectation, that was not a refactor. |
A red bar is not the goal. These are the reds that count:
| Failure | Verdict |
|---|---|
| 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 setUp | Bad — your fixture is broken, and you have learned nothing about the code. |
| Kind | Asserts | Green on arrival? | Files |
|---|---|---|---|
| Golden master (approval / characterisation) | What the program does | Yes, by construction | GoldenMasterTest GoldenMasterTest. behavesAsApproved.approved.txt |
| Characterisation unit test | What existing units do | Yes — the code already worked | BookTest |
| Driving unit test (real TDD) | What the code should do | No — must fail first | LibraryTest (exceptions) LibraryPersistenceTest |
“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.
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() { ... }
}
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
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.
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.
<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
}
}
| Thing | Detail |
|---|---|
| File naming | <TestClass>.<testMethod>.approved.txt |
| Location | Beside the test source, in src/test/java/…/ — not in target/ |
| On failure | Writes .received.txt, prints both paths and the mv -f command to approve |
| To approve | mv -f the received file over the approved file — after reading it |
| git | Commit .approved.txt. Add *.received.* to .gitignore |
| Bytecode | Java 8, verified — safe with release 11 |
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.
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.
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
@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.
JUnit 5 User Guide
— §2 Writing Tests, and the
@TempDir section.