skip to content

runTest & Virtual Time

runTest runs your suspend test in a scope with a virtual clock, so delay returns immediately and the scheduler advances automatically. It also surfaces exceptions from child coroutines, which a plain runBlocking would let you miss.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

What is runTest and why do you use it to test suspend functions instead of runBlocking?

level: juniorimportance: must knowfreq 80%

answer

  1. kotlinx-coroutines-test artifact
  2. TestScope + TestCoroutineScheduler virtual clock
  3. delay() skipped, not really waited
  4. auto-advances children to idle
  5. rethrows uncaught child exceptions

basics

~10 s

runTest is a helper from kotlinx-coroutines-test that runs a suspend test body. It skips delays using a fake clock, so tests with delay() finish instantly instead of really waiting.

solid answer

~40 s

runTest is the entry point in kotlinx-coroutines-test for testing suspend code. It builds a TestScope backed by a TestCoroutineScheduler whose virtual clock makes delay() return immediately instead of suspending in real time, so a test that would sleep 10 seconds finishes in milliseconds. It auto-advances the scheduler until all coroutines launched in its scope are idle, then completes. By contrast runBlocking uses a real dispatcher and a real clock, so delays actually block the thread. runTest also aggregates uncaught exceptions from child coroutines and rethrows them so a failing launch{} fails the test. The lambda is a suspend block receiving a TestScope (this: TestScope), giving access to advanceTimeBy, runCurrent, and testScheduler.

code

kotlin · 8 lines
kotlin
@Test
fun timeoutLogic() = runTest {
    val service = OrderService()
    // production code calls delay(30_000) internally on timeout
    val result = service.placeWithTimeout()
    assertEquals(Result.TimedOut, result)
    assertEquals(30_000, currentTime) // virtual time advanced, no real wait
}

go deeper

for a junior

Knows runTest runs suspend tests and that delays are skipped so tests are fast.

for a middle

Explains the virtual clock, auto-advancing children to idle, and TestScope vs runBlocking's real clock.

for a senior

Adds uncaught-exception aggregation, TestResult/multiplatform reason for returning it, and the scheduler being the single source of virtual time.

for a principal

Frames runTest as the deterministic-time substitute for wall-clock concurrency, and discusses when NOT to use it (true I/O timing, real thread interplay).

## What problem runTest solves Testing `suspend` functions and coroutine-based code has two pain points: (1) real `delay()` calls make tests slow and flaky, and (2) work launched in background coroutines (`launch`, `async`) may not finish before the test assertion runs. `runTest` from the `kotlinx-coroutines-test` artifact solves both. ## The virtual clock `runTest` creates a `TestScope` that owns a `TestCoroutineScheduler`. The scheduler holds a **virtual clock** — a simulated notion of time. When code calls `delay(10_000)`, the coroutine does not really sleep; it registers a resume event at virtual-time +10s and yields. The scheduler then jumps the virtual clock forward to that point with **no real wall-clock wait**. This is why a test covering a 30-second timeout still runs in milliseconds. ```kotlin @Test fun fastDelay() = runTest { val start = currentTime // TestScope virtual time, starts at 0 delay(10_000) // skipped, not really waited assertEquals(10_000, currentTime) } ``` ## Auto-advancing The body lambda runs first; when it suspends or finishes, `runTest` repeatedly advances virtual time until the scheduler has no more pending tasks (everything launched in the scope is idle). So child coroutines you `launch {}` inside the scope are driven to completion automatically — you usually do not need to call `join()`. ```kotlin @Test fun childCompletes() = runTest { var done = false launch { delay(1_000); done = true } // body suspends here; runTest auto-advances // (read `done` after the launched job finishes) } ``` ## Uncaught exceptions surface If a child coroutine throws, `runTest` collects that exception and rethrows it at the end, failing the test. With plain `runBlocking` an exception in a detached `launch` could be lost or crash differently. ## Contrast with runBlocking - `runBlocking` — real dispatcher, **real clock**: `delay` truly blocks the calling thread. Fine for production glue code, bad for fast tests. - `runTest` — `TestScope` + virtual clock: delays skipped, auto-advances, surfaces child failures. Returns a `TestResult` (on JVM it just runs; the type matters for multiplatform/JS). ## Key APIs in scope Inside the lambda `this` is a `TestScope`, exposing `testScheduler`, `currentTime`, `advanceTimeBy`, `advanceUntilIdle`, and `runCurrent`. The artifact also gives you `StandardTestDispatcher` and `UnconfinedTestDispatcher`.

  • Does runTest return a value, and why does the signature use `= runTest { ... }`?
    It returns a TestResult. The `@Test fun x() = runTest {}` form returns that result; on JVM it's effectively Unit, but on JS/native the test framework awaits the returned promise, so always return it rather than discarding it.
  • Will a long delay inside a launched child still be skipped?
    Yes — any delay scheduled on the TestScope's scheduler is virtual, whether it's in the body or in a child launch/async, as long as that coroutine uses the test dispatcher.

Like fast-forwarding a recorded video: you still hit every frame (every delay/event) but you don't wait in real time.

saying these in an interview costs you the question

  • Saying runTest actually sleeps for the delay duration
  • Claiming runTest and runBlocking are interchangeable
  • Thinking you must add real Thread.sleep to wait for coroutines
  • Not knowing it comes from kotlinx-coroutines-test

context

open as a page

Inside runTest, what is the difference between virtual time (currentTime) and real wall-clock time, and which delays get skipped?

level: middleimportance: must knowfreq 60%

basics

~10 s

Virtual time is a fake counter the test scheduler controls. currentTime reads it. Only suspending delays that go through the test scheduler are skipped; a real Thread.sleep or blocking I/O still waits.

open as a page

How does runTest handle exceptions thrown by child coroutines (launch/async), and how does that differ from runBlocking?

level: middleimportance: should knowfreq 45%

basics

~10 s

If a coroutine you launch inside runTest throws, runTest catches it and rethrows at the end so the test fails. You don't have to join the child to see the failure.

open as a page

By default runTest uses StandardTestDispatcher — what does that mean for when launched coroutines actually run, and how do you observe intermediate state?

level: seniorimportance: should knowfreq 40%

basics

~10 s

By default, coroutines you launch inside runTest don't run immediately — they queue. The test body runs first; the scheduler runs the queued coroutines when it advances or when the body suspends.

open as a page

Within runTest, what is the difference between launching in the TestScope (this) and in backgroundScope, and when do you need backgroundScope?

level: seniorimportance: should knowfreq 35%

basics

~10 s

Coroutines launched directly in runTest must finish or the test fails. backgroundScope is for never-ending coroutines (like collecting a hot flow) that you want auto-cancelled when the test ends.

open as a page