JUnit 5 provides both assertThrows and assertThrowsExactly. How does each one match the thrown exception's type, and when is the stricter form the right call?
answer
- assertThrows = isInstance (subclasses pass)
- assertThrowsExactly = getClass() == expected (5.8+)
- NumberFormatException extends IllegalArgumentException
- assertThrows(RuntimeException.class,...) is near-vacuous
- exact form is stricter but brittle to legitimate refinement
basics
~20 sassertThrows passes for the named type or any subclass of it; assertThrowsExactly passes only when the thrown object's class is exactly the named one. Use the exact form when a subclass would signal a different failure — for example when IllegalArgumentException is required but NumberFormatException would be wrong.
solid answer
~50 s`assertThrows(T.class, executable)` uses an `isInstance` check, so any subclass satisfies it. `assertThrowsExactly(T.class, executable)` (JUnit 5.8+) compares `thrown.getClass()` with the expected class and fails on any subclass. Both return the thrown exception for further assertions. The permissive form is right most of the time — it is what you want when the contract really is "a validation failure", and it survives a later refactor that introduces a more specific subtype. But it can be too weak: `assertThrows(IllegalArgumentException.class, ...)` also passes for `NumberFormatException`, so a parsing bug can satisfy a test written for a range check. And `assertThrows(RuntimeException.class, ...)` is close to vacuous — a stray `NullPointerException` passes it. Use `assertThrowsExactly` when sibling subtypes carry different meaning and confusing them would be a real defect, or when the exact type is a published contract. Its cost is brittleness: legitimately refining the thrown type breaks the test.
code
java · 19 lines@Test
void subtypeSatisfiesAssertThrows() {
// NumberFormatException extends IllegalArgumentException -> this PASSES
assertThrows(IllegalArgumentException.class, () -> Integer.parseInt("abc"));
// exact match required -> this FAILS, naming NumberFormatException
// assertThrowsExactly(IllegalArgumentException.class, () -> Integer.parseInt("abc"));
assertThrowsExactly(NumberFormatException.class, () -> Integer.parseInt("abc"));
}
@Test
void preferStructuredDetailOverExactClass() {
ValidationException ex = assertThrows(
ValidationException.class, () -> validator.check(new Order(-1)));
assertEquals("quantity", ex.field());
assertEquals(ErrorCode.OUT_OF_RANGE, ex.code());
}go deeper
State the difference plainly — subclasses pass the ordinary assertion, only the exact class passes the strict one — with a NumberFormatException example.
Explain the underlying checks (isInstance versus getClass), name the 5.8 availability, and give a concrete case where the permissive form is too weak.
Argue the tradeoff: type breadth is the real determinant of assertion strength, structured exception fields often beat exact-class pinning, and over-strict tests churn when the implementation legitimately refines its exceptions.
Frame it as exception-hierarchy design — whether callers branch on concrete types at all, whether error codes should replace type-based dispatch, and what that implies for how tests pin failure modes across modules.
## Two matching rules JUnit 5 gives you two exception-type assertions that differ only in how they compare the thrown object's type to the expected one. **`assertThrows(Class<T> expectedType, Executable executable)`** — the original, present since Jupiter 5.0 — accepts the thrown throwable if `expectedType.isInstance(thrown)` is true. That is assignability: the exact class, or any subclass, or an implementation of the expected interface. **`assertThrowsExactly(Class<T> expectedType, Executable executable)`** — added in JUnit 5.8 — accepts only when `thrown.getClass()` **is** `expectedType`. A subclass fails with a message such as `Unexpected exception type thrown, expected: <java.lang.IllegalArgumentException> but was: <java.lang.NumberFormatException>`. Both have message and message-supplier overloads, and both return the thrown exception typed as `T`, so the follow-on assertions (`getMessage`, `getCause`, domain fields) look identical. ## Why the subtype question matters Java's exception hierarchies are deep, and sibling subtypes often mean genuinely different things: - `NumberFormatException extends IllegalArgumentException`. A test that asserts `IllegalArgumentException` for "quantity must be positive" also passes if the code blew up parsing the quantity string — a different bug. - `FileNotFoundException extends IOException`. A test asserting `IOException` for a network read failure passes if the code instead failed to open a local temp file. - `NullPointerException`, `ClassCastException`, `ArrayIndexOutOfBoundsException` are all `RuntimeException`, so `assertThrows(RuntimeException.class, ...)` is satisfied by essentially any programming error in the code under test. This is the single most common way an exception test becomes worthless. The rule of thumb: **assert the narrowest type that expresses the behaviour you are specifying.** Type breadth, not the choice of assertion method, is usually what makes a test weak. `assertThrowsExactly` is the additional lever for the cases where even the narrowest named type has subtypes that would be wrong. ## When to pick the exact form - **Sibling subtypes are semantically different** and your production code deliberately distinguishes them — for example a `ValidationException` base with `MissingFieldException` and `OutOfRangeException` subclasses, where the caller branches on the concrete type. - **The exact type is a published contract**, such as a library API documented to throw precisely `IllegalStateException`, or a framework integration where a mapper keys on the class. - **Guarding against accidental widening**: you want the test to fail loudly if someone starts throwing a subclass in a path where callers depend on the base class behaviour. ## When it is the wrong choice `assertThrowsExactly` couples the test to the concrete class, so it breaks when the implementation legitimately refines its exception — which is often an *improvement*. If the behaviour you are specifying is "the call is rejected as invalid input", the base type is the contract, and a more specific subclass still honours it. Forcing the exact class in that case creates churn without protection. A good default: use `assertThrows` with a tightly chosen type, and reach for `assertThrowsExactly` when you can articulate why a subclass would be a defect. ## Strengthening either form Whichever you pick, the type is only half the assertion. Capture the returned exception and assert something that identifies *which* failure occurred: ```java var ex = assertThrows(ValidationException.class, () -> validator.check(input)); assertEquals("quantity", ex.field()); assertEquals(ErrorCode.OUT_OF_RANGE, ex.code()); ``` A structured field is more robust than an exact message string and often removes the need for the exact-class assertion altogether: instead of pinning the class, pin the error code that the class was standing in for. ## Interaction with the cause chain Neither assertion inspects causes. If a framework layer wraps the real failure, `assertThrows(WrapperException.class, ...)` matches the wrapper only; the underlying type must be asserted separately via `getCause()`, typically with `assertInstanceOf`, which — like `assertThrows` — uses assignability and returns the narrowed value. ## Version note `assertThrowsExactly` requires JUnit Jupiter 5.8 or later; on older versions the equivalent is to capture the exception with `assertThrows` and then assert `assertEquals(Expected.class, ex.getClass())`, which produces a slightly less readable message but the same strictness.
- Why is assertThrows(RuntimeException.class, ...) considered a weak assertion?Almost every programming error in Java is a `RuntimeException` subclass, so a `NullPointerException` from an uninitialised field, a `ClassCastException`, or an `ArrayIndexOutOfBoundsException` all satisfy it. The test then passes while the code fails for a completely different reason than the one being specified. Assert the narrowest type that expresses the behaviour, and add an assertion on a message fragment, error code or domain field so the test identifies which failure occurred.
- You are on JUnit 5.4 and need exact-class matching. What do you do?`assertThrowsExactly` only exists from 5.8, so capture the exception with `assertThrows` on the base type and then assert the class explicitly: `assertEquals(Expected.class, ex.getClass())`. The strictness is identical; only the failure message is less descriptive. Upgrading Jupiter is usually preferable, but this idiom is a faithful stand-in and is also useful when you want to assert the class alongside other properties in an `assertAll` group.
saying these in an interview costs you the question
- Believing assertThrows requires an exact class match and that subclasses fail.
- Treating assertThrowsExactly as strictly better and using it everywhere, coupling tests to implementation detail.
- Asserting Exception or RuntimeException and considering the failure path covered.
- Expecting either assertion to look into the cause chain and match a wrapped exception.
- Not knowing NumberFormatException is an IllegalArgumentException, so a parsing bug can satisfy a validation test.