Testing Coroutines with runTest

AndroidKotlin
Share on LinkedIn

A ViewModel test flaked three times in CI before someone noticed delay(300) in debounce logic was running real time on a loaded runner. The fix was not increasing timeouts—it was runTest { advanceTimeBy(300) } so the debounce fired instantly and deterministically. Coroutine tests fail when they respect wall clock; they pass when you control virtual time.

runTest from kotlinx-coroutines-test is the standard harness for coroutine unit tests. It installs a TestScope, tracks unfinished coroutines, and throws if work leaks after the test body completes.

Basic runTest structure

@Test
fun `loads data on init`() = runTest {
    val repository = FakeRepository()
    val viewModel = ItemViewModel(repository, this)

    viewModel.load()
    advanceUntilIdle()

    assertEquals(listOf("item-1"), viewModel.state.value.items)
}

advanceUntilIdle() runs all pending tasks until the dispatcher queue is empty.

Dependencies:

testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

Virtual time and delays

@Test
fun `retries after backoff`() = runTest {
    val api = FlakyApi(failuresBeforeSuccess = 2)
    val client = RetryClient(api, delayMs = 1_000)

    val deferred = async { client.fetch() }
    advanceTimeBy(1_000) // first retry
    advanceTimeBy(1_000) // second retry
    advanceUntilIdle()

    assertEquals("ok", deferred.await())
}

Without advancing time, delay(1000) suspends forever in StandardTestDispatcher.

StandardTestDispatcher explicitly

@Test
fun `emits in order`() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val scope = TestScope(dispatcher)

    scope.launch {
        repo.observe().collect { emitted.add(it) }
    }

    repo.emit("a")
    runCurrent() // execute tasks scheduled until next suspend
    assertEquals(listOf("a"), emitted)
}

runCurrent() vs advanceUntilIdle(): use runCurrent when you want step-by-step assertions mid-flow.

Testing ViewModels with Dispatchers.Main

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

@Test
fun `viewModel uses main`() = runTest {
    val viewModel = UserViewModel(repo)
    viewModel.onRefresh()
    advanceUntilIdle()
    assertFalse(viewModel.uiState.value.loading)
}

Combine @get:Rule val main = MainDispatcherRule() with runTest sharing the same scheduler when using StandardTestDispatcher(testScheduler).

Exception and cancellation testing

runTest fails if coroutines launch but never complete:

@Test
fun `cancels work when scope cleared`() = runTest {
    val job = launch { longRunningWork() }
    job.cancel()
    advanceUntilIdle()
    assertTrue(job.isCancelled)
}

Use assertThrows<CancellationException> for explicit cancellation paths.

For expected exceptions in child coroutines, use SupervisorJob in production code and assert error states—not uncaught test failures.

Turbine for Flow tests

Pair runTest with Turbine for Flow assertions:

@Test
fun `state flow updates`() = runTest {
    viewModel.uiState.test {
        assertEquals(Loading, awaitItem())
        advanceUntilIdle()
        assertEquals(Success(data), awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}

Turbine's awaitItem() respects test scheduler when configured with the same scope.

Common mistakes

Mistake Symptom Fix
Hardcoded Dispatchers.IO Test hangs or uses real time Inject dispatcher
Missing advanceUntilIdle Assertions see stale state Advance after actions
GlobalScope in code under test runTest reports uncaught jobs Refactor to injected scope
Mixing runBlocking + runTest Nondeterministic ordering runTest only

TestScope vs runTest timeout

runTest default timeout catches leaked coroutines. Long integration tests may need runTest(timeout = 30.seconds)—but prefer splitting true integration tests to separate JVM suite without virtual time.

What to measure after rollout

Track error rates, tail latency, and resource utilization for two weeks after changes land—most regressions appear under real traffic mixes, not in staging smoke tests. Keep a rollback path documented: feature flags, Helm revision, or Git revert with known good digest. Review on-call pages tied to the topic quarterly; delete alerts that never fire and add thresholds that would have caught your last incident.

Run a short blameless postmortem if production surprised you, even for minor issues. The goal is updating this runbook section with one concrete lesson per quarter so the next engineer inherits context, not just configuration snippets.

Documentation your team should maintain

Maintain a one-page runbook link from your main service README: prerequisites, owner rotation, last drill date, and known sharp edges. Link to vendor docs in the Resources section below but capture org-specific decisions (CIDR ranges, cluster names, approval gates) in internal docs that stay current. New hires should deploy a safe canary within a week using only that runbook—if they cannot, the doc is incomplete.

Pre-production checklist

Before promoting to production, walk through this list with someone who was not the primary author—fresh eyes catch assumptions.

If any item is "we will do that later," treat it as a release blocker for tier-1 services.

Common questions from reviewers

Reviewers and auditors often ask whether this approach scales with team growth and whether it fails safely. Answer explicitly in your design doc: what happens when dependencies are down, when credentials expire, and when traffic doubles overnight. Prefer defaults that deny or degrade gracefully over defaults that fail open. Document known limits (throughput ceilings, supported versions, regions) in the same place operators look during incidents—avoid scattering critical constraints across Slack threads.

Resources

Frequently asked questions

Why not use runBlocking in coroutine tests?

runBlocking uses real time and blocks the test thread. Slow delays make tests flaky and slow. runTest uses a TestDispatcher with virtual time—delay(5000) completes instantly when you advance the clock. Tests become deterministic and fast.

What is the difference between StandardTestDispatcher and UnconfinedTestDispatcher?

StandardTestDispatcher queues coroutines until you call runCurrent or advanceUntilIdle—explicit control. UnconfinedTestDispatcher executes eagerly on the calling thread until the first suspension, which can surprise you with ordering. Standard is the default choice for most unit tests.

How do I inject TestDispatcher into classes under test?

Pass a CoroutineDispatcher constructor parameter or use a test rule that sets Dispatchers.setMain(testDispatcher) for Android ViewModel tests. Avoid hardcoding Dispatchers.IO in code you want to test—inject dispatchers or use a CoroutineScope you control in tests.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →