Java Guild course · Lesson 0004
Lesson 0004 · the first real red bar
Three unused classes, three required scenarios, and the highest-value commit in the whole submission.
This is the lesson where TDD actually starts. Everything so far has characterised behaviour that existed. Now you are going to write a test for behaviour that does not exist, watch it fail for the right reason, and make it pass.
Library
Add to LibraryTest. Note that each test asserts the exception
type and its message — the message is where the two conflated
cases get pulled apart.
@Test
@DisplayName("refuses to borrow a book that is already out")
void refusesToBorrowABookAlreadyOut() throws Exception {
library.addBook(new Book("Dune", "Frank Herbert"));
library.borrowBook("Dune");
AlreadyBorrowedException thrown = assertThrows(AlreadyBorrowedException.class,
() -> library.borrowBook("Dune"));
assertEquals("'Dune' is already borrowed", thrown.getMessage());
}
Write the sibling tests for BookNotFoundException (borrowing
something absent) and NotBorrowedException (returning something
never borrowed). Then run mvn test.
[ERROR] Tests run: 10, Failures: 3, Errors: 0, Skipped: 0
LibraryTest.refusesToBorrowABookAlreadyOut:31
Unexpected exception type thrown,
expected: <...exceptions.AlreadyBorrowedException> but was: <java.io.IOException>
LibraryTest.refusesToBorrowAnUnknownBook:20
Unexpected exception type thrown,
expected: <...exceptions.BookNotFoundException> but was: <java.io.IOException>
LibraryTest.refusesToReturnABookNeverBorrowed:41
Unexpected exception type thrown,
expected: <...exceptions.NotBorrowedException> but was: <java.io.IOException>
“expected AlreadyBorrowedException but was IOException” is a red bar failing for exactly the reason you intended. It is pointing at the design defect, in words, in the build output.
Compare with the red bars that do not count: a compile error in an
unrelated file, a NullPointerException from a fixture you forgot to
set up, or a test that fails on an assertion you did not mean to make. Red is not
the goal — red for the stated reason is.
Getting to green is ten minutes of work. The marks are in three choices you make on the way, each of which an interviewer can push on.
The supplied classes extends Exception — checked. You may change
that. Should you?
Checked (extends Exception) | Unchecked (extends RuntimeException) | |
|---|---|---|
| For | The caller can recover — the console prints a message and carries on. Bloch Item 70: use checked for recoverable conditions. It is also what you were handed. | No throws clauses cluttering signatures. Arguably these are avoidable by asking first. |
| Against | throws propagates up through every caller. | “Avoidable by asking first” is false in general — between the check and the borrow, another thread could take the book. |
Keep them checked. These are recoverable conditions with an obvious recovery, which is Oracle's own stated rule of thumb. And the classes were handed to you as checked exceptions — changing that is a claim that needs a reason, and there isn't one here.
In interview: “Checked, because the caller has a real recovery — the menu prints the message and loops. If this were a library API used by code that couldn't do anything useful about it, I'd reconsider.”
borrowBook can now throw two different exceptions.
returnBook, two more. The console's response to all of them is
identical: print the message, keep going. Without a supertype:
try {
library.borrowBook(title);
} catch (BookNotFoundException e) {
out.println(e.getMessage());
} catch (AlreadyBorrowedException e) {
out.println(e.getMessage()); // ...identical. And it grows.
}
So introduce LibraryException extends Exception and have the three
extend it. The console catches one type; code that needs to distinguish them still
can.
} catch (LibraryException e) {
out.println(e.getMessage());
}
A common supertype makes it easy to catch too broadly and lose the
distinction you just built. If some caller genuinely needs to treat
“absent” differently from “on loan”, a blanket
catch (LibraryException) hides that. The defence: the subtypes still
exist and are still thrown, so any caller that needs the distinction can have it.
You have added an option, not removed one.
booleanOnce the method throws on failure, boolean is unreachable — it can
only ever return true. Two ways to report one outcome is one too many.
public boolean borrowBook(String title) throws IOException
public void borrowBook(String title)
throws BookNotFoundException, AlreadyBorrowedException
The signature now is the documentation: this either works, or it tells you precisely which of two things went wrong.
The README specifies boolean borrowBook(String title). You are
changing the return type. That is defensible and probably wanted, but it must be
declared, not smuggled. Put a short “Deviations from the
brief” section in your README:
borrowBook/returnBookreturnvoidrather thanboolean. With the required exception handling in place the boolean is unreachable — the methods can only returntrue— so it would be a return value no caller could act on.
An assessor who sees a deviation with a reason reads judgement. One who sees a deviation with no reason reads carelessness. Same diff, opposite marks.
nullsearchBook returns null when nothing matches, and the
menu duly does foundBook != null ? …. Return
Optional<Book> instead:
public Optional<Book> searchBook(final String title) { ... }
// and the absence check becomes impossible to forget:
out.println(library.searchBook(title).map(Object::toString).orElse("Book not found."));
It also gives you a clean way to remove the duplicated “is it
there?” check from both borrowBook and returnBook:
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("No book titled '" + title + "'"));
}
Optional too
The javadoc's own API Note says Optional is intended for
return types where absence must be handled. Not fields, not parameters,
not collections — an empty List already expresses emptiness. If you
say “Optional everywhere” in an interview you will be
corrected; say “Optional on a return type where absence is a
normal outcome” and you will not.
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
And your golden master from Lesson 0002 now fails — correctly. Diff it. You should see three stack traces become three sentences:
-!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
+Enter the title of the book to borrow: 'Dune' is already borrowed
Re-approve it in the same commit as the fix, so the diff and its cause travel together.
Why is assertThrows preferable to a try/fail/catch block?
The stack trace is filled in either way. Speed is not a consideration in a test that runs in two milliseconds.
The old try/fail/catch idiom handles checked exceptions perfectly well. It is just verbose and easy to get wrong.
This is the practical win. assertThrows hands back the thrown exception, so you can go on to assert the message — which is what separates “it threw something” from “it correctly distinguished already borrowed from not found”. That distinction is the entire defect.
True, and a real advantage over a hand-written try/catch that forgets its fail() — but it is not the reason to reach for it here.
What is the most precise criticism of throw new IOException("Book not found: " + title) when the book is present but on loan?
True, and a real defect — but it is the criticism of the type. The question asks about this specific case, where something worse is happening.
This is the sharpest version. The type is merely wrong; the message is actively false. It says the library has no such book while holding it. Someone debugging a support ticket will search the catalogue, find Dune, and conclude the logs are unreliable. Wrong types cost minutes; lying messages cost hours.
A micro-optimisation with no bearing on correctness, and irrelevant on an exception path that runs once per user mistake.
That is a property of every checked exception including the ones you are replacing it with, so it cannot be the criticism.
You change borrowBook from boolean to void. The brief specified boolean. What is the right move?
The brief says “visibility at candidate discretion” throughout and marks “proper use of OOP principles”. It is inviting judgement, not compliance. A boolean no caller can act on is worse code.
Two ways to do one thing, one of which is dead. This doubles the API surface to avoid a sentence in a README.
The reasoning is evident to you. To an assessor with forty submissions and a checklist, an unexplained deviation from a stated signature is indistinguishable from not having read the brief.
Right. The deviation is good; the silence would be the mistake. One sentence converts “did not follow the spec” into “read the spec, disagreed for a stated reason” — and the second is what “proper use of OOP principles” is asking to see.
Replace IOException with the supplied domain exceptions
borrowBook and returnBook signalled every failure with IOException,
although neither performs any I/O, and collapsed two distinct cases
into one branch: borrowing an already-borrowed book reported
"Book not found" for a book the library was holding.
Wires up AlreadyBorrowedException, BookNotFoundException and
NotBorrowedException, which were present in the repository but
unreferenced, and adds LibraryException as a common supertype so the
console can report any domain failure uniformly.
The boolean return values are dropped: with failure signalled by an
exception, both methods could only ever return true.
Golden master re-approved in this commit: three stack traces become
three messages, and the application now survives to save its state.
Oracle Java Tutorial — “Unchecked Exceptions: The Controversy”. Two pages. It ends with the rule of thumb you should be able to quote: “If a client can reasonably be expected to recover from an exception, make it a checked exception.” Being able to attribute that to Oracle rather than to your own taste is what makes the answer land.
Then the Optional javadoc,
class-level text only — especially the API Note on intended use.
Push back on any of these calls if you disagree — especially checked-versus-unchecked. An interviewer may well argue the other side to see whether you fold, and rehearsing that argument with me is cheaper than discovering your position is thin on the day.