Engineering

runTest skips your delays, which is why the flaky test passed

Virtual time makes a 30-second timeout instant — and makes a real-dispatcher leak invisible.

MobileByteSensei 9 min read
kmptestingcoroutines

TL;DR

runTest runs on a TestDispatcher with a virtual clock. delay(30.seconds) returns immediately; the clock jumps instead of the thread waiting. That is why coroutine tests are fast and deterministic, and it is the right default.

It also means two things you will eventually be bitten by. A test can assert the effect of a timeout without ever proving the timeout fires in real time. And any coroutine that escapes onto a real dispatcher is no longer on the virtual clock, so advanceUntilIdle() returns while that work is still in flight — producing a test that passes locally, fails on CI, and gets labelled flaky.

advanceUntilIdle advances virtual time. It is not "wait for everything".

The case this post is about

A sync manager with a timeout and a retry, under test:

// commonMain
class SyncManager(private val api: Api, private val dispatcher: CoroutineDispatcher) {

    suspend fun sync(): SyncResult = withContext(dispatcher) {
        repeat(3) { attempt ->
            try {
                return@withContext withTimeout(30.seconds) { api.pull() }
            } catch (e: TimeoutCancellationException) {
                delay(2.seconds * (attempt + 1))        // backoff
            }
        }
        SyncResult.Failed
    }
}
// commonTest
@Test
fun retriesThreeTimesThenFails() = runTest {
    val api = HangingApi()
    val result = SyncManager(api, Dispatchers.Default).sync()   // ← the problem
    assertEquals(SyncResult.Failed, result)
    assertEquals(3, api.attempts)
}

This test passes. It finishes in 40 milliseconds despite describing 96 seconds of behaviour, which should have been the clue.

The symptom

The test passed locally, every time, and failed on CI about one run in six:

retriesThreeTimesThenFails   PASSED   (local, 41ms)
retriesThreeTimesThenFails   PASSED   (local, 38ms)
retriesThreeTimesThenFails   FAILED   (CI)
    expected 3 attempts but was 1
retriesThreeTimesThenFails   FAILED   (CI)
    expected 3 attempts but was 2

Two details made it look like flakiness rather than a bug:

The failure count varied. Sometimes 1, sometimes 2, occasionally 0. A wrong-but-consistent answer reads as a bug; a varying answer reads as a race, and "it's flaky on CI" is where investigations go to die.

It only failed on the slower machine. Which inverts the usual intuition — a slower machine should give background work more time, not less.

Then a second symptom in a different test, same root cause:

@Test
fun timesOutAfterThirtySeconds() = runTest {
    val start = testScheduler.currentTime
    SyncManager(api, StandardTestDispatcher(testScheduler)).sync()
    assertEquals(96_000, testScheduler.currentTime - start)   // 3×30s + 2s + 4s
}

That one asserts the right thing and proves less than it appears to: it verifies the declared durations compose correctly, not that a real 30-second timeout ever fires.

Why the obvious fix failed

"Add a delay at the end of the test to let things finish." The reflex, and it cannot work — delay inside runTest is virtual, so it returns immediately and waits for nothing. This is the single most instructive failure here: the tool you would reach for to wait is the tool that does not wait.

"Use Thread.sleep instead." It does block a real thread, and it is unavailable in commonMain/commonTest (there is no Thread on native or wasm), so the shared test cannot use it. On the JVM-only path it "fixes" the test by making it slow and still racy.

"Retry the test on CI." We did, for two weeks. It converted a real bug into a longer build, and the underlying defect — a production class that hard-codes its dispatcher — stayed.

"Increase the CI timeout." Addresses a symptom that was never the problem. The test was not timing out; it was asserting too early.

The actual mechanism

runTest installs a TestScheduler and a TestDispatcher as the test coroutine's context. The scheduler holds a virtual current time.

delay consults the scheduler, not the OS. A delaying coroutine registers a resumption at virtual time T and suspends. When nothing else can run, the scheduler advances its clock to the next registered time and resumes it. No thread ever sleeps.

advanceUntilIdle() drains the scheduler's queue. It runs tasks and advances virtual time until the scheduler has nothing left. That is a statement about tasks the scheduler knows about.

A real dispatcher is not on that scheduler. Dispatchers.Default runs on a real thread pool with real timing. The test scheduler has no visibility into it, so advanceUntilIdle considers itself idle while that work continues.

Which makes the defect a property of the code under test, not of the test. A single withContext is enough to leave the scheduler's world:

// production code — the test can do nothing about this from outside
suspend fun sync() = withContext(Dispatchers.IO) {
    delay(30.seconds)          // a REAL 30 seconds; the virtual clock is not consulted
    api.push(pending)
}

No amount of advanceUntilIdle() reaches that delay, because the coroutine running it is not on the test scheduler. The test either waits thirty real seconds or — far more commonly — asserts while the work is still in flight and passes for the wrong reason.

So "use virtual time" is really "inject the dispatcher", and the two are the same requirement:

class Syncer(private val io: CoroutineDispatcher) {
    suspend fun sync() = withContext(io) { delay(30.seconds); api.push(pending) }
}

@Test fun syncRetriesAfterBackoff() = runTest {
    // StandardTestDispatcher shares THIS test's scheduler — so the delay is virtual
    val syncer = Syncer(StandardTestDispatcher(testScheduler))
    …
}

