Learn Java with Tests

A TDD course for Java, in the spirit of Learn Go with Tests.

Chapter 10 of 18

10 — Exceptions

assertThrows, assertAll, and exceptions as part of the contract.

Kata: Calculator.divide that can’t divide by zero, IntParser.parse that rejects garbage. New ideas: assertThrows, assertAll, and why exceptions are a design surface you test.

Exceptions are part of the contract

Java throws: control unwinds to a catch. The turning pair is throw (producer) and assertThrows (test):

public static double divide(double dividend, double divisor) {
    if (divisor == 0) {
        throw new IllegalArgumentException("division by zero");
    }
    return dividend / divisor;
}

Step 1 — test the failure path from day one

Write both behaviours as tests (happy + failure), because “it throws” is a contract just like “it returns 5”:

@Test
void dividesNormally() {
    assertEquals(5.0, Calculator.divide(10, 2));
}

@Test
void divisionByZeroThrows() {
    IllegalArgumentException exception =
            assertThrows(IllegalArgumentException.class, () -> Calculator.divide(10, 0));
    assertEquals("division by zero", exception.getMessage());
}

REDdivide doesn’t exist. Implement (as above) → GREEN. Note what assertThrows(...).getMessage() buys you: the test pins the exception type and its message.

Step 2 — assertAll: one test, many checks, no early abort

An assertEquals that fails aborts the test at the first failure. If you want to see every problem at once, group the assertions:

@Test
void happyPathAndFailurePathTogether() {
    assertAll("calculator contract",
            () -> assertEquals(5.0, Calculator.divide(10, 2)),
            () -> assertThrows(IllegalArgumentException.class, () -> Calculator.divide(10, 0)));
}

assertAll reports every failing assertion, not just the first — useful when a contract has several properties you want checked together.

Step 3 — the JDK throws too

Integer.parseInt("garbage") throws NumberFormatException. Your test just locks it:

assertThrows(NumberFormatException.class, () -> IntParser.parse("not-a-number"));

Checked vs unchecked — the interview question hiding here: JDK checked exceptions (e.g. IOException, SQLException) must be declared (throws…); runtime ones (IllegalArgumentException, and every exception you throw yourself) don’t. Unchecked is the default for new APIs — that’s why all the exceptions in this course are unchecked.

Java-specific notes

  • assertThrows needs an executable: () -> Calculator.divide(10, 0). That lambda-target interface is Executable, a @FunctionalInterface from the test framework.
  • Match the exception to the lie: IllegalArgumentException (bad argument) for division by zero, NumberFormatException (JDK’s own) for parse garbage. The type is the message.
  • Design discipline: throw at the edge, catch at the boundary. Your tests show both sides: the producer contract (throw + message) and the caller’s weapon (assertThrows).

Run it

gradle test --tests "exceptions.*"

Key takeaway: exceptions are return values with a different shape. Spec the failure modes in tests like any other behaviour — type, message, and both paths — and your error handling stops being an afterthought.

This wraps the language and design core: basics (01–04), records + interfaces (05–06), maps (07), streams (08), and modern absent/error handling (09–10). Next: 11 — dependency injection — seams that make behaviour observable. Happy test-driving!