skip to content

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%

answer

  1. currentTime = virtual ms, starts at 0
  2. delay/withTimeout schedule on the scheduler -> skipped
  3. Thread.sleep / blocking I/O / Instant.now NOT skipped
  4. hardcoded Dispatchers.IO escapes virtual time
  5. one scheduler shared across test dispatchers

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.

solid answer

~40 s

Inside runTest the TestScope exposes currentTime, the scheduler's virtual-clock value in milliseconds, starting at 0. delay() and withTimeout() schedule resume events on the TestCoroutineScheduler; advancing the clock (auto or via advanceTimeBy/advanceUntilIdle) fires them instantly. So coroutine-suspending time is fully virtual. What is NOT skipped: anything that blocks a real thread rather than suspending — Thread.sleep, blocking JDBC, System.currentTimeMillis based logic, or work on a dispatcher whose scheduler is not the test scheduler (e.g. a hardcoded Dispatchers.IO). Those run in real time and break determinism, which is the main reason you inject test dispatchers instead of hardcoding them. currentTime is also useful to assert how much virtual time elapsed, verifying timeout/retry/backoff logic deterministically.

code

kotlin · 7 lines
kotlin
@Test
fun backoffIsVirtual() = runTest {
    val repo = Repo(dispatcher = StandardTestDispatcher(testScheduler))
    repo.fetchWithRetry() // does delay(100) + delay(200) backoff internally
    advanceUntilIdle()
    assertEquals(300, currentTime) // exact virtual backoff, zero real wait
}

go deeper

for a junior

Knows currentTime is fake time and that delay is skipped.

for a middle

Distinguishes suspension (virtual) from blocking (real) and uses currentTime to assert elapsed virtual time.

for a senior

Explains how a hardcoded dispatcher escapes the scheduler and breaks determinism, motivating dispatcher injection and a shared scheduler.

for a principal

Generalizes to a testability principle: externalize all sources of non-determinism (time, dispatchers, the system clock) behind injectable abstractions.

## Two clocks - **Wall-clock / real time** — actual elapsed milliseconds, what `Thread.sleep` and `System.nanoTime` observe. - **Virtual time** — a simulated counter owned by the `TestCoroutineScheduler`. In a `TestScope` you read it via `currentTime` (Long, ms). It starts at `0` and only moves when the scheduler advances. ## What schedules on virtual time Suspending primitives that go through the **test dispatcher's scheduler** register their resume as a timed event: - `delay(ms)` — resumes at `now + ms` virtual. - `withTimeout` / `withTimeoutOrNull` — arm a virtual timeout. - Anything built on these (e.g. `kotlinx.coroutines.flow` `debounce`, `sample`, retry backoff using `delay`). When the scheduler advances the clock to that timestamp, the coroutine resumes — with zero real waiting. ```kotlin @Test fun measuresVirtualElapsed() = runTest { assertEquals(0, currentTime) delay(200) delay(300) assertEquals(500, currentTime) // virtual ms accumulated } ``` ## What is NOT skipped Virtual time only governs *suspension*, not *blocking*: - `Thread.sleep(ms)` blocks a real thread — really waits. - Blocking I/O (JDBC, file reads) — really waits. - `System.currentTimeMillis()` / `Instant.now()` — read the real OS clock, not `currentTime`. - Coroutines launched on a dispatcher **not tied to the test scheduler** (e.g. a literal `withContext(Dispatchers.Default)` or `Dispatchers.IO`) — their delays use the real timed-resume of those dispatchers and are NOT virtual. This last point is the crux: to keep delays virtual, every coroutine in the code under test must run on a dispatcher backed by the test scheduler. That is why production code should accept an injected `CoroutineDispatcher` rather than hardcoding `Dispatchers.IO`. ## Why currentTime matters for assertions Because it is deterministic, you can assert timing logic exactly: 'after the retry loop, exactly 700 ms of (virtual) backoff elapsed', or 'the timeout fired at 30_000'. You cannot do that reliably with the real clock. ## Single source of truth All dispatchers created from one `runTest` share the *same* scheduler, so virtual time is consistent across them. Passing `testScheduler` (or the TestScope's dispatcher) into manually-created `StandardTestDispatcher(testScheduler)` keeps them linked.

  • A teammate's runTest is slow despite using delay. What's the likely cause?
    The code under test runs on a real dispatcher (hardcoded Dispatchers.IO/Default) or uses Thread.sleep, so those waits use real time. Inject the test scheduler's dispatcher instead.
  • How would you make code that reads Instant.now() testable under virtual time?
    Inject a Clock/time provider abstraction and supply a controllable test clock; currentTime governs only coroutine delays, not java.time.

Virtual time is a director shouting 'time-jump!' on a film set — staged delays vanish, but if an actor really falls asleep (Thread.sleep) the whole crew waits.

saying these in an interview costs you the question

  • Claiming Thread.sleep is also skipped by virtual time
  • Thinking System.currentTimeMillis tracks currentTime
  • Not realizing a hardcoded dispatcher escapes the virtual clock
  • Believing different test dispatchers have separate clocks by default

context