Java Guild course · Lesson 0006

Lesson 0006 · design under pressure

Making Main Testable

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.

Start with the crash

Task · 5 minutes · do this before reading on

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.

The fix is not 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 design move: an enum instead of six constants

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()
}
Why this is the strongest OO signal in the submission

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.

The other design move: stop reaching for global state

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."));
}

The red bar

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.

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

Where Mockito fits — and where it does not

The pom declares mockito-core and nothing uses it. It is tempting to reach for it here to prove you can. Resist.

For this classVerdict
Mocking Scanner and PrintStreamNo. ByteArrayInputStream and ByteArrayOutputStream are real, simpler, and assert on the actual bytes a user would see.
Mocking Library to verify borrowBook was calledNo. That asserts your implementation, not your behaviour. Use a real Library and check the observable outcome.
Mocking a LibraryRepository to simulate a disk failureYes. A genuine use: making a collaborator fail on demand is hard to arrange for real.
This is a question you will be asked

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

Drill

Why does taking Scanner and PrintStream as constructor parameters make the class testable?

  1. Constructor parameters are initialised before any method can be invoked
  2. It gives the test a seam to substitute streams it controls and can read
  3. Static methods cannot be invoked from a JUnit test without reflection use
  4. Injected fields can be marked final, which makes the whole class immutable

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?

  1. Enums use less memory at runtime than the equivalent integer constants do
  2. An enum constant cannot be passed where a plain integer is being expected
  3. Switching over an enum executes faster than switching over an int value
  4. The menu text is derived from the options, so the two cannot drift apart

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()?

  1. hasNextInt consumes nothing, so bad input must be discarded separately
  2. hasNextInt was deprecated in Java 11 and should no longer be relied upon
  3. Reading a line is the only way to accept input containing any whitespace
  4. nextInt cannot parse negative numbers, so out-of-range input still crashes

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.

Commit it — as two commits

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.
Note what that first message claims

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.

Read this

Primary source

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.

Carry on

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.