Java Guild course · Lesson 0006
Lesson 0006 · design under pressure
A 90-line static method with a hardcoded Scanner, a giant switch, and a crash on any keypress that is not a digit.
Main is where most candidates stop, because it works when you type
carefully. It is also the class that most directly answers the criterion
“proper use of object-oriented programming principles” — and
right now it has almost none.
Run the program and type banana at the menu.
Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:977)
at java.base/java.util.Scanner.nextInt(Scanner.java:2251)
at com.solirius.intermediate.library.Main.main(Main.java:72)
One stray keypress kills the application. And because the state file is only written by menu option 6, everything added during that session is lost. The brief asks you to handle edge cases; a user mistyping a menu choice is the most ordinary edge case a console program has.
hasNextInt()The instinctive fix has a trap in it:
while (!scanner.hasNextInt()) {
System.out.println("Invalid choice.");
// the bad token is still sitting in the buffer -> infinite loop
}
hasNextInt() does not consume anything. Unless you also call
scanner.next() to discard the offending token, this spins forever
printing the message. Reading a whole line and parsing it yourself avoids
the trap entirely, and handles "3 ", "" and
"banana" the same way:
String input = in.nextLine();
MenuOption chosen = MenuOption.from(input).orElse(null); // null => not a valid choice
The starter has six public static final int fields and a
MENU string that repeats all six labels. Two lists that must be kept
in step by hand — add an option and forget the string, and the feature is
invisible.
public enum MenuOption {
ADD_BOOK(1, "Add a new book"),
VIEW_AVAILABLE(2, "View available books"),
SEARCH(3, "Search for a book"),
BORROW(4, "Borrow a book"),
RETURN(5, "Return a book"),
EXIT(6, "Exit");
public static Optional<MenuOption> from(final String input) { ... }
public static String menu() { ... } // renders from values()
}
The menu text is now generated from the options. It is no longer possible to add an option that does not appear on screen, or to show an option that does nothing — the two lists became one list. That is not “using an enum because enums are good”; it is removing a class of bug by making it unrepresentable.
It also gives the switch a compiler ally: switch over an enum and
most IDEs and static analysers will warn about unhandled constants.
Main reads System.in and writes System.out
directly, from a static method. That is why it cannot be tested: there
is no seam to push a fake through. Take the streams as constructor parameters:
public LibraryConsole(final Library theLibrary,
final Scanner input,
final PrintStream output) { ... }
This is constructor injection — dependency injection with no framework, no annotations and no container. And it is what makes this possible:
private String runWith(final String input) {
ByteArrayOutputStream captured = new ByteArrayOutputStream();
new LibraryConsole(
new Library(),
new Scanner(new ByteArrayInputStream(input.getBytes(UTF_8))),
new PrintStream(captured, true, UTF_8)
).run();
return captured.toString(UTF_8);
}
@Test
@DisplayName("asks again when the choice is not a number")
void asksAgainWhenTheChoiceIsNotANumber() {
assertTrue(runWith("banana\n6\n").contains("Invalid choice. Please try again."));
}
Write that test against a straight extraction of the existing logic — still
using nextInt() — and the crash moves out of the terminal and into
the build, which is exactly where you want it:
[ERROR] LibraryConsoleTest.asksAgainWhenTheChoiceIsNotANumber <<< ERROR!
java.util.InputMismatchException
at LibraryConsoleTest.runWith(LibraryConsoleTest.java:23)
Then switch to line-based parsing and it goes green.
Main should shrink to
After the extraction, main does one job: wire things
together. Load state, construct the console, run it, save state. No
business logic, no switch, nothing to test. That is what a
composition root looks like, and an assessor scanning your diff will see a
90-line method become about fifteen.
The pom declares mockito-core and nothing uses it. It is tempting
to reach for it here to prove you can. Resist.
| For this class | Verdict |
|---|---|
Mocking Scanner and PrintStream | No. ByteArrayInputStream and ByteArrayOutputStream are real, simpler, and assert on the actual bytes a user would see. |
Mocking Library to verify borrowBook was called | No. That asserts your implementation, not your behaviour. Use a real Library and check the observable outcome. |
Mocking a LibraryRepository to simulate a disk failure | Yes. A genuine use: making a collaborator fail on demand is hard to arrange for real. |
“I see Mockito in the pom — why didn’t you use it?” is a near-certain interview question, and it is a trap for the eager. The strong answer: “I only reach for a mock when the real collaborator is slow, non-deterministic, or hard to drive into the state I need. Streams are none of those, so I used real ones and asserted on the actual output. If I’d written the SQLite repository I’d have mocked it to test the failure path.”
Using a mock to prove you know Mockito is the wrong reason, and experienced reviewers can tell.
Why does taking Scanner and PrintStream as constructor parameters make the class testable?
True of any field assigned in a constructor, and nothing to do with testability.
This is the whole mechanism. Previously the class named its collaborators (System.in, System.out) so nobody else could choose them. Now the caller chooses — production passes the real streams, the test passes byte arrays it can write to and read back.
Static methods are perfectly callable from tests; the golden master calls Main.main directly. The problem was never the static keyword, it was the hardwired streams.
The fields can be final, but a Scanner is deeply mutable, so the class is not immutable. Nor would immutability make it testable.
What is the real benefit of replacing the six int constants with an enum?
The opposite — each constant is an object. Memory is irrelevant at this scale either way.
A genuine benefit of type safety, and worth a sentence. But the menu had no such confusion to prevent, so it is not the main win here.
Both compile to a jump table. Performance is not a consideration in a method that waits on a human typing.
This is the one. The starter kept a hand-written MENU string alongside six separate constants, so adding an option meant remembering to edit both. Rendering the menu from values() makes that impossible — you have removed a bug rather than tidied some syntax.
Why is reading a whole line better than calling hasNextInt() before nextInt()?
Correct, and it is the trap. hasNextInt() only peeks. Loop on it without calling next() to throw the bad token away and you have swapped a crash for an infinite loop printing “Invalid choice” forever — which is arguably worse, because it hangs rather than exits.
It is not deprecated and is perfectly usable, provided you also consume the token.
You can read whitespace-containing input other ways, and the menu choice contains none. Not the issue.
nextInt() parses negatives fine. -5 would be read and then rejected as an unknown option, which is correct behaviour.
Extract LibraryConsole from Main so the menu can be tested
Main read System.in and wrote System.out from a static method, so the
menu loop had no seam a test could reach. LibraryConsole takes its
Scanner and PrintStream as constructor parameters; Main is reduced to
wiring and no longer contains behaviour.
No functional change: the golden master is unchanged by this commit.
Handle menu input that is not a number
Typing any non-digit at the menu raised InputMismatchException and
terminated the application, discarding everything added during that
session, since state is only written on a clean exit.
The choice is now read a line at a time and matched against MenuOption,
which also replaces the six int constants and the hand-maintained menu
string. The menu is rendered from the enum, so an option cannot exist
without appearing on screen.
“No functional change: the golden master is unchanged by this commit.” That is a falsifiable statement, and the assessor can check it in seconds. Making claims a reviewer can verify is a habit worth having.
Martin Fowler — “Inversion of Control Containers and the Dependency Injection Pattern”. Long, but read the first third: the naive example, and the section on constructor injection. It is the article that makes clear DI is a pattern, not a framework — which is precisely the point you are making by doing it here with no framework at all.
Send me your LibraryConsole and I will review it as an
assessor would — the first thing I will look for is whether anything in it still
mentions System.