8 min read

Testing Coroutines: Virtual Time, Not Thread.sleep

Sleeping in a coroutine test is slow, flaky and asserts nothing. How runTest's virtual clock works, choosing between the two test dispatchers, the backgroundScope pattern for Flow collectors, and the four mistakes that make a virtual-time test behave like a real-time one.

KotlinCoroutinesTestingAndroidFlow
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Thread.sleep in a coroutine test is slow, flaky under CI load, and asserts that time passed rather than that work finished. runTest provides a virtual clock: delay(30_000) completes instantly and deterministically. Use StandardTestDispatcher by default (it queues, so ordering bugs surface) and UnconfinedTestDispatcher for collectors that must subscribe before emission. Four things break virtual time: a hard-coded Dispatchers.IO in the code under test, collecting an infinite flow in the test scope instead of backgroundScope, forgetting Dispatchers.setMain, and creating dispatchers that don't share testScheduler.

What's wrong with sleeping

@Test
fun `retries on failure`() {
    repo.load()
    Thread.sleep(500)              // hope
    assertEquals(3, api.calls)
}

Three separate defects:

  1. It's slow. Five hundred milliseconds × a few hundred tests is minutes per CI run, permanently.
  2. It's flaky. On a loaded CI machine the work takes 600ms and the test fails. Someone raises the sleep to 1000ms, and the suite gets slower without getting more correct.
  3. It asserts the wrong thing. It doesn't verify the retries finished — it verifies that half a second elapsed and then guesses. When it fails, the message tells you nothing about why.

That third point is the real one. A sleeping test is a race that usually resolves in your favour, which is a different thing from a test.

Virtual time

runTest runs the body on a TestScope backed by a scheduler that fakes the clock. delay() doesn't wait — it registers with the scheduler, which jumps straight to the next scheduled event.

@Test
fun `retries three times with exponential backoff`() = runTest {
    repo.load()            // delays 1s, 2s, 4s internally
    advanceUntilIdle()     // seven virtual seconds; zero real ones
    assertEquals(3, api.calls)
}

Seven seconds of retry logic, tested in about a millisecond, deterministically. The test is now faster than the sleep it replaced and strictly more correct — it asserts the work completed, not that time passed.

Controlling the clock

advanceUntilIdle()      // run everything until nothing is scheduled
advanceTimeBy(5_000)    // advance the clock by 5 virtual seconds
runCurrent()            // run only what's scheduled at the current instant
currentTime             // read the virtual clock (assert on it!)

currentTime is underused and genuinely valuable — it lets you assert on timing rather than just outcome:

repo.load()
advanceUntilIdle()
assertEquals(7_000, currentTime)   // backoff really was 1+2+4

You've just tested a timing policy without waiting for it. That's not possible with real time at all.

The two dispatchers

StandardTestDispatcher(testScheduler)    // queues; you advance
UnconfinedTestDispatcher(testScheduler)  // runs eagerly, inline

Default to Standard. New coroutines are queued rather than run immediately, which means ordering assumptions in your code become visible — if your production code only works when a child coroutine runs eagerly, that's a bug and Standard will show it. runTest uses it by default.

Use Unconfined for collectors. When you need a subscriber attached before the emission happens, eager execution is exactly right, and advanceUntilIdle() between every step gets tedious fast.

Mixing them in one test is fine and often correct: Standard for the code under test, Unconfined for the collector watching it.

Four things that break virtual time

1. A hard-coded dispatcher in the code under test

// ❌ Virtual time can't reach a real thread pool.
suspend fun load() = withContext(Dispatchers.IO) { … }

The moment execution leaves the test scheduler, you're back to real threads and real races. Your delays become real waits and the test flakes again — often only on CI.

// ✅ Inject it.
class Repo(
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    suspend fun load() = withContext(io) { … }
}

// in the test
Repo(io = StandardTestDispatcher(testScheduler))

This is the concrete reason to inject dispatchers. Not architectural purity — a test that can control time.

2. Collecting an infinite flow in the test scope

// ❌ runTest never completes: state is a StateFlow,
//    which never finishes.
@Test fun broken() = runTest {
    viewModel.state.collect { … }
}

The test hangs until runTest's real-time timeout (60s by default) and fails with a message that doesn't mention the cause.

// ✅ backgroundScope is cancelled when the body ends.
@Test fun `emits loading then content`() = runTest {
    val states = mutableListOf<UiState>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.state.toList(states)
    }

    viewModel.load()
    advanceUntilIdle()

    assertEquals(
        listOf(UiState.Loading, UiState.Content(items)),
        states,
    )
}

Three deliberate choices there: backgroundScope so the collector is cancelled automatically, UnconfinedTestDispatcher so it subscribes before load() emits anything, and testScheduler passed explicitly — see below.

Turbine does the same job with less ceremony and is worth adopting if you assert on flows often:

viewModel.state.test {
    assertEquals(UiState.Loading, awaitItem())
    viewModel.load()
    assertEquals(UiState.Content(items), awaitItem())
}

3. Forgetting Dispatchers.setMain

viewModelScope uses Dispatchers.Main, which doesn't exist in a JVM unit test.

class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
    override fun finished(d: Description) = Dispatchers.resetMain()
}

Write it once, apply it everywhere. Without it you get Module with the Main dispatcher had failed to initialize, which is at least an honest error.

4. Dispatchers that don't share the scheduler

The subtlest one, and it produces the strangest behaviour:

// ❌ Two independent virtual clocks.
val a = StandardTestDispatcher()
val b = StandardTestDispatcher()

// ✅ One clock.
val a = StandardTestDispatcher(testScheduler)
val b = StandardTestDispatcher(testScheduler)

With separate schedulers, advanceUntilIdle() on one leaves the other frozen. Work appears to never run, delays don't resolve, and nothing in the failure points at the cause. Always pass testScheduler.

What not to test

Virtual time makes timing testable, which makes it tempting to test everything about it. Two limits:

  • Don't assert on internal scheduling order unless it's part of the contract. You'll pin the implementation and break the test on every refactor.
  • Don't use runTest to paper over a real concurrency bug. If a test only passes with a well-placed advanceTimeBy, the production code may have an ordering assumption that a real device won't honour. StandardTestDispatcher exists to make that visible — listen to it.

The rule

If a coroutine test mentions a real duration, it's wrong. runTest gives you a clock you control, so assert that work completed rather than that time passed — and while you're there, assert on currentTime to test the timing policy itself. Inject your dispatchers, share one testScheduler, and put infinite collectors in backgroundScope.

Keep reading