By default runTest uses StandardTestDispatcher — what does that mean for when launched coroutines actually run, and how do you observe intermediate state?
answer
- Default = StandardTestDispatcher = lazy
- launch only schedules; body runs first
- runCurrent / advanceTimeBy / advanceUntilIdle drive it
- lazy lets you assert intermediate (loading) state
- Unconfined = eager alternative
basics
~10 sBy default, coroutines you launch inside runTest don't run immediately — they queue. The test body runs first; the scheduler runs the queued coroutines when it advances or when the body suspends.
solid answer
~40 srunTest's TestScope defaults to a StandardTestDispatcher, which is 'lazy': calling launch{} or async{} only *schedules* the coroutine on the TestCoroutineScheduler; it does not start executing until the test body suspends or you explicitly drive the scheduler. So right after launch{}, the child has not run a single line yet — letting you assert pre-execution state. To make queued work progress you use runCurrent() (run tasks scheduled at the current virtual time), advanceTimeBy(d) (run everything up to now+d), or advanceUntilIdle() (drain all). This determinism is the point: you control exactly when each coroutine step happens, so you can observe intermediate states (e.g. a loading flag set before the result arrives). If you instead want eager execution where launched coroutines run up to their first suspension immediately, you opt into UnconfinedTestDispatcher.
code
kotlin · 9 lines@Test
fun orderingIsDeterministic() = runTest {
val log = mutableListOf<String>()
launch { log += "a" }
launch { log += "b" }
assertTrue(log.isEmpty()) // neither ran yet (lazy)
runCurrent()
assertEquals(listOf("a", "b"), log) // FIFO, deterministic
}go deeper
Recognizes that launched coroutines need the scheduler to be advanced to run.
Knows the default is Standard/lazy and can use runCurrent/advanceUntilIdle.
Explains how laziness enables step-by-step intermediate-state assertions and contrasts with Unconfined's eager execution.
Articulates deterministic FIFO scheduling as the reliability foundation and chooses dispatcher per test based on what ordering/state must be observed.
## StandardTestDispatcher is lazy `runTest` defaults to a `StandardTestDispatcher` (backed by the scope's `TestCoroutineScheduler`). 'Lazy' means: when you call `launch { ... }` or `async { ... }`, the coroutine is **enqueued** on the scheduler but does not execute yet. The current coroutine (the test body) keeps running until it **suspends** or returns. Only then does the scheduler get a chance to dispatch the queued coroutine. ```kotlin @Test fun launchedDoesNotRunYet() = runTest { var ran = false launch { ran = true } // merely scheduled assertFalse(ran) // still false: child hasn't started runCurrent() // now run tasks at current virtual time assertTrue(ran) } ``` ## Driving the scheduler Three `TestScope` controls let you advance execution deterministically: - **`runCurrent()`** — execute all tasks scheduled at the *current* virtual time, without advancing the clock. - **`advanceTimeBy(duration)`** — move virtual time forward by `duration`, running every task scheduled up to (but not including the exact end boundary in older nuances) that point; modern API takes a `kotlin.time.Duration`. - **`advanceUntilIdle()`** — keep advancing until there are no tasks left at any time. When the test body finishes, `runTest` itself performs an implicit `advanceUntilIdle()` so children complete. ## Observing intermediate state Laziness is what makes it possible to test a state machine step by step. Example: a view-model that sets `loading = true`, suspends on a repository call, then sets the result. ```kotlin @Test fun showsLoadingThenResult() = runTest { val vm = MyViewModel(repo, StandardTestDispatcher(testScheduler)) vm.load() // launches internal coroutine, only scheduled runCurrent() // run up to the first suspension (the repo call) assertTrue(vm.state.value.isLoading) // observe the loading state advanceUntilIdle() // let the repo call + completion run assertEquals(expected, vm.state.value.data) } ``` With eager execution you could not catch the `isLoading == true` window because the child would race straight through. ## Contrast with UnconfinedTestDispatcher `UnconfinedTestDispatcher` is **eager**: a launched coroutine starts running immediately on the current thread up to its first real suspension point, like classic `Dispatchers.Unconfined`. That is convenient when you do not care about intermediate ordering and just want results, but it removes the fine-grained control. Choosing between them is exactly the sibling 'Standard vs Unconfined' distinction; the default for `runTest` is Standard. ## Why default to lazy Lazy/standard scheduling produces **deterministic, FIFO** ordering driven explicitly by you, which makes concurrency tests reproducible and avoids hidden races — the core value proposition of the test scheduler.
- Why does asserting a 'loading' state often require StandardTestDispatcher rather than Unconfined?Standard is lazy, so after launching you can call runCurrent() to reach the first suspension and observe the loading flag. Unconfined runs eagerly past that point, so the intermediate window may already be gone.
- What's the difference between runCurrent() and advanceUntilIdle()?runCurrent() executes only tasks at the current virtual time without moving the clock; advanceUntilIdle() keeps advancing the clock and running tasks until nothing is pending at any time.
Standard dispatcher is a ticketed queue: pulling a ticket (launch) doesn't serve you — the clerk (scheduler) calls numbers only when you ring the bell (advance).
saying these in an interview costs you the question
- Believing launch inside runTest runs immediately by default
- Not knowing runCurrent vs advanceUntilIdle
- Thinking the default dispatcher is Unconfined
- Claiming you can't observe intermediate state under runTest