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());
}
RED — divide 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
assertThrowsneeds an executable:() -> Calculator.divide(10, 0). That lambda-target interface isExecutable, a@FunctionalInterfacefrom 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!