Java Guild course · Lesson 0003
Lesson 0003 · the graduate requirement
Anyone can reach 100% coverage on Book. The marks are in which cases you chose — and this lesson ends by proving yours were the right ones.
The Graduate requirement is one line: “Write test cases for Book and Library classes.” Every candidate will do this. So it is not where you gain marks — it is where you lose them, by writing the obvious six tests that assert the getters return what the constructor was given.
A test earns its place if some plausible wrong implementation would fail it. If you cannot name the bug a test catches, it is documentation at best and noise at worst.
That sounds like a slogan. Step 5 turns it into an experiment: you will deliberately break the production code and check that exactly the right test goes red.
These tests are written against the code as it is now —
searchBook returns a nullable Book, and
borrowBook returns boolean and throws
IOException. Lesson
0004 changes all three, and you will update these tests then. That is normal:
characterisation tests record today’s behaviour, and today’s behaviour is
what you are about to change.
BookTestCreate this file, at exactly this path:
src/test/java/com/solirius/intermediate/library/BookTest.java
The directory already exists from Lesson 0002. Paste the whole file:
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 static final String TITLE = "Dune";
private static final String AUTHOR = "Frank Herbert";
private Book aBook() {
return new Book(TITLE, AUTHOR);
}
@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");
}
}
@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());
}
@Test
@DisplayName("describes itself as borrowed")
void describesItselfAsBorrowed() {
Book book = aBook();
book.borrowBook();
assertEquals("Dune by Frank Herbert (Borrowed)", book.toString());
}
}
aBook() — a tiny factory so no test repeats the
constructor. When Book gains a third field, you change one line.“a book already on loan must not be borrowable again”).
A failure then reads as a sentence about the domain rather than
expected <false> but was <true>. You will see this pay off in Step 5.@Nested groups the borrow and return cases so the
runner prints A book › when borrowed › refuses a second borrow —
a sentence from the brief, generated by the suite.cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn test
Expect 8 tests — your 7 plus the golden master from Lesson 0002:
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
@Nested
Surefire mis-attributes nested tests in its per-class summary. On this exact suite it printed:
Tests run: 5 ... in when returned // actually has 2
Tests run: 2 ... in when borrowed
Tests run: 0 ... in A book // actually has 3
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 // the total is right
Trust the total. IDEs render the tree correctly. Worth knowing so it does not panic you thirty seconds before you send the zip — and worth mentioning in interview, because noticing it is evidence you read your build output.
1. The filename must match the class. A file called
BootTest.java containing class BookTest will
compile — the class is package-private, so javac allows the mismatch —
but IntelliJ flags it permanently and an assessor will notice. Check:
ls src/test/java/com/solirius/intermediate/library/
If it is wrong: git mv …/BootTest.java …/BookTest.java
(or plain mv if it is not staged yet).
2. Watch the closing brace of WhenReturned. It is
easy to paste the two toString tests inside the nested class.
That compiles and all seven still run, so nothing warns you — but the runner then
prints “A book › when returned › describes itself as
available”, which is nonsense. The two toString tests belong
at the same indent level as isAvailableWhenNewlyCreated.
If your IDE reports “Annotations are not allowed here” while
pasting, that is its parser reacting to temporarily unbalanced braces. It should
clear once the file is complete. The authority is
mvn clean test, not the red squiggle.
LibraryTestsrc/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.assertNotNull;
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 an added book as available")
void offersAnAddedBookAsAvailable() {
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, from the available list")
void hidesOnlyTheBorrowedBook() 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 should remain");
assertEquals("Emma", available.get(0).getTitle());
}
@Test
@DisplayName("matches a title regardless of case, so users need not type it exactly")
void matchesATitleRegardlessOfCase() {
library.addBook(new Book("Dune", "Frank Herbert"));
assertNotNull(library.searchBook("dune"));
assertNotNull(library.searchBook("DUNE"));
}
@Test
@DisplayName("returns null when searching for a book it does not have")
void returnsNullWhenSearchingForAnAbsentBook() {
assertNull(library.searchBook("Gone With The Wind"));
}
}
| Test | The wrong implementation it catches |
|---|---|
| hasNothingAvailableWhenEmpty | Weak on its own — but it pins that an empty library returns an empty list rather than throwing or returning null. |
| offersAnAddedBookAsAvailable | An addBook that silently drops the book. |
| hidesOnlyTheBorrowedBook | The important one. Two books, one borrowed. Catches a viewAvailableBooks that ignores the flag and returns everything. |
| matchesATitleRegardlessOfCase | A search that switched to equals. Also records a decision — see below. |
| returnsNullWhenSearchingForAnAbsentBook | Nothing yet — it characterises the null you are about to remove in Lesson 0004. |
hidesOnlyTheBorrowedBook uses two books
With one book borrowed out of one, “filters correctly” and “returns an empty list always” look identical. With two books and one borrowed, the only implementation that passes is one that actually reads the flag — and the assertion checks which book survived, not just how many.
This is the fixture-selection habit worth having: choose inputs that cannot be right by accident.
searchBook uses equalsIgnoreCase. Nothing in the
brief asked for that. It is a choice the original author made silently.
Naming the test “matches a title regardless of case, so users need not type it exactly” turns an accident into a decision with a reason attached. Behaviour nobody chose is behaviour nobody can safely change; now a reviewer who disagrees knows exactly which test to argue with.
mvn test
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Thirteen: 1 golden master + 7 BookTest + 5 LibraryTest.
Every one of those went green the moment you wrote it, because the behaviour already existed. That is not TDD. These are characterisation tests at unit level — a safety net, and the Graduate requirement discharged. The red-green-refactor starts in Lesson 0004, on behaviour that is genuinely missing.
If an interviewer asks “did you do TDD?”, the strong answer draws the line: “Not for the existing behaviour — that was already written, so those tests characterise it. The exception handling and the persistence fixes were driven test-first, and you can see it in the commit history.”
A green suite tells you nothing on its own. This step is the one that separates these tests from the obvious ones — and it takes four minutes.
Break the production code on purpose, and check that exactly the right test catches it. This is mutation testing, done by hand.
borrowBook always succeedIn Book.java, replace the body of borrowBook() with:
public boolean borrowBook() {
isBorrowed = true;
return true; // the guard is gone
}
mvn test
[ERROR] BookTest.refusesASecondBorrow
a book already on loan must not be borrowable again ==> expected: <false> but was: <true>
[ERROR] GoldenMasterTest.behavesAsApproved » Failed Approval
[ERROR] Tests run: 13, Failures: 1, Errors: 1, Skipped: 0
One unit test caught it: refusesASecondBorrow. And
look at which one did not — reportsSuccessTheFirstTime, the
obvious test that borrows a fresh book and asserts true,
passed against the broken code.
That is the test almost every candidate writes, and it is worthless. It cannot
distinguish a correct borrowBook from one with no guard at all. The
second borrow is where the behaviour lives.
Note also that your custom failure message did the work: the report reads as a
sentence about the domain, not as expected <false> but was <true>.
Now put it back and confirm green before continuing.
viewAvailableBooks ignore the flagIn Library.java, replace the body of viewAvailableBooks() with:
public List<Book> viewAvailableBooks() {
return new ArrayList<>(books); // no filtering at all
}
mvn test
[ERROR] LibraryTest.hidesOnlyTheBorrowedBook
exactly one of the two should remain ==> expected: <1> but was: <2>
[ERROR] GoldenMasterTest.behavesAsApproved » Failed Approval
[ERROR] Tests run: 13, Failures: 1, Errors: 1, Skipped: 0
Again exactly one unit test, and it is the one designed for the job. Restore the code:
git checkout src/main/java/com/solirius/intermediate/library/Book.java \
src/main/java/com/solirius/intermediate/library/Library.java
mvn test
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
It failed both times too — but it only told you something changed, with a five-hundred-line diff to read. The unit test named the method and the expectation in one line.
That is the division of labour, and it is a good interview answer: the golden master is a broad net that catches anything, coarsely; the unit tests are a fine-grained specification that says precisely what broke. You want both, and for different reasons.
cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn test # must be green before committing
git add src/test/java/com/solirius/intermediate/library/BookTest.java
git add src/test/java/com/solirius/intermediate/library/LibraryTest.java
git commit -F - <<'EOF'
test: add unit tests for Book and Library
Covers the Graduate requirement; the project had no src/test directory
and "mvn test" reported "No tests to run".
Cases are chosen so that a plausible wrong implementation would fail.
Verified by hand: removing the guard in Book.borrowBook fails exactly
refusesASecondBorrow, and making viewAvailableBooks skip its filter
fails exactly hidesOnlyTheBorrowedBook. The obvious test - borrowing a
fresh book and asserting true - passes against both broken versions,
which is why it is not the one carrying the weight.
These pass against the existing code, so they characterise rather than
drive it. Behaviour that does not yet exist is driven test-first in the
commits that follow.
EOF
git log --oneline
It states a falsifiable claim — that a specific mutation fails a specific test — which a reviewer can check in two minutes. Almost no candidate will do this, and it converts “I wrote tests” into “I know which of my tests are load-bearing, and I checked.”
An assessor reads your test names as a specification, often without opening the bodies.
| Reads as noise | Reads as a specification |
|---|---|
| testBorrowBook() | refusesASecondBorrow() |
| testBook2() | isAvailableWhenNewlyCreated() |
| testToString() | describesItselfAsBorrowed() |
| testViewAvailable() | hidesOnlyTheBorrowedBook() |
searchBook returns the first match. So a library with three
copies of Dune can only ever lend one of them, and the model has no concept
of a copy or a loan. That is a genuine domain gap.
Do not build it. The brief does not ask for it and scope creep reads as poor judgement. Put it in your README under “things I noticed but deliberately did not build”. Naming a limitation you chose not to fix is a senior move; silently fixing it is not.
Which test of viewAvailableBooks is worth writing?
Worth one line, but an implementation returning an empty list unconditionally would pass it. It discriminates almost nothing.
This is the one, and Step 5 proved it: sabotaging the filter failed exactly this test. Two books with different states is the minimum fixture that can catch a missing filter, and asserting which book survived is what makes it airtight.
If none is borrowed, returning everything and filtering correctly are indistinguishable. Right by accident.
The compiler already guarantees it. A test that cannot fail is not a test.
Why write a test for the case-insensitivity of searchBook at all?
Coverage is a by-product. Writing tests to move a number is how suites fill up with assertions nobody believes.
A real hazard in general — equalsIgnoreCase has locale subtleties — but not why this test belongs. Raise it as a follow-up, not as the reason.
Read it again: the brief says only “Searches for a book by title”. Case-insensitivity is a choice the original author made silently.
This is the point. Behaviour nobody chose is behaviour nobody can safely change. A named test turns an accident into a decision with an owner, and shows the assessor you noticed something the brief did not mention.
In Step 5, removing the guard from borrowBook left reportsSuccessTheFirstTime green. What does that tell you?
Exactly. It is the most commonly written test for this method and it is the least useful one — it pins only the case that was never in doubt. Keep it as documentation of the happy path, but know it is not what protects you.
Deleting it is defensible but not required; it costs one line and records the happy path. The insight is about which tests are load-bearing, not about pruning.
A correct mutation should fail some test, not every test. If it failed everything, your tests would be too coupled to tell you where the problem is.
Line coverage would report borrowBook fully covered in both versions. Coverage tells you a line ran, not that it ran on an input that could distinguish right from wrong.
JUnit 5 User Guide — “Writing Tests”.
Read §2.1 to §2.6: annotations, display names, assertions, nesting. Half an
hour, and it is the section that turns a suite of testFoo() methods into
something an assessor enjoys reading.
Try inventing your own sabotage: change something in Book or
Library and predict which test will catch it before you run
mvn test. If nothing catches it, you have found a test worth adding —
bring it to me.