Testing Kotlin Coroutines: TestDispatcher, Virtual Time, and Turbine

Last month I was adding unit tests to a networking module and hit an annoying problem: a viewModelScope.launch called delay(2000) and then updated UI state. The test either took two real seconds or never reached the updated assertion. runBlocking could block, but it did not give me structured-concurrency-friendly virtual time. advanceTimeBy looked useful in the docs, but the real question was how to inject the scheduler into the code under test.

Coroutine testing is not hard because the APIs are complex. It is hard because time control and dispatcher injection are hidden in the implementation, and the documentation often stays at the surface. The key is to understand two things: how virtual time works, and how to put a test dispatcher into the code path under test.

runTest Virtual Time: Time Stops in Tests

kotlinx-coroutines-test provides runTest. Its core capability is virtual time: delay operations based on coroutine dispatchers do not really wait. They are recorded at future timestamps and run only when the scheduler advances.

@Test
fun `delay should not really wait`() = runTest {
    val start = System.currentTimeMillis()
    delay(5000) // The virtual clock jumps ahead by 5 seconds
    val elapsed = System.currentTimeMillis() - start
    assertTrue(elapsed < 100) // Actual wall time is under 100 ms
}

Internally, runTest uses TestCoroutineScheduler to maintain a virtual timeline. When a coroutine calls delay(n), the scheduler does not block a thread. It records an event that says “resume at current time + n milliseconds.” Only when you call runCurrent(), or when the test coroutine naturally suspends, does the scheduler jump to the nearest pending event and execute it.

There is a catch: runTest advances time by default only when the top-level test coroutine becomes idle. If the code under test starts a child coroutine with launch and you do not join or cancelAndJoin it, time will not automatically jump to the child coroutine’s completion.

@Test
fun `child coroutine needs explicit join`() = runTest {
    var result = ""
    launch {
        delay(1000)
        result = "done"
    }
    // At this point the child coroutine has not finished, so result is still ""
    assertEquals("", result) // Passes, but it is not what you intended
}

You can solve this by joining children after launch, for example with currentCoroutineContext().job.children.forEach { it.join() }. More directly, use StandardTestDispatcher and call runCurrent() manually.

TestDispatcher Injection: Give Tests Control of Time

By default, runTest uses StandardTestDispatcher. It does not eagerly advance time. Delayed tasks stay in the scheduler until test code explicitly advances them. That is exactly the control level unit tests need.

There are two common injection scenarios.

Scenario 1: The Code Under Test Accepts CoroutineContext

The most direct design is to make the production class accept a CoroutineContext parameter, defaulting to something like Dispatchers.IO, and pass a test dispatcher during tests:

class UserRepository(
    private val api: Api,
    private val dispatcher: CoroutineContext = Dispatchers.IO
) {
    suspend fun fetchUser(id: String): User = withContext(dispatcher) {
        delay(300) // Simulate network latency
        api.getUser(id)
    }
}

@Test
fun `fetchUser with injected dispatcher`() = runTest {
    val repo = UserRepository(fakeApi, coroutineContext)
    val user = repo.fetchUser("1")
    assertEquals("test_user", user.name)
}

Here, runTest’s coroutineContext, which contains the test dispatcher, is passed directly into the repository. The 300 ms delay completes instantly in test time.

Scenario 2: A ViewModel Hard-Codes Dispatchers.Main

This is common in Android: viewModelScope.launch runs on Dispatchers.Main, and you cannot inject a parameter directly. In that case, use Dispatchers.setMain:

@Before
fun setup() {
    Dispatchers.setMain(StandardTestDispatcher())
}

@After
fun tearDown() {
    Dispatchers.resetMain()
}

@Test
fun `viewModel fetches and updates state`() = runTest {
    val viewModel = MyViewModel(fakeRepo)
    advanceUntilIdle() // Advance all pending tasks
    assertEquals(Loaded("data"), viewModel.state.value)
}

advanceUntilIdle() keeps advancing virtual time until the scheduler has no pending tasks. For a ViewModel path like launch -> delay -> update StateFlow, this handles the whole chain.

When you need finer control, use advanceTimeBy(duration), for example when testing timeout behavior:

@Test
fun `timeout triggers fallback`() = runTest {
    val viewModel = MyViewModel(fakeSlowRepo)
    advanceTimeBy(5100) // Skip past the 5-second timeout threshold
    assertEquals(Fallback("timeout"), viewModel.state.value)
}

