skip to content

You need a test to prove that your service abandons a slow dependency under withTimeout. How do you make a MockK-stubbed suspend function actually suspend, and what must you avoid?

level: seniorimportance: should knowfreq 35%

answer

  1. coAnswers + delay = cancellable fake latency
  2. runs inline in the caller's coroutine
  3. Thread.sleep = blocked thread, uncancellable
  4. coAndThen for slow-then-fast retry test
  5. cancelled call is still recorded

basics

~20 s

Stub it with a suspending answer: coEvery { client.fetch() } coAnswers { delay(10_000); Response }. The delay runs in the caller's coroutine, so it is a real cancellable suspension point. Never fake latency with Thread.sleep in a plain answers block.

solid answer

~50 s

Give the stub a **suspending** answer rather than an immediate value: ```kotlin coEvery { client.fetch() } coAnswers { delay(10_000); Response.OK } ``` MockK invokes that body inline in the calling coroutine, so `delay` is a genuine suspension point owned by the caller's job. A `withTimeout` in the code under test therefore cancels the stub exactly as it would cancel a real slow client, and the test can assert the timeout path. What to avoid: `answers { Thread.sleep(10_000); … }`. That blocks the carrier thread, is not a cancellation point at all, and burns real wall-clock time — the timeout may not fire the way production would, and the suite gets slow. Also remember MockK launches nothing of its own: the stub's suspension belongs to whatever coroutine calls it, so if the test drives the caller on a scheduler with virtual time, the delay is observed by that scheduler rather than by the wall clock.

code

kotlin · 10 lines
kotlin
coEvery { client.fetch() } coAnswers {
    delay(10_000)          // cancellable suspension in the CALLER's coroutine
    Response.OK
}

// code under test: withTimeout(100) { client.fetch() } -> falls back
val result = runBlocking { service.loadWithFallback() }

assertEquals(Response.FALLBACK, result)
coVerify { client.fetch() }   // the cancelled call was still recorded

go deeper

for a junior

Know the shape: coAnswers with a delay makes the stub suspend instead of returning immediately.

for a middle

Explain why delay works and Thread.sleep does not — one is a cancellation point in the caller's coroutine, the other blocks a thread.

for a senior

Reason about the whole test: cancellation propagates from the caller's job, the cancelled call is still recorded, and assertions should target the outcome rather than elapsed time.

for a principal

Set policy — simulated latency only in tests specifically about timeout, cancellation or interleaving; never wall-clock assertions; never blocking sleeps anywhere in the suite.

## The scenario Production code wraps a call to a dependency in a timeout and is supposed to fall back, cancel or report when the dependency is too slow. Testing that path requires a dependency that is slow **on demand**. A stub that returns immediately can never exercise it, which is why every coroutine-heavy interview eventually asks how you fake latency. ## The mechanism MockK's answer for a stub can be a suspending lambda: the `coAnswers { }` terminator. Its body may contain suspend calls, and MockK runs it inline as part of the stubbed invocation — it does not launch a coroutine, create a scope, or move the work to another dispatcher. Consequently the body's suspension points belong to the calling coroutine's continuation and its job. That has three properties that make it a faithful stand-in for a slow dependency: 1. **Cancellable.** `delay` inside the body is a cooperative cancellation point. When `withTimeout` around the call expires, the caller's job is cancelled and the suspended stub is cancelled with it, so the call site sees the same `TimeoutCancellationException` it would see from a real client. 2. **Non-blocking.** No thread is held while the stub "waits", so other coroutines in the test can interleave — which is what you want when the test also asserts that a fallback path or a concurrent request proceeds. 3. **Scheduler-honest.** Whatever scheduler drives the caller is the one that sees the suspension, so if the test harness supplies virtual time the fake latency costs no wall-clock time. ## The anti-pattern The wrong version is to keep the immediate answer and sleep the thread: ``` coEvery { client.fetch() } answers { Thread.sleep(10_000); Response.OK } ``` This is broken in three separate ways. `Thread.sleep` is not a suspension point, so cancellation cannot interrupt it cooperatively — the code under test is stuck until the sleep finishes regardless of its timeout. It occupies a real thread, which on a small dispatcher can starve the very coroutine that was supposed to enforce the timeout, producing results that depend on pool size. And it always costs real seconds, so a suite full of such tests becomes unusable. The plain `answers` block is not the problem in itself — it is the correct terminator for a pure computation — but it cannot suspend, so any attempt to model waiting inside it degenerates into blocking. ## Variations you may need - **Slow then fast.** Chain answers so the retry path can be tested: `coEvery { client.fetch() } coAnswers { delay(10_000); Response.OK } coAndThen { Response.OK }`. The first invocation is slow, the second returns at once. - **Suspend forever.** Where the point is "the caller must give up", an effectively unbounded `delay` is clearer than a large magic number; the caller's timeout is what ends it. - **Yield instead of delay.** If the goal is only to let another coroutine interleave rather than to model duration, suspending briefly is enough — the mechanism is the same, the intent is different. - **Argument-dependent latency.** The answer body can read the call's arguments, so one stub can be slow for a particular input and instant for the rest. ## Verifying afterwards A cancelled call is still a *recorded* call — the invocation happened, it simply did not complete normally. So `coVerify { client.fetch() }` still passes after a timeout, and that is usually the assertion you want alongside asserting the exception or fallback value from the code under test. ## Judgment: how far to take it Simulated latency is a sharp tool. Two cautions worth voicing: - **Do not make it the default.** Most tests should stub instantly; only tests specifically about timeout, cancellation or interleaving should suspend, otherwise the suite pays for time it does not need to spend. - **Do not assert on durations.** Asserting "the call took at least 100 ms" reintroduces wall-clock flakiness. Assert on the *observable outcome*: the exception thrown, the fallback returned, the compensating call made. ## The sentence to lead with "Use `coAnswers` with a `delay`, because MockK runs the answer in the caller's coroutine, which makes the fake latency a real cancellable suspension point — and never fake it with `Thread.sleep`, which blocks a thread and cannot be cancelled."

  • After the timeout cancels the call, will coVerify still see it?
    Yes. MockK records the invocation when the stubbed member is entered, so `coVerify { client.fetch() }` passes even though the call was cancelled before its answer completed. That lets a test assert both that the dependency was attempted and that the caller took the timeout path, which is usually the pair of facts you care about.
  • Why is asserting on elapsed time in such a test a bad idea?
    Because it makes the assertion depend on wall-clock scheduling, machine load and dispatcher behaviour, which is exactly the flakiness the fake was supposed to remove. Assert on the observable outcome instead — the exception type, the fallback value, or the compensating interaction — all of which are deterministic regardless of how the suspension is scheduled.

saying these in an interview costs you the question

  • Using Thread.sleep inside an answers block to simulate a slow dependency.
  • Believing MockK runs the answer on its own dispatcher so cancellation cannot reach it.
  • Assuming a cancelled call is not recorded and therefore cannot be verified.
  • Asserting on measured elapsed time instead of on the outcome of the timeout path.
  • Making every stub slow "to be realistic", turning the suite into a wall-clock test.

context