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.
Add these three to LibraryTest. Each asserts the exception
type and its message — the message is where
the two conflated cases get pulled apart, so type alone is not enough.
First the imports at the top of the file:
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
Then the tests:
@Test
@DisplayName("refuses to borrow a book it does not have")
void refusesToBorrowAnUnknownBook() {
BookNotFoundException thrown = assertThrows(BookNotFoundException.class,
() -> library.borrowBook("Gone With The Wind"));
assertEquals("No book titled 'Gone With The Wind'", thrown.getMessage());
}
@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());
}
@Test
@DisplayName("refuses to return a book that was never borrowed")
void refusesToReturnABookNeverBorrowed() throws Exception {
library.addBook(new Book("Dune", "Frank Herbert"));
NotBorrowedException thrown = assertThrows(NotBorrowedException.class,
() -> library.returnBook("Dune"));
assertEquals("'Dune' is not currently borrowed", thrown.getMessage());
}
throws Exception, not throws IOException
It is natural to write throws IOException, because that is what
borrowBook declares today. But you are about to change that, and
the moment you do the test stops compiling:
LibraryTest.java:[57,27] unreported exception
com.solirius.intermediate.library.exceptions.BookNotFoundException;
must be caught or declared to be thrown
throws Exception on a test method is fine — JUnit catches whatever
comes out, and a test signature is not an API anyone depends on. It saves you
editing the clause twice.
cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn clean test
[ERROR] Tests run: 16, Failures: 3, Errors: 0, Skipped: 0
LibraryTest.refusesToBorrowAnUnknownBook
Unexpected exception type thrown,
expected: <...exceptions.BookNotFoundException> but was: <java.io.IOException>
LibraryTest.refusesToBorrowABookAlreadyOut
Unexpected exception type thrown,
expected: <...exceptions.AlreadyBorrowedException> but was: <java.io.IOException>
LibraryTest.refusesToReturnABookNeverBorrowed
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 reds that do not count: a compile error in an unrelated
file, a NullPointerException from a fixture you forgot, 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.
Create src/main/java/com/solirius/intermediate/library/exceptions/LibraryException.java:
package com.solirius.intermediate.library.exceptions;
/**
* Base type for every failure that is meaningful in the library domain.
* <p>
* A common supertype lets a user interface catch and report all domain
* failures uniformly, while code that needs to tell them apart can still
* catch the specific subtype.
* </p>
*/
public class LibraryException extends Exception {
/**
* Constructs a new LibraryException with the specified message.
*
* @param message the detail message
*/
public LibraryException(final String message) {
super(message);
}
}
In each of AlreadyBorrowedException.java,
BookNotFoundException.java and NotBorrowedException.java,
change one word — Exception becomes LibraryException:
public class AlreadyBorrowedException extends Exception { // before
public class AlreadyBorrowedException extends LibraryException { // after
Nothing else in those three files changes.
LibraryFirst the imports:
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import java.util.Optional;
Replace searchBook with this — it returns an
Optional, and gains a private helper that both other methods will use:
/**
* Searches for a book by its title.
*
* @param title the title of the book to search for
* @return the book if present, otherwise an empty optional
*/
public Optional<Book> searchBook(final String title) {
for (Book book : books) {
if (book.getTitle().equalsIgnoreCase(title)) {
return Optional.of(book);
}
}
return Optional.empty();
}
/**
* Finds a book by title or fails, so callers that require the book to
* exist do not each repeat the same absence check.
*
* @param title the title of the book required
* @return the book
* @throws BookNotFoundException if no book has that title
*/
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("No book titled '" + title + "'"));
}
Replace borrowBook:
/**
* Borrows a book by its title.
*
* @param title the title of the book to borrow
* @throws BookNotFoundException if no book has that title
* @throws AlreadyBorrowedException if the book is already on loan
*/
public void borrowBook(final String title)
throws BookNotFoundException, AlreadyBorrowedException {
Book book = requireBook(title);
if (!book.borrowBook()) {
throw new AlreadyBorrowedException(
"'" + book.getTitle() + "' is already borrowed");
}
}
Replace returnBook:
/**
* Returns a book by its title.
*
* @param title the title of the book to return
* @throws BookNotFoundException if no book has that title
* @throws NotBorrowedException if the book is not currently on loan
*/
public void returnBook(final String title)
throws BookNotFoundException, NotBorrowedException {
Book book = requireBook(title);
if (!book.returnBook()) {
throw new NotBorrowedException(
"'" + book.getTitle() + "' is not currently borrowed");
}
}
requireBook holds the “it must exist” rule once,
instead of both methods repeating book == null.|| is gone. Absence and already-borrowed are now
separate branches with separate exceptions — which is the whole
defect from Lesson 0001.void. With failure signalled by an exception the
old boolean could only ever be true.Easy to miss, and Checkstyle’s Sun rules — which this pom runs — will flag it. The comments above those three methods still describe the old signatures:
// borrowBook - it is void now, so there is no return value to document
* @return true if the book is successfully borrowed, otherwise false
* @throws BookNotFoundException if no book has that title
* @throws AlreadyBorrowedException if the book is already on loan
// returnBook
* @return true if the book is successfully returned, otherwise false
* @throws BookNotFoundException if no book has that title
* @throws NotBorrowedException if the book is not currently on loan
// searchBook - it returns an Optional now, never null
* @param title the title of the book to search
* @return the book if found, otherwise null
* @param title the title of the book to search for
* @return the book if present, otherwise an empty optional
A javadoc that contradicts its own signature is worse than no javadoc: it tells a reader something false with apparent authority. It is also exactly the kind of detail that separates “made it compile” from “finished the change”, and the brief marks code readability and organisation explicitly.
Main now breaksCompile and you will get this:
Main.java:[97,56] incompatible types: java.util.Optional<Book>
cannot be converted to Book
Add the imports:
import com.solirius.intermediate.library.exceptions.LibraryException;
import java.util.Optional;
6a. The search case — searchBook now returns an
Optional, so the null check becomes a map/orElse:
// before
Book foundBook = library.searchBook(title);
System.out.println(foundBook != null
? foundBook : "Book not found.");
// after
Optional<Book> foundBook = library.searchBook(title);
System.out.println(foundBook
.map(Object::toString)
.orElse("Book not found."));
6b. The borrow case — this is the block that currently kills the application on a typo:
// before
try {
library.borrowBook(title);
System.out.println("Book borrowed successfully!");
} catch (IOException e) {
throw new RuntimeException(e);
}
// after
try {
library.borrowBook(title);
System.out.println("Book borrowed successfully!");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
6c. The return case — identical change:
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
One catch (LibraryException e) handles both
BookNotFoundException and AlreadyBorrowedException. Without
the supertype you would need two identical catch blocks per call site, and a third
the day someone adds a fourth exception.
And notice what the change actually does: throw new RuntimeException(e)
becomes println(e.getMessage()). A mistyped title stops being
fatal. That single line is the difference between losing the user’s whole
session and printing one helpful sentence.
If you skip straight to running the suite you will see:
LibraryTest.returnsNullWhenSearchingForAnAbsentBook:51
expected: <null> but was: <Optional.empty>
That is not a bug in your change — it is the Lesson 0003 test doing its job. It
characterised searchBook returning null, and you have just
deliberately changed that.
searchBook returns an Optional now, so the assertions you
wrote in Lesson 0003 must
change. This is expected — you characterised behaviour you were about to alter.
// matchesATitleRegardlessOfCase
assertNotNull(library.searchBook("dune"));
assertTrue(library.searchBook("dune").isPresent());
// rename the test too - it no longer describes null
@DisplayName("returns null when searching for a book it does not have")
void returnsNullWhenSearchingForAnAbsentBook() {
assertNull(library.searchBook("Gone With The Wind"));
}
@DisplayName("returns an empty optional when searching for a book it does not have")
void returnsEmptyWhenSearchingForAnAbsentBook() {
assertTrue(library.searchBook("Gone With The Wind").isEmpty());
}
Remove the now-unused import java.io.IOException; from
LibraryTest if your IDE flags it.
clean, or you will chase a ghost
Use mvn clean test here, not mvn test.
You have just changed a method signature and an exception type, and that is precisely
the case where incremental compilation leaves a stale .class behind.
The symptom is baffling if you do not know it. The test still fails with the old exception:
Expected :class ...exceptions.AlreadyBorrowedException
Actual :class java.io.IOException
Caused by: java.io.IOException: Book not found: Dune
at com.solirius.intermediate.library.Library.borrowBook(Library.java:75)
Check the line number against your source. If line 75 of your
Library.java is a javadoc comment — or anything that plainly cannot
throw — you are not looking at the code you just wrote. You are looking at a class
file compiled before your edit.
mvn clean test
In IntelliJ the equivalents are Build → Rebuild Project, and —
whenever pom.xml changes — right-click pom.xml →
Maven → Reload Project. When IntelliJ and Maven disagree, believe
Maven: Maven is what the assessor will run.
mvn clean test
Tests run: 5 ... in A library
Tests run: 7 ... in A book
[ERROR] GoldenMasterTest.behavesAsApproved » Failed Approval
[ERROR] Tests run: 16, Failures: 0, Errors: 1, Skipped: 0
Every unit test passes and only the golden master is red. That is exactly right: you changed what the user sees, so the recorded transcript no longer matches. Look at what moved before you approve anything:
D=src/test/java/com/solirius/intermediate/library
diff -u $D/GoldenMasterTest.behavesAsApproved.approved.txt \
$D/GoldenMasterTest.behavesAsApproved.received.txt
The entire diff, menus elided — three hunks, nothing else:
-!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
+Enter the title of the book to borrow: 'Dune' is already borrowed
+Saving library state...
+Thank you for using the Library Management System!
-!! java.lang.RuntimeException: java.io.IOException: Book not found: Gone With The Wind
+Enter the title of the book to borrow: No book titled 'Gone With The Wind'
-!! java.lang.RuntimeException: java.io.IOException: Book not found or not borrowed: Dune
+Enter the title of the book to return: 'Dune' is not currently borrowed
+ lines include
Saving library state... and Thank you for using... now
appear in all three. Previously the application died before reaching
them, so everything the user had done that session was discarded. Fixing
the exception types did not just improve a message — it stopped three ways of
losing data.
Only now, approve it:
mv -f $D/GoldenMasterTest.behavesAsApproved.received.txt \
$D/GoldenMasterTest.behavesAsApproved.approved.txt
mvn clean test
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn clean test # must be green
git add src/main/java/com/solirius/intermediate/library/exceptions/
git add src/main/java/com/solirius/intermediate/library/Library.java
git add src/main/java/com/solirius/intermediate/library/Main.java
git add src/test/java/com/solirius/intermediate/library/LibraryTest.java
git add src/test/java/com/solirius/intermediate/library/GoldenMasterTest.behavesAsApproved.approved.txt
git commit -F - <<'EOF'
fix: 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
referenced by nothing, and adds LibraryException as a common supertype
so the console can report any domain failure uniformly.
They remain checked exceptions: the caller has a real recovery, which
is to report the message and continue.
searchBook returns Optional rather than null, which also gives
borrowBook and returnBook a shared requireBook helper via orElseThrow.
The boolean return values are dropped. With failure signalled by an
exception both methods could only ever return true. This deviates from
the signatures in the brief and is recorded in the README.
Golden master re-approved here: three stack traces become three
messages, and the application now survives to save its state.
EOF
git log --oneline
Getting to green was mechanical. The marks are in four choices you just made, each of which an interviewer can push on.
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.”
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.
Optional instead of nullThe 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.
The README specifies boolean borrowBook(String title) and
Book searchBook(String title). You changed both. That is defensible and
probably wanted, but it must be declared. Put this 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.searchBookreturnsOptional<Book>rather than a nullableBook.
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.
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.
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.