One pitfall I have hit: if @Before calls Dispatchers.setMain(testDispatcher) but runTest creates a different TestDispatcher internally, they are not the same scheduler instance. advanceUntilIdle() advances the scheduler owned by runTest, while viewModelScope.launch runs on the dispatcher you installed as Main. The two are disconnected and the test may hang. Use the same instance:

val testDispatcher = StandardTestDispatcher()

@Before
fun setup() {
    Dispatchers.setMain(testDispatcher)
}

@Test
fun `shared dispatcher works`() = runTest(testDispatcher) { ... }

Turbine: Flow Assertions Without Hand-Written Collectors

For StateFlow, checking state.value is often enough. A regular Flow has no hot value, so you need to collect it. My early tests looked like this:

@Test
fun `manual collect is tedious`() = runTest {
    val flow = repo.observeEvents()
    val events = mutableListOf<Event>()
    val job = launch { flow.toList(events) }
    advanceUntilIdle()
    job.cancel()
    assertEquals(listOf(Event.Start, Event.Data), events)
}

After a few test cases, this became unpleasant: each test needed launch, toList, and cancel, and failures did not produce especially clear stacks. Turbine is a lightweight library built specifically for Flow tests. Its core APIs are test() and awaitItem().

@Test
fun `flow emits loading then data`() = runTest {
    repo.observeData().test {
        assertEquals(Loading, awaitItem())
        assertEquals(Data("result"), awaitItem())
        awaitComplete() // Assert that the Flow completes normally
    }
}

test { } starts a collecting coroutine internally. awaitItem() suspends until the next emitted value. awaitComplete() asserts that the Flow completed normally. Everything runs under runTest virtual time, so delay does not really wait.

If the Flow under test emits a finite number of items and then completes, call awaitComplete(). Otherwise Turbine may keep waiting for another value and the test can time out.

To test a Flow that throws, use awaitError():

repo.errorFlow().test {
    assertEquals(Start, awaitItem())
    assertEquals("Network error", (awaitError() as IOException).message)
}

Turbine’s cancelAndIgnoreRemainingEvents() is useful when you only need to validate the first few events:

flow.test {
    assertEquals("first", awaitItem())
    cancelAndIgnoreRemainingEvents()
}

If you do not call it, Turbine may notice at the end of test {} that the Flow has not completed and throw a CancellationException, causing the test to fail.

Engineering Notes the Docs Often Skip

flowOn dispatcher behavior. If the Flow under test uses flowOn(Dispatchers.IO), the main dispatcher inside runTest is a TestDispatcher, but the internal dispatcher created by flowOn is not affected by Dispatchers.setMain. The Flow still should not block on real delays: runTest brings coroutine delays under virtual-time control. I have verified that flowOn(Dispatchers.IO).delay(5000) completes instantly inside runTest. The mechanism is that runTest intercepts the underlying DefaultDelay; as long as the code runs in a coroutine context, delay uses virtual time.

Testing countdown logic. Consider a 60-second tick Flow:

fun countdownFlow(seconds: Int): Flow<Int> = flow {
    repeat(seconds) { i ->
        emit(seconds - i)
        delay(1000)
    }
}

@Test
fun `countdown emits all ticks instantly`() = runTest {
    countdownFlow(60).test {
        (60 downTo 1).forEach { assertEquals(it, awaitItem()) }
        awaitComplete()
    }
}

Sixty awaitItem() calls and sixty delay(1000) calls finish immediately under virtual time. If you tested this with real time, each case would take a minute and CI would become painful fast.

Assert complete event sequences for complex state. Do not only assert the final value. Cover every important event in the chain:

viewModel.events.test {
    // The first two events are related to initialization
    assertEquals(Init, awaitItem())
    assertEquals(Loading, awaitItem())
    
    // After triggering the action
    viewModel.onAction(Refresh)
    assertEquals(Data(emptyList()), awaitItem())
    assertEquals(Idle, awaitItem())
    
    cancelAndIgnoreRemainingEvents()
}

Asserting the event sequence catches intermediate-state bugs better than checking only state.value. For example, a UI flicker caused by an extra empty-data emission between Loading and Data will only show up in a sequence test.

A few practical rules: first, new projects should let Repository and UseCase classes accept a CoroutineContext from the start. Do not hard-code Dispatchers.Main inside classes; dispatcher injection is the lowest-cost test-friendly design. Second, set a reasonable testTimeLimit for runTest (the default is 10 seconds). If virtual time does not finish advancing, the test fails quickly and tells you that you probably forgot advanceUntilIdle. Third, Turbine is best for event-sequence validation. For StateFlow, use state.value. They solve different testing problems.

Further Reading