Why should a class that launches coroutines take a CoroutineDispatcher as a constructor parameter instead of hardcoding Dispatchers.IO or Dispatchers.Default?
answer
- Hardcoded IO/Default = real threads = slow/flaky
- Inject CoroutineDispatcher, default to production
- Test substitutes scheduler-backed test dispatcher
- Shared testScheduler makes delay() virtual
- DI principle applied to concurrency
basics
~10 sSo 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 sHardcoding 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 linesclass 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
States the basic reason: tests need a controllable dispatcher and hardcoding prevents that.
Explains the constructor-default pattern and that the test dispatcher shares testScheduler to make delay virtual.
Frames it as DI of the CoroutineDispatcher abstraction, knows withContext vs launch, and how scheduler sharing enables determinism.
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