How does runTest handle exceptions thrown by child coroutines (launch/async), and how does that differ from runBlocking?
answer
- launch failure -> captured & rethrown at end, test fails
- no join() needed to see child failure
- async exception surfaces at await() or as uncaught
- UncompletedCoroutinesError on leaked/never-finishing jobs
- timeout/dispatchTimeoutMs guards hangs
basics
~10 sIf a coroutine you launch inside runTest throws, runTest catches it and rethrows at the end so the test fails. You don't have to join the child to see the failure.
solid answer
~40 srunTest runs its body in a TestScope whose context includes a TestCoroutineScheduler and aggregates uncaught exceptions. A failure in a child launch{} (an 'uncaught' exception, since launch has no awaiter) is captured and rethrown when runTest finishes, failing the test deterministically. With async{}, the exception is stored in the Deferred and surfaces when you await() it; if you never await, runTest still reports it as uncaught at the end. This differs from plain runBlocking where an uncaught launch exception propagates through the structured-concurrency job and can cancel the whole scope or be delivered to a CoroutineExceptionHandler, making test outcomes murkier. runTest also fails if the scope is still 'uncompleted' — e.g. a child coroutine that never finishes (a leaked job) triggers an 'uncompleted coroutines' / timeout error, catching forgotten cleanup.
code
kotlin · 6 lines@Test
fun leakedJobIsDetected() = runTest(timeout = 2.seconds) {
val channel = Channel<Int>()
launch { channel.receive() } // never completes; nothing is sent
// runTest fails with UncompletedCoroutinesError instead of hanging forever
}go deeper
Knows a throwing launched coroutine makes the test fail.
Explains capture-and-rethrow, the no-join guarantee, and async-await difference.
Adds UncompletedCoroutinesError, the timeout parameter, and how to assert precise exception types deterministically.
Reasons about exception aggregation vs CoroutineExceptionHandler trade-offs and designing tests that fail loudly on background errors and leaks.
## Structured concurrency recap In coroutines, `launch` and `async` create **children** of the current scope. `launch` returns a `Job` and has no awaiter, so an exception in it is *uncaught* — it propagates up the parent job. `async` returns a `Deferred<T>`; its exception is held until `await()`. ## What runTest guarantees `runTest` wires the `TestScope` so that **uncaught exceptions are aggregated and rethrown** when the test body and all its children settle. Concretely: ```kotlin @Test fun childFailureFailsTest() = runTest { launch { throw IllegalStateException("boom") } // no join needed: runTest advances the child, captures the throw, // and rethrows it -> the test FAILS with IllegalStateException } ``` You do **not** need to `join()` the child to observe the failure; auto-advancing drives the child to completion and the captured exception becomes the test's failure. ## async without await ```kotlin @Test fun asyncWithoutAwait() = runTest { val d = async { throw IllegalArgumentException("x") } // If you never call d.await(), runTest still surfaces it at the end // (reported as an uncaught exception). With await(), it throws there. } ``` ## Uncompleted-coroutines detection If a coroutine launched in the scope never finishes (e.g. it suspends forever on a channel that is never sent to), `runTest` does not hang indefinitely — after `dispatchTimeoutMs` (default 60s of *real* time waiting for the scheduler to make progress) it fails with an `UncompletedCoroutinesError`, surfacing leaked jobs. This is configurable via the `timeout` parameter (a `kotlin.time.Duration`) on `runTest`. ## Contrast with runBlocking With `runBlocking`: - An uncaught `launch` exception propagates through structured concurrency and may cancel the whole `runBlocking` scope, surfacing as the thrown exception — but timing and whether a `CoroutineExceptionHandler` intercepts it is less controlled. - There is no virtual clock and no built-in uncompleted-coroutine timeout reporting tailored for tests. ## Practical implications - Background failures cannot silently pass a `runTest`. - Use a custom `CoroutineExceptionHandler` in the context only when you intentionally want to *observe* rather than *fail on* an exception; otherwise let runTest's default aggregation fail the test. - A test that should verify a specific child throws should `assertFailsWith` around the `await()` of an `async`, or assert on captured state — not rely on ambiguous propagation.
- How do you assert that a specific child coroutine throws a particular exception?Capture it deterministically: wrap an async's await() in assertFailsWith<T>{ deferred.await() }, or assert on observable state set in a catch block — don't rely on runTest's end-of-test aggregation for precise type checks.
- What is UncompletedCoroutinesError and when does it fire?It's the error runTest raises when, after the body returns, there are still active coroutines in the scope that never complete within the dispatch timeout — typically a leaked/forever-suspended job.
runTest is a strict supervisor doing a head-count at the end of shift: anyone who left an error note (uncaught throw) or never clocked out (uncompleted job) gets flagged.
saying these in an interview costs you the question
- Saying you must join every child or the failure is lost
- Believing a throwing launch silently passes the test
- Confusing async (held until await) with launch (uncaught) propagation
- Thinking runTest hangs forever on a leaked job instead of timing out