Java Guild course · Lesson 0003

Lesson 0003 · the graduate requirement

Unit Tests That Earn Their Place

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.

The bar

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.

The trap in Book

Book.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.

The general move

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.

Naming: the part that gets read

An assessor reads your test names as a specification, often without opening the bodies. Compare:

Reads as noiseReads 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.

A real wrinkle with @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.

Do this now

Task · 40 minutes

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
Be honest with yourself about what just happened

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.”

Case-insensitivity: a test that asks a question

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.

A question worth raising, not necessarily solving

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.

Drill

Which test of viewAvailableBooks is worth writing?

  1. An empty library returns a list whose size is verified to be exactly zero
  2. A library of two books, one borrowed, returns only the one still available
  3. A library of three books returns a list containing three separate entries
  4. The returned list is confirmed to be a genuine instance of java.util.List

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?

  1. Because uncovered branches reduce the coverage percentage that is reported
  2. Because String comparison in Java varies by the platform default locale
  3. Because the brief explicitly requires searching to ignore letter casing
  4. Because it converts an undocumented accident into a recorded decision

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?

  1. Nothing about the tests — they characterise code that already worked
  2. The tests are too weak, since a good suite finds bugs when first run
  3. Book is correct, since a passing suite is evidence of a correct class
  4. The suite is redundant given the golden master already covers this class

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.

Commit it

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.

Read this

Primary source

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.

Carry on

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.