skip to content

A teammate's ViewModel test calls Dispatchers.setMain(StandardTestDispatcher()) and runs the test body in runTest {}, but advanceUntilIdle() never seems to complete the ViewModel's work. Diagnose the likely bug and fix it.

level: seniorimportance: should knowfreq 45%

answer

  1. Two schedulers = work never advanced
  2. setMain dispatcher and runTest must share scheduler
  3. Fix: runTest(dispatcher.scheduler)
  4. advanceUntilIdle drives only its own scheduler
  5. Symptom: hangs/stale state despite advancing

basics

~20 s

The dispatcher passed to setMain uses a different scheduler than runTest. They must share one scheduler. Create the test dispatcher from runTest's testScheduler (or start runTest from the dispatcher's scheduler) so advanceUntilIdle drives the ViewModel.

solid answer

~40 s

`runTest {}` creates its own `TestCoroutineScheduler` (exposed as `testScheduler`). If you separately call `Dispatchers.setMain(StandardTestDispatcher())` with **no argument**, that dispatcher gets a *different* scheduler. The ViewModel's `viewModelScope` coroutines run on the Main override's scheduler, but `advanceUntilIdle()` inside `runTest` only advances `runTest`'s scheduler — so the ViewModel's work is never driven and the test hangs or asserts stale state. Fix by sharing one scheduler. Two idioms: (1) build the Main dispatcher from `runTest`'s scheduler — but `setMain` runs in `@BeforeEach`, before `runTest`, so instead (2) create `val td = StandardTestDispatcher()` once, `setMain(td)` in setup, and start the test with `runTest(td.scheduler) { ... }`. Now both the Main override and the test body advance the same scheduler, and `advanceUntilIdle()` completes the ViewModel.

code

kotlin · 16 lines
kotlin
// BUG: two different schedulers
Dispatchers.setMain(StandardTestDispatcher())
runTest {                       // its own testScheduler
    val vm = VM(); vm.load()
    advanceUntilIdle()          // advances runTest's scheduler, not Main's
    assertEquals(Loaded, vm.state.value) // FAILS / stale
}

// FIX: one shared scheduler
val td = StandardTestDispatcher()
Dispatchers.setMain(td)
runTest(td.scheduler) {
    val vm = VM(); vm.load()
    advanceUntilIdle()
    assertEquals(Loaded, vm.state.value) // passes
}

go deeper

for a junior

May not spot the dual-scheduler issue; might guess at missing advance calls.

for a middle

Recognizes that setMain dispatcher and runTest need the same scheduler and applies runTest(dispatcher.scheduler).

for a senior

Diagnoses the two-scheduler root cause from symptoms (hang/stale), explains advanceUntilIdle binding, and gives the clean setup/teardown fix.

for a principal

Codifies a project-wide MainDispatcherRule that guarantees scheduler sharing and documents the failure mode to prevent recurrence.

## Root cause: two schedulers `TestCoroutineScheduler` is the clock/queue that virtual time and `advance*` functions operate on. Each `TestDispatcher` and each `runTest` is associated with exactly one scheduler. - `StandardTestDispatcher()` with no args **creates a fresh scheduler**. - `runTest {}` with no args **also creates a fresh scheduler** (its `testScheduler`). If you do `Dispatchers.setMain(StandardTestDispatcher())` and then `runTest { ... advanceUntilIdle() }`, you have **two** schedulers. The ViewModel's `viewModelScope.launch` runs on the Main override's scheduler; `advanceUntilIdle()` advances `runTest`'s scheduler. They never meet, so the ViewModel never progresses. ## The fix: one shared scheduler Share a single scheduler between the Main override and `runTest`. ```kotlin @OptIn(ExperimentalCoroutinesApi::class) class VmTest { private val dispatcher = StandardTestDispatcher() @BeforeEach fun setUp() { Dispatchers.setMain(dispatcher) } @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test fun loads() = runTest(dispatcher.scheduler) { // <- share scheduler val vm = VM() vm.load() advanceUntilIdle() assertEquals(Loaded, vm.state.value) } } ``` Passing `dispatcher.scheduler` to `runTest` makes `runTest` reuse the same `TestCoroutineScheduler`, so `advanceUntilIdle()` drives everything — the test body *and* the Main-dispatched ViewModel coroutines. ## Alternative idioms - Use a `MainDispatcherExtension`/`MainCoroutineRule` that exposes its `TestDispatcher`, and pass `rule.dispatcher.scheduler` (or `rule.testScheduler`) into `runTest`. - Or create the dispatcher *inside* `runTest` from `testScheduler` and pass it to `setMain` there — but remember to reset it; the setup/teardown idiom is cleaner. ## Symptoms that point to this bug - `advanceUntilIdle()` returns but state is unchanged. - The test sometimes hangs until `runTest`'s default timeout. - Adding `delay()` in the ViewModel makes it worse, not better. ## Key APIs/terms - **`TestCoroutineScheduler`**, **`testScheduler`** — the shared clock/queue. - **`runTest(scheduler)`** / **`runTest(context)`** — accepts a scheduler or a dispatcher in its context. - **`Dispatchers.setMain`/`resetMain`**, **`StandardTestDispatcher.scheduler`**. - **`advanceUntilIdle()`** — only advances the scheduler it's bound to.

  • Can you instead pass the dispatcher (not the scheduler) into runTest?
    Yes — runTest accepts a CoroutineContext, so runTest(td) works and uses td's scheduler; but the dispatcher in context also becomes the body's dispatcher, which is usually fine.
  • Why does the test sometimes hang instead of just failing the assertion?
    runTest waits for its coroutines to finish (with a timeout). If the ViewModel's launched work lives on an unadvanced foreign scheduler, runTest can stall until the timeout.

saying these in an interview costs you the question

  • Adds more advanceUntilIdle calls instead of fixing scheduler sharing
  • Thinks setMain automatically shares runTest's scheduler
  • Switches to runBlocking to 'make it work', losing virtual time
  • Blames StateFlow rather than the dual scheduler
  • Removes setMain entirely and hits the Main-not-initialized error

context