How do you assert that a suspend function throws a specific exception, and what subtle issues arise with assertFailsWith and coroutines?
answer
- assertFailsWith block is non-suspend
- Wrap test in runTest; call suspend inside
- launch failures escape the block; use async + await
- CancellationException is special — rethrow
- runTest fails deterministically on uncaught children
basics
~20 sRun the suspend call inside runTest (or runBlocking) and put it in the assertFailsWith lambda. Be careful: the lambda is not a suspend lambda by default, so call the suspend function from within a coroutine scope.
solid answer
~50 s`assertFailsWith<T> { ... }`'s block is an ordinary (non-suspend) function type, so you cannot directly call a `suspend` function inside it unless the enclosing lambda is itself running in a coroutine. The standard pattern is to wrap the whole test body in `runTest { ... }` (from `kotlinx-coroutines-test`) and place `assertFailsWith` inside it; the assertion block runs synchronously within the already-active coroutine, and a suspend call there resolves because the surrounding test function is suspend. Subtleties: (1) exceptions thrown in a **child** coroutine launched with `launch` propagate via the parent/`TestScope` and may surface as test failures or be wrapped, not necessarily caught by an inner `assertFailsWith`; for those, assert on `async {}.await()` or on what the scope reports. (2) `CancellationException` is special — it signals cooperative cancellation and should generally be rethrown, so avoid asserting it as a normal error. (3) With `runTest`, uncaught child exceptions fail the test deterministically.
code
kotlin · 24 linesimport kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import kotlin.test.*
class CoroutineFailTest {
class LoadError(msg: String) : Exception(msg)
private suspend fun load(id: String): String {
if (id == "bad") throw LoadError("no such id")
return id
}
@Test
fun directSuspendThrows() = runTest {
val ex = assertFailsWith<LoadError> { load("bad") }
assertEquals("no such id", ex.message)
}
@Test
fun childCoroutineThrows() = runTest {
val deferred = async { load("bad") }
val ex = assertFailsWith<LoadError> { deferred.await() }
assertEquals("no such id", ex.message)
}
}go deeper
Knows to put the suspend call inside runTest/runBlocking and then inside assertFailsWith.
Understands the block is non-suspend and relies on the enclosing coroutine to host the suspend call.
Distinguishes direct suspend vs launched-child propagation, uses async+await, and handles CancellationException correctly.
Sets coroutine failure-testing conventions, reasons about structured concurrency propagation and runTest determinism across the codebase.
## The core constraint `assertFailsWith`'s block parameter is a **plain function type** `() -> T`, not `suspend () -> T`. A `suspend` function — one marked with the `suspend` keyword, runnable only from a coroutine — cannot be invoked from a non-suspend context. The trick is *where* the lambda runs. ## Pattern: wrap in `runTest` `runTest { }` from **`kotlinx-coroutines-test`** starts a coroutine with a virtual-time `TestScope`. Because the lambda you pass to `assertFailsWith` is *invoked synchronously* by `assertFailsWith` while you are already inside that coroutine, a `suspend` call placed there compiles and runs: ```kotlin import kotlinx.coroutines.test.runTest import kotlin.test.* class RepoTest { @Test fun loadThrows() = runTest { val ex = assertFailsWith<IllegalStateException> { repo.load("bad") // suspend fun, called inside the runTest coroutine } assertEquals("no such id", ex.message) } } ``` This works because the enclosing test body is a suspend lambda; the inner non-suspend lambda still executes within that suspend continuation. ## Subtlety 1 — direct suspend vs launched children If the failing work runs in a **child coroutine** started with `launch { throw ... }`, the exception does **not** flow back through the `assertFailsWith` block — it propagates to the parent via structured concurrency and is reported by `runTest` (failing the test or surfacing through the scope's uncaught handler). To assert on such failures deterministically, prefer `async { }` and assert on `await()`: ```kotlin val deferred = async { failingWork() } val ex = assertFailsWith<MyError> { deferred.await() } ``` `await()` is a suspend call that rethrows the deferred's exception, so it is catchable by `assertFailsWith`. ## Subtlety 2 — `CancellationException` `CancellationException` drives cooperative cancellation. Catching/asserting it as if it were a normal error can break cancellation semantics; if your catch logic swallows it, the coroutine won't cancel. Generally don't target it with `assertFailsWith`; if you must, rethrow afterwards. ## Subtlety 3 — `runBlocking` vs `runTest` `runBlocking { }` also works but uses real time and real dispatch; `runTest` gives virtual time, auto-advances delays, and **fails the test on uncaught child exceptions** deterministically, which is why it is preferred for testing failure paths. ## Summary - The assert block is non-suspend; rely on the enclosing `runTest`/`runBlocking` coroutine to host suspend calls. - Catch child-coroutine failures via `async {}.await()`, not by wrapping `launch`. - Treat `CancellationException` specially.
- Why can't you call a suspend function directly inside the assertFailsWith lambda in a non-coroutine test?Its block parameter is a plain () -> T, not suspend () -> T. You must be inside a coroutine (e.g. runTest) so the suspend call has a continuation to run on.
- Why use async + await instead of launch to assert a child coroutine's failure?launch propagates the exception to the parent via structured concurrency, escaping the assert block; await() rethrows the failure at the call site, where assertFailsWith can catch it.
saying these in an interview costs you the question
- Trying to mark the assertFailsWith lambda itself as suspend
- Wrapping launch { } and expecting the inner assertion to catch the failure
- Asserting CancellationException as a normal error
- Using runBlocking and not understanding virtual-time benefits of runTest
- Assuming all coroutine exceptions surface inside the assert block