skip to content

A flow uses delay() between emissions. How does runTest let you collect it with toList() without the test actually waiting in real time?

level: middleimportance: should knowfreq 58%

answer

  1. runTest = virtual clock, delays skipped
  2. Direct toList() auto-advances through delays
  3. Separate launch -> advanceUntilIdle()/advanceTimeBy()
  4. Order preserved by time-ordered resumption
  5. Real dispatchers (IO/Default) break virtual time

basics

~10 s

runTest uses a virtual clock. Delays inside the flow are skipped instantly instead of really pausing, so toList() still collects all the values but the test finishes in milliseconds.

solid answer

~40 s

runTest runs on a TestScope backed by a TestCoroutineScheduler and a virtual time source. delay() calls are not real sleeps; the scheduler advances virtual time and resumes coroutines immediately when the test coroutine is idle. So a flow that emits with delay(1000) between items still produces all emissions, and toList() collects them all in near-zero real time. When you collect with toList() in the same runTest body, the auto-advancing behavior runs pending delayed tasks so the flow completes. If you launch the flow in a separate coroutine, you may need advanceUntilIdle() or advanceTimeBy() to push virtual time forward. assertEquals(listOf(...), result) then verifies the full ordered sequence. This is why delays make tests slow only when you forget to use runTest.

code

kotlin · 8 lines
kotlin
@Test
fun separateCollector() = runTest {
    val out = mutableListOf<Int>()
    val job = launch { flow { emit(1); delay(50); emit(2) }.collect { out += it } }
    advanceUntilIdle()       // push virtual time so both emissions happen
    job.join()
    assertEquals(listOf(1, 2), out)
}

go deeper

for a junior

Knows runTest makes delayed flows finish fast and still collect all values.

for a middle

Explains virtual time/scheduler and the difference between auto-advance and manual advanceUntilIdle().

for a senior

Discusses StandardTestDispatcher vs UnconfinedTestDispatcher, runCurrent, and how real dispatchers break virtual time.

for a principal

Establishes dispatcher-injection conventions so all delay-based flows remain virtual-time testable across the codebase.

## The problem: real delays make tests slow Production flows often emit on a schedule using `delay(ms)` (a suspend function). If tests honored real time, a flow emitting every second would make a test take seconds. `runTest` solves this with **virtual time**. ## TestScope, TestCoroutineScheduler, virtual time `runTest { }` creates a `TestScope`. Its dispatcher is backed by a `TestCoroutineScheduler` that maintains a **virtual clock** (`currentTime`). When a coroutine calls `delay(1000)`, it does not sleep on a real thread—it registers a resumption at virtual time +1000 and suspends. The scheduler advances the clock and resumes the coroutine the instant nothing else can run, so delays are effectively **skipped**. ```kotlin @Test fun delayedFlowIsInstant() = runTest { val ticks = flow { emit("a"); delay(1_000) emit("b"); delay(1_000) emit("c") } val result = ticks.toList() // collected in ~0 real ms assertEquals(listOf("a", "b", "c"), result) } ``` ## Auto-advance vs manual control - When you simply call `toList()` (or any suspend collector) **directly in the runTest body**, the test coroutine drives collection and the scheduler auto-advances through the delays until the flow completes. - If you instead `launch { flow.collect { ... } }` a **separate** coroutine, the collector runs concurrently. To force pending delayed work to run you use `advanceUntilIdle()` (run everything until no tasks remain) or `advanceTimeBy(ms)` (advance the clock by a fixed amount). `runCurrent()` runs tasks scheduled at the current virtual time. ## Why ordering is still correct Virtual time preserves the **order** of emissions because the scheduler resumes tasks in time order. So `toList()` yields values in the same order they were emitted, and `assertEquals` against an ordered `listOf(...)` is valid. ## Common pitfall Using `Dispatchers.IO`/`Default` inside the flow escapes the test scheduler—those dispatchers use real threads/real time. Inject a test dispatcher (`StandardTestDispatcher`/`UnconfinedTestDispatcher`) or `testScheduler` so delays stay virtual. ## Keywords `runTest`, `TestScope`, `TestCoroutineScheduler`, virtual time, `delay`, `advanceUntilIdle()`, `advanceTimeBy()`, `runCurrent()`, `StandardTestDispatcher`.

  • When do you need advanceUntilIdle() if toList() already auto-advances?
    When the flow is collected in a separately launched coroutine rather than directly in the runTest body, so the test coroutine must explicitly drive the scheduler forward.
  • What breaks virtual time inside the flow?
    Switching to a real dispatcher like Dispatchers.IO or Default via flowOn/withContext—those use real threads and real delays, so the scheduler can't skip them.

Virtual time is like a video editor: you can scrub past the boring waiting frames instantly while keeping every event in the right order.

saying these in an interview costs you the question

  • Claiming runTest sleeps for the real delay duration
  • Thinking delay() must be removed to test fast
  • Using Thread.sleep in the flow and expecting it to be skipped
  • Always sprinkling advanceTimeBy even for direct toList()
  • Not realizing Dispatchers.IO escapes the test scheduler

context