Yeda AI Tips · #078

Español

Never Launch Real Coroutines in Tests

Async tests that never flake? Never launch a real coroutine in a test. A real dispatcher runs on real threads and real time — so your async test passes on your fast machine and flakes on a loaded CI runner. The fix is one design rule: inject the dispatcher, and in tests swap in a deterministic one that runs on virtual time.

Why real dispatchers flake

When production code hardcodes Dispatchers.IO or Dispatchers.Default, every coroutine it launches lands on a real thread pool. Your test now races against the scheduler: an assertion that runs 5 ms too early fails, a delay(500) costs 500 real milliseconds, and whether the launched coroutine finished before your assert depends on machine load. That's the classic pass-locally-flake-in-CI signature. Android's official guidance is blunt about it: don't use real dispatchers in unit tests — they cause thread issues, flaky tests, and unpredictable timing.

Inject the dispatcher

Make the dispatcher a constructor parameter instead of a hardcoded global:

class Repository(
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        delay(500L)          // skipped instantly under a TestDispatcher
        "Hello world"
    }
}

@Test
fun fetchIsDeterministic() = runTest {
    val repository = Repository(StandardTestDispatcher(testScheduler))
    assertEquals("Hello world", repository.fetchData())
}

runTest (from kotlinx-coroutines-test) creates a TestScope backed by a TestDispatcher on a single test thread. Delays are skipped, not waited out — a test full of delay(1_000) calls finishes in milliseconds, and execution order stays deterministic.

Control virtual time explicitly

Two TestDispatcher implementations, one decision:

DispatcherBehaviorUse when
StandardTestDispatcherQueues new coroutines; nothing runs until you yieldYou want precise control of ordering
UnconfinedTestDispatcherEnters new coroutines eagerlySimple tests where ordering doesn't matter

With the standard dispatcher, you drive the clock yourself: advanceUntilIdle() runs everything queued, advanceTimeBy(duration) moves virtual time forward, runCurrent() runs only what's due right now. The coroutine runs exactly when you say — not when a thread pool gets around to it.

One hard rule: a test must have exactly one TestCoroutineScheduler. Every TestDispatcher you create should share it (StandardTestDispatcher(testScheduler)); a stray StandardTestDispatcher() silently spins up a second clock and your "deterministic" test races itself.

Test the whole Flow lifecycle

Most Flow tests only assert emissions. The bugs live in the other paths: does the flow complete? Does it surface errors? Does collection actually stop on cancellation? For finite flows, first() and toList() cover emissions. For everything else, collect continuously with backgroundScope (auto-cancelled when the test ends) or use Turbine's test {} DSL, which gives you awaitItem(), awaitComplete(), and awaitError() as first-class assertions — completion, error, and cancellation become things you check, not things you hope.

Power-user notes

Resources

Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.

Talk to us · Read the blog