Passing testScheduler is the load-bearing part. A bare StandardTestDispatcher() constructs its own scheduler, which puts the work back outside the one advanceUntilIdle drains — the same bug, now wearing a test-dispatcher costume and considerably harder to spot in review.

So the race is between the test body's assertions and a thread pool the test cannot see — and a slower machine makes the pool later, which is why the slow machine failed more. The local machine was fast enough to finish the real work before the assertion by luck.

A test scheduler's virtual clock jumping from zero to ninety-six seconds without any thread sleeping, beside a real dispatcher running on a thread pool the scheduler cannot see — so advanceUntilIdle reports idle while that pool is still mid-retry, and the assertion reads a partial attempt count that varies with machine speed

The rule:

Virtual time only governs coroutines on the test dispatcher. Anything that hops to a real dispatcher leaves the clock, and advanceUntilIdle will cheerfully report idle while it runs.

The fix

Inject the dispatcher so the test can supply the test one. That is the whole fix, and it is the same injectability lesson that applies to clocks.

// commonTest
@Test
fun retriesThreeTimesThenFails() = runTest {
    val api = HangingApi()
    // StandardTestDispatcher shares THIS test's scheduler, so the work is on the virtual clock.
    val manager = SyncManager(api, StandardTestDispatcher(testScheduler))

    val result = manager.sync()

    assertEquals(SyncResult.Failed, result)
    assertEquals(3, api.attempts)
    assertEquals(96_000, currentTime)      // 3×30s timeout + 2s + 4s backoff
}

The production class already took a dispatcher — it was the test that passed a real one. Where it does not, that is the change to make:

// commonMain — dispatcher is a dependency, with a platform default
class SyncManager(
    private val api: Api,
    private val dispatcher: CoroutineDispatcher = platformIoDispatcher(),
)

Two further pieces are worth having.

A leak detector, so this class of bug fails loudly rather than flakily. runTest already fails a test whose child coroutines have not completed — the gap is work on a foreign dispatcher. An explicit assertion closes it:

// commonTest
@Test
fun noWorkEscapesTheTestDispatcher() = runTest {
    val api = CountingApi()
    SyncManager(api, StandardTestDispatcher(testScheduler)).sync()
    advanceUntilIdle()
    assertEquals(api.started, api.finished)   // nothing still in flight
}

And one real-time test, for the thing virtual time cannot prove. Keep exactly one, and name it so nobody "optimises" it away:

// commonTest — deliberately NOT runTest. Asserts a real wall-clock bound.
@Test
fun realTimeoutActuallyFires() = runBlocking {
    val elapsed = measureTime {
        withTimeoutOrNull(300.milliseconds) { HangingApi().pull() }
    }
    assertTrue(elapsed < 1.seconds, "timeout did not fire in real time: $elapsed")
}

Short duration, real clock, one assertion: that the timeout mechanism works at all. The virtual tests then cover the composition of durations, which is what they are good at. Using virtual time for the arithmetic and one real test for the mechanism is the division that actually holds.

How we proved it

The bug is a race, so the honest proof is to make it deterministic rather than to run it until it passes.

Forcing the failure. A deliberate delay on the fake API's real-dispatcher path turns the one-in-six into every time:

with Dispatchers.Default + HangingApi(startupDelay = 50ms)
  run 1  FAILED  expected 3 attempts but was 0
  run 2  FAILED  expected 3 attempts but was 0
  run 3  FAILED  expected 3 attempts but was 0

Reproducing a flaky failure deterministically is the step that distinguishes a diagnosis from a guess. Before this, "slow machine" was a theory; after it, the mechanism is demonstrated.

After the fix, across the matrix:

jvmTest                retriesThreeTimesThenFails  PASSED  (12ms, currentTime=96000)
androidUnitTest        retriesThreeTimesThenFails  PASSED  (14ms, currentTime=96000)
iosSimulatorArm64Test  retriesThreeTimesThenFails  PASSED  (31ms, currentTime=96000)
wasmJsTest             retriesThreeTimesThenFails  PASSED  (48ms, currentTime=96000)

Identical currentTime on four targets is the useful signal: the assertion is now on virtual time, which is machine-independent by construction.

And 500 CI runs, because the original claim was about frequency:

before:  500 runs → 83 failures  (16.6%)
after:   500 runs →  0 failures

One honest limit, and it is the point of the real-time test above: nothing here proves that withTimeout(30.seconds) fires after thirty seconds on a real device. The virtual tests prove the retry logic composes the durations I declared. If the production dispatcher is misconfigured in a way that breaks real delays, this suite stays green — which is a gap I chose to cover with one cheap real-clock test rather than pretend away.

What this generalizes to

A fake clock tests logic, not timing. This is true of every injected clock, every frozen time in a test framework, every simulated scheduler. You get determinism and speed; you give up evidence that the real mechanism fires. Keep one real-time test for the mechanism and use the fake for everything about the arithmetic.

"Wait for idle" is always scoped to what the waiter can see. advanceUntilIdle, an IdlingResource, await all tasks, a framework's flush() — each drains a queue it owns. Work that escaped to another executor is outside the promise, and the symptom is an assertion that reads too early.

Varying failure counts point at an unobserved concurrent actor. A consistently wrong value is a logic bug; a value that changes between runs means something you are not controlling is racing you. That reframing is what turns "flaky" into a findable bug.

Reproduce the flake deterministically before fixing it. Adding the 50ms startup delay took two minutes and converted a statistical argument into a demonstration. Retry-until-green is the alternative, and it is how a real defect survives for two weeks.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS