skip to content

Injecting Test Dispatchers

Production code should take its dispatcher as a dependency so tests can substitute a scheduler-backed one, with Dispatchers.setMain covering the UI dispatcher. Hardcoding Dispatchers.IO in a class is exactly the testability smell interviewers probe.

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

questions

5

Why should a class that launches coroutines take a CoroutineDispatcher as a constructor parameter instead of hardcoding Dispatchers.IO or Dispatchers.Default?

level: juniorimportance: must knowfreq 70%

answer

  1. Hardcoded IO/Default = real threads = slow/flaky
  2. Inject CoroutineDispatcher, default to production
  3. Test substitutes scheduler-backed test dispatcher
  4. Shared testScheduler makes delay() virtual
  5. DI principle applied to concurrency

basics

~10 s

So tests can pass in a fake, controllable dispatcher. If the dispatcher is hardcoded, the test cannot speed up or control timing and must wait for real threads, making tests slow and flaky.

solid answer

~30 s

Hardcoding Dispatchers.IO/Default couples the class to real background threads, so tests run on real schedulers with real delays — slow, non-deterministic, and impossible to advance virtual time. Injecting the dispatcher (constructor param, often defaulting to the production one) lets a test substitute a StandardTestDispatcher or UnconfinedTestDispatcher backed by runTest's TestScheduler. That single shared scheduler makes delay() skippable, makes coroutine execution deterministic, and lets the test advance virtual time. This is the dependency-injection principle applied to concurrency: the unit under test depends on the CoroutineDispatcher abstraction, and the test controls the concrete implementation.

code

kotlin · 14 lines
kotlin
class UserService(
    private val io: CoroutineDispatcher = Dispatchers.IO,
) {
    suspend fun fetch(id: Int): User = withContext(io) {
        // expensive/blocking call
        api.get(id)
    }
}

// test
@Test fun fetchReturnsUser() = runTest {
    val service = UserService(StandardTestDispatcher(testScheduler))
    assertEquals(User(1), service.fetch(1))
}

go deeper

for a junior

States the basic reason: tests need a controllable dispatcher and hardcoding prevents that.

for a middle

Explains the constructor-default pattern and that the test dispatcher shares testScheduler to make delay virtual.

for a senior

Frames it as DI of the CoroutineDispatcher abstraction, knows withContext vs launch, and how scheduler sharing enables determinism.

for a principal

Discusses a DispatcherProvider abstraction across a codebase, default-parameter hygiene, and trade-offs vs injecting a full CoroutineScope.

## The problem with hardcoded dispatchers A `CoroutineDispatcher` decides *which thread(s)* a coroutine runs on. `Dispatchers.IO` and `Dispatchers.Default` are real, shared, multi-threaded pools. If a class writes `launch(Dispatchers.IO) { ... }` directly, every test of that class is forced onto those real pools. That causes three problems: - **Slowness/flakiness:** real `delay(1000)` actually waits one second; thread scheduling is non-deterministic. - **No time control:** you cannot fast-forward virtual time because the work isn't on the test's scheduler. - **No determinism:** assertions race against background threads. ## The fix: inject the dispatcher Expose the dispatcher as a dependency so the test can replace it: ```kotlin class Repository( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { suspend fun load(): Data = withContext(dispatcher) { /* blocking work */ } } ``` Production uses the default (`Dispatchers.IO`). A test passes a test dispatcher: ```kotlin @Test fun loads() = runTest { val repo = Repository(StandardTestDispatcher(testScheduler)) val result = repo.load() assertEquals(expected, result) } ``` ## Why test dispatchers are special `StandardTestDispatcher` and `UnconfinedTestDispatcher` are backed by a `TestCoroutineScheduler`. Inside `runTest`, `testScheduler` is that scheduler. When the injected dispatcher shares it, `delay()` becomes *virtual* — the scheduler skips it instead of really sleeping — and the test can call `advanceUntilIdle()`/`advanceTimeBy()`. ## Key APIs/terms - **`CoroutineDispatcher`** — the abstraction you depend on (program to interfaces). - **`withContext(dispatcher)`** — switches the dispatcher for a block. - **`testScheduler`** — the `TestCoroutineScheduler` exposed by `runTest`. - **Constructor default parameter** — `= Dispatchers.IO` keeps production call sites clean. This is plain dependency injection: depend on the `CoroutineDispatcher` abstraction, let the caller (production or test) supply the concrete one.

  • What's a clean way to inject multiple dispatchers (IO, Default, Main) at once?
    Define a DispatcherProvider interface holding each dispatcher and inject that single object; production uses real dispatchers, tests use scheduler-backed ones.
  • Should the default parameter point at the test dispatcher?
    No — the default must be the production dispatcher; only tests should ever pass a test dispatcher, otherwise production behavior changes.

Like passing a Clock into code instead of calling System.now() — tests can hand you a fake clock you control.

saying these in an interview costs you the question

  • Says hardcoding is fine because 'you can use runBlocking in tests'
  • Thinks injecting a dispatcher changes production performance
  • Defaults the constructor parameter to a TestDispatcher
  • Confuses CoroutineDispatcher with CoroutineScope
  • Believes Dispatchers.IO can be advanced/skipped in tests

context

open as a page

A ViewModel calls viewModelScope.launch { ... } which uses Dispatchers.Main. How do you make this testable with Dispatchers.setMain / Dispatchers.resetMain, and why is each step needed?

level: middleimportance: must knowfreq 75%

basics

~10 s

In a unit test there's no Android main thread, so Dispatchers.Main throws. Before the test, call Dispatchers.setMain(a test dispatcher); after, call Dispatchers.resetMain() to clean up so other tests aren't affected.

open as a page

Design a DispatcherProvider abstraction for injecting IO/Default/Main across a codebase, and explain how production and test wiring differ.

level: middleimportance: should knowfreq 55%

basics

~10 s

Make a small interface that exposes the dispatchers your classes need (io, default, main). Production supplies the real Dispatchers; tests supply a single test dispatcher (backed by the test scheduler) for all of them.

open as a page

A teammate's ViewModel test calls Dispatchers.setMain(StandardTestDispatcher()) and runs the test body in runTest {}, but advanceUntilIdle() never seems to complete the ViewModel's work. Diagnose the likely bug and fix it.

level: seniorimportance: should knowfreq 45%

basics

~20 s

The dispatcher passed to setMain uses a different scheduler than runTest. They must share one scheduler. Create the test dispatcher from runTest's testScheduler (or start runTest from the dispatcher's scheduler) so advanceUntilIdle drives the ViewModel.

open as a page

When injecting a test dispatcher (e.g., into setMain or a provider), what changes if you choose StandardTestDispatcher vs UnconfinedTestDispatcher, and how does that interact with asserting ViewModel state right after launch?

level: seniorimportance: should knowfreq 50%

basics

~10 s

StandardTestDispatcher queues new coroutines and doesn't run them until you advance time, so you must call advanceUntilIdle before asserting. UnconfinedTestDispatcher runs them eagerly up to the first suspension, so state is often already set.

open as a page