Testing coroutines with runTest

Testing Mid-level Kotlin 2.0+ API 24+ Reviewed By Best Latest Apps

Predict the output

How long does this test take in real time?

@Test
fun retriesAfterDelay() = runTest {
    var attempts = 0
    repeat(3) {
        attempts++
        delay(10_000)
    }
    assertEquals(3, attempts)
}
Options
Hint 1 of 1

What kind of clock does runTest use?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A A few milliseconds

Correct. runTest uses virtual time, so delays are skipped.

Why not the others?
  • B 30 seconds. That's runBlocking behaviour.
  • C 10 seconds. runTest doesn't wait for any of the delays.
  • D It times out after 10 seconds. Virtual delays don't count towards the real timeout.

30-second answer

Use runTest { } from kotlinx-coroutines-test. It runs on a TestScope with virtual time, so delay(10_000) finishes instantly, and it fails if coroutines leak. Replace Dispatchers.Main with Dispatchers.setMain(StandardTestDispatcher()) in setup (and resetMain after), and inject other dispatchers so tests can pass the test dispatcher.

Practise saying it · 35 s. No recording, just a timer

Explain how to test coroutines.

Say your answer out loud, then check which of these points you covered:

  • runTest with virtual time
  • setMain with a test dispatcher
  • Inject dispatchers
  • advanceUntilIdle / advanceTimeBy
  • backgroundScope for collectors

Deep dive

StandardTestDispatcher queues coroutines until you advanceUntilIdle() or runCurrent(); UnconfinedTestDispatcher runs them eagerly.

advanceTimeBy(ms) moves virtual time forward to test debounce or timeouts.

A JUnit rule (MainDispatcherRule) wraps setMain/resetMain.

Use backgroundScope for collectors that should be cancelled at the end of the test.

A Main dispatcher rule Compile-checked · Kotlin 2.4.21
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : org.junit.rules.TestWatcher() {
    override fun starting(description: org.junit.runner.Description) = Dispatchers.setMain(dispatcher)
    override fun finished(description: org.junit.runner.Description) = Dispatchers.resetMain()
}

Common mistakes

  • Using runBlocking and waiting real time for delays.
  • Hard-coding Dispatchers.IO so tests can't control it.
  • Forgetting resetMain and leaking into other tests.

Senior take & follow-ups

Mention virtual time and injected dispatchers; that's the core of testable coroutine code.

Follow-up questions

Think of your answer first, then open the hint.

  1. Standard vs Unconfined test dispatcher?

    Show hint

    Standard needs advancing; Unconfined runs immediately.

  2. How do you test a 300 ms debounce?

    Show hint

    advanceTimeBy(300) and runCurrent, then assert.

Similar on iOS: Testing async code Swift async tests await real time unless you inject a clock.

Suggest a fix if something here is wrong or out of date.