skip to content

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