skip to content

How does Kotest's shouldThrowExactly<T> differ from shouldThrow<T> in what it accepts, and what happens in either one when the block throws an AssertionError that is not the expected type?

level: seniorimportance: should knowfreq 34%

answer

  1. shouldThrow = `is T` (subtypes pass)
  2. shouldThrowExactly = runtime class equality
  3. expected-type check runs BEFORE the AssertionError rule
  4. stray AssertionError → rethrown unchanged
  5. exact-type assertions break when a subclass appears

basics

~20 s

shouldThrow<T> accepts T or any subtype; shouldThrowExactly<T> requires the runtime class to be exactly T and fails on subtypes. If the block throws an AssertionError that isn't the expected type, Kotest rethrows it as-is so a nested assertion failure isn't masked as a wrong-exception report.

solid answer

~50 s

`shouldThrow<T>` is an `is T` check: `shouldThrow<RuntimeException>` is satisfied by an `IllegalStateException`. `shouldThrowExactly<T>` compares the runtime class for equality, so a subtype **fails** — use it when the exact class is part of the contract (a caller distinguishes `NotFoundException` from its subclass) and `shouldThrow` otherwise, since exact-type assertions break whenever someone introduces a more specific exception subclass. The subtle part is the `AssertionError` case. Kotest's implementation checks `is T` **first**, so if you genuinely expect an `AssertionError` it is captured and returned normally. Otherwise, an `AssertionError` escaping the block is **rethrown unchanged** rather than reported as "expected X but Y was thrown". That is deliberate: assertion failures come from the assertion library itself — typically a matcher you called inside the block — and masking one behind a wrong-exception message would hide the real diagnostic. Practically: if you assert inside a `shouldThrow` block and that assertion fails, you see the original matcher failure, not a confusing type-mismatch message.

code

kotlin · 8 lines
kotlin
class NotFoundException : RuntimeException()
class AccountNotFoundException : NotFoundException()

// passes — subtype accepted
shouldThrow<NotFoundException> { accounts.load("missing") }

// fails — actual runtime class is AccountNotFoundException
shouldThrowExactly<NotFoundException> { accounts.load("missing") }

go deeper

for a junior

Recall that shouldThrow allows subtypes and shouldThrowExactly demands the exact class.

for a middle

Give a concrete case for each and explain why exact-type assertions are brittle when a subclass is added.

for a senior

Add the AssertionError passthrough and its rationale, and state the rule that assertions belong outside the block, on the returned exception.

for a principal

Frame exception types as public contract: decide which classes are observable, assert on those with the narrowest tolerant match, and reserve exactness for documented distinctions.

## Subtype vs exact type Kotest's exception assertions come in three widths: | Function | Accepts | |---|---| | `shouldThrowAny { }` | any `Throwable`; returns `Throwable` | | `shouldThrow<T> { }` | `T` **or any subtype**; returns `T` | | `shouldThrowExactly<T> { }` | only a throwable whose runtime class **is** `T`; returns `T` | `shouldThrow<T>` performs a Kotlin `is T` test, which is inheritance-aware. So `shouldThrow<RuntimeException> { throw IllegalStateException() }` passes. `shouldThrowExactly<T>` compares the thrown object's runtime class against `T` for equality, so the same block **fails** under `shouldThrowExactly<RuntimeException>`, reporting that an `IllegalStateException` was thrown instead. There are matching negatives: `shouldNotThrow<T>`, `shouldNotThrowExactly<T>`, `shouldNotThrowAny`. ## When exactness is right — and when it is a trap Use `shouldThrowExactly` when the exact class is genuinely part of the observable contract: - a hierarchy where callers branch on the concrete class (`NotFoundException` vs its subclass `AccountNotFoundException`) and you are pinning which one a specific path raises; - a test asserting that you did **not** accidentally wrap or specialise an exception; - a regression test for a bug where the wrong subclass leaked out. Avoid it as a default. Exact-type assertions are the tightest possible coupling to an implementation detail: the day someone introduces a more specific subclass for better diagnostics — a strictly backwards-compatible change for callers who catch the parent — every `shouldThrowExactly` on the parent goes red. `shouldThrow<T>` with the narrowest type the contract actually promises is the balanced default. A second reason for the default: `shouldThrow` is what makes tests survive framework wrapping. Many libraries throw subclasses of a documented base type, and a subtype-tolerant assertion expresses "the documented contract held" rather than "this exact class was constructed". ## The AssertionError rule Both functions run the block inside a `try`/`catch` over `Throwable`. The decision order is what matters: 1. Nothing thrown → fail with "expected exception ... but no exception was thrown". 2. The throwable matches the expected type → return it. **This check comes first**, so a test that legitimately expects an `AssertionError` (for example, testing your own custom matcher, or a `require`-style helper that raises one) works normally. 3. The throwable is an `AssertionError` but not the expected type → **rethrow it unchanged**. 4. Anything else → fail with "expected exception X but a Y was thrown instead", attaching the actual throwable. Rule 3 exists because `AssertionError` is how the assertion library itself signals failure. Consider: ```kotlin shouldThrow<IllegalStateException> { result.status shouldBe "OK" // a matcher, inside the block machine.transition() } ``` If `status` is `"ERROR"`, the matcher raises an `AssertionError`. Without rule 3 you would be told "expected IllegalStateException but an AssertionError was thrown" — technically true, useless in practice. With rule 3 you see the real message: `expected: "OK" but was: "ERROR"`. The original diagnostic survives. The operational lesson is the same either way: **do not put assertions inside a `shouldThrow` block**. Rule 3 is a safety net for accidents, not a licence. Assertions belong outside, on the returned exception. A related consequence worth knowing: a matcher failure inside the block can never be "absorbed" into a passing `shouldThrow`. If your production code under test itself throws `AssertionError` (a bare `assert` with assertions enabled, or a library that misuses it), then `shouldThrow<SomeOtherException>` will surface that `AssertionError` verbatim, which is usually exactly the signal you want. ## Reporting quality When the wrong non-assertion exception is thrown, Kotest's failure names the expected type and the actual type and carries the actual throwable along, so its stack trace is visible in the report. That is why the mismatch case is usually easy to diagnose without a debugger — you get both the expectation and the real stack. ## Interview framing The question separates people who know the three functions by name from those who understand the matching semantics and the failure-reporting rules underneath. A strong answer states the subtype/exact distinction, gives a concrete case for each, and adds the `AssertionError` passthrough with its rationale — plus the practical guidance that assertions do not belong inside the block in the first place.

  • You expect your code to throw an AssertionError. Does shouldThrow<AssertionError> work?
    Yes. Kotest checks whether the thrown value matches the expected type before applying the AssertionError passthrough rule, so an expected `AssertionError` is captured and returned like any other exception. The passthrough only fires when the thrown `AssertionError` is *not* the type you asked for, which is the accidental case.
  • Why is shouldThrowExactly a poor default in a large codebase?
    It couples the test to the concrete class rather than to the contract. Introducing a more specific subclass — a change that is invisible to callers who catch the parent type — turns every exact-type assertion red, which trains people to loosen assertions in bulk. Reserve it for the few places where the concrete class is genuinely observable behaviour.

saying these in an interview costs you the question

  • Claiming shouldThrow and shouldThrowExactly are interchangeable aliases
  • Thinking shouldThrowExactly accepts subtypes as long as they inherit from the expected type
  • Believing a matcher failure inside the block gets reported as a wrong-exception-type error
  • Assuming you cannot test for AssertionError itself with shouldThrow
  • Defaulting to shouldThrowExactly everywhere 'to be strict'

context