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:
| Dispatcher | Behavior | Use when |
|---|---|---|
StandardTestDispatcher | Queues new coroutines; nothing runs until you yield | You want precise control of ordering |
UnconfinedTestDispatcher | Enters new coroutines eagerly | Simple 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
- Replace
Dispatchers.Mainin local tests withDispatchers.setMain(testDispatcher)and reset it in teardown — or wrap the pair in a reusable JUnitMainDispatcherRule. Once Main is aTestDispatcher, newly created test dispatchers automatically share its scheduler. stateIn+WhileSubscribedneeds a collector. AStateFlowshared withSharingStarted.WhileSubscribed()won't produce anything in a test until something collects it — launch an empty collector inbackgroundScopefirst.runTesthas a 60-second timeout by default (real time, not virtual). If it trips, the usual culprit is a coroutine still on a real dispatcher.- Keep
kotlinx-coroutines-testout of main sources — it's atestImplementationdependency only.
Resources
- kotlinx-coroutines-test module docs —
TestDispatcher,TestCoroutineScheduler, virtual time runTestAPI reference- Testing Kotlin coroutines on Android — dispatcher injection,
MainDispatcherRule - Testing Kotlin flows on Android —
backgroundScope, thestateIngotcha - Turbine — Flow testing DSL with
awaitItem/awaitComplete/awaitError
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.