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 what you named them.
The Graduate requirement is one line: “Write test cases for Book and Library classes.” Every candidate will do this. It is therefore not where you gain marks — it is where you lose them, by producing 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.
BookBook.borrowBook() returns a boolean. Here is the test almost
everyone writes:
@Test
void testBorrowBook() {
Book book = new Book("Dune", "Frank Herbert");
assertTrue(book.borrowBook());
}
Now ask the question from Lesson 0001: which wrong implementations still
pass this? One that always returns true. One that never sets the
flag. One that lets you borrow the same book five times. The test covers the
method and proves almost nothing.
The behaviour that actually matters is the second call:
@Test
@DisplayName("refuses a second borrow")
void refusesASecondBorrow() {
Book book = new Book(TITLE, AUTHOR);
book.borrowBook();
assertFalse(book.borrowBook());
}
That one cannot pass by accident. Same for returnBook: the
interesting case is returning a book that was never borrowed.
For any method guarding a state transition, the valuable tests are
the transition that is refused and the round trip
— not the happy path. Borrow-twice and return-unborrowed are the two that catch
a missing if.
An assessor reads your test names as a specification, often without opening the bodies. Compare:
| Reads as noise | Reads as a specification |
|---|---|
| testBorrowBook() | refusesASecondBorrow() |
| testBook2() | isAvailableWhenNewlyCreated() |
| testToString() | describesItselfAsBorrowed() |
Then add @DisplayName so the runner prints English:
@DisplayName("A book")
class BookTest {
@Nested @DisplayName("when borrowed")
class WhenBorrowed {
@Test @DisplayName("refuses a second borrow")
void refusesASecondBorrow() { ... }
}
}
Which produces, in any IDE: A book › when borrowed › refuses a second borrow. That is a sentence from the brief, generated by the test suite.
@Nested
Surefire's per-class summary attributes nested tests oddly. On a run of this exact suite it printed:
Tests run: 5 ... in BookTest$WhenReturned ← WhenReturned has 2
Tests run: 2 ... in BookTest$WhenBorrowed
Tests run: 0 ... in BookTest ← outer has 3
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 ← the total is right
The total is correct and IDEs display the tree properly; only Surefire's per-class attribution is confused. 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 actually read your build output.
Write BookTest and LibraryTest. Minimum bar:
BookTest — a new book is available; the first
borrow succeeds; the second borrow is refused; returning an
unborrowed book is refused; a borrow-then-return round trip leaves it
available; toString in both states.
LibraryTest — an added book appears in
available books; a borrowed book disappears from available books;
search is case-insensitive; searching for something absent.
Target: 7 tests in BookTest. Run
mvn test and confirm the count.
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Those tests went green the moment you wrote them, 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 the next lesson, on behaviour that is genuinely missing.
If an interviewer asks “did you do TDD?”, the strong answer distinguishes the two: “Not for the existing behaviour — that was already written, so those tests are characterisation. The exception handling and the persistence fixes were driven test-first, and you can see it in the commit history.”
searchBook uses equalsIgnoreCase. Nothing in the brief
asked for that. So write the test that pins it, and name the test after the
decision:
@Test
@DisplayName("matches a title regardless of case, so users need not type exactly")
void matchesATitleRegardlessOfCase() {
library.addBook(new Book("Dune", "Frank Herbert"));
assertTrue(library.searchBook("dune").isPresent());
}
Now the behaviour is deliberate rather than incidental, and a reviewer who disagrees knows exactly which test to change.
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. Instead 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. It is the only case that distinguishes “filters by borrowed status” from “returns everything” — the whole purpose of the method. Two books with different states is the minimum fixture that can catch a missing filter.
If none is borrowed, returning everything and filtering correctly are indistinguishable. This is the 1 kg fixture problem: 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.
You have written 7 tests for Book and all pass on the first run. What does that tell you?
Correct, and the honest answer. Book is the one class in this repository with no defects in it, so green on arrival is the expected result. The tests still earn their place: they are the net that lets you change Book later, and they discharge a stated requirement.
Sometimes true, but not here — you know from Lesson 0001 exactly where the defects are, and Book is not one of them. Judging a test suite by bugs found on the first run would condemn every regression test ever written.
A passing suite is evidence about your test data. It shows the cases you thought of work, and says nothing about the ones you did not.
The golden master covers Book only through the console, so it cannot isolate a failure to a class or a method. Different job, different granularity.
Add unit tests for Book and Library
Covers the Graduate requirement. The cases are chosen so that a
plausible wrong implementation would fail: borrowing twice, returning
a book that was never borrowed, and a two-book library where exactly
one is on loan.
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.
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.
Send me your BookTest when it is written and I will
review it the way an assessor would — starting with which wrong implementations
would still pass.