skip to content

Kotest exposes a `coroutineTestScope` configuration flag. What does enabling it change about how a test runs, where can you set it, and how do you reach the scheduler it installs?

level: seniorimportance: should knowfreq 30%

answer

  1. coroutineTestScope = true → run body in a TestScope
  2. per test .config() > per spec property > AbstractProjectConfig
  3. testCoroutineScheduler only exists when the flag is on
  4. experimental in Kotest 5
  5. virtual time cannot skip real network waits

basics

~20 s

It makes Kotest run the test body inside a kotlinx-coroutines-test TestScope instead of an ordinary real-time coroutine, so delays are virtual. You can set it per test via .config(), per spec as a spec-level property, or globally in AbstractProjectConfig, and the installed scheduler is reachable from the test as testCoroutineScheduler.

solid answer

~50 s

`coroutineTestScope` is Kotest's integration point with kotlinx-coroutines-test. Normally Kotest runs a test in a plain coroutine on a real dispatcher, so `delay` genuinely waits. With the flag on, Kotest installs a `TestScope` for the test body, which brings the virtual-time behaviour of that library — delays are skipped rather than slept through. Three levels of granularity, narrowest wins: ```kotlin test("debounces").config(coroutineTestScope = true) { ... } // per test class MySpec : FunSpec({ coroutineTestScope = true // whole spec ... }) object ProjectConfig : AbstractProjectConfig() { override val coroutineTestScope = true // whole project } ``` Inside such a test, `testCoroutineScheduler` gives you the scheduler that scope is using, so you can drive time explicitly. It is only meaningful when the flag is enabled — asking for it in an ordinary test is an error, because no test scheduler exists. The flag is marked experimental in Kotest 5, so pin it deliberately rather than enabling it project-wide on a whim.

code

kotlin · 15 lines
kotlin
class DebouncerTest : FunSpec({

    test("fires only after the quiet window").config(coroutineTestScope = true) {
        val debouncer = Debouncer(window = 500.milliseconds)
        var fired = 0

        debouncer.submit { fired++ }

        testCoroutineScheduler.advanceTimeBy(400.milliseconds)
        fired shouldBe 0

        testCoroutineScheduler.advanceTimeBy(200.milliseconds)
        fired shouldBe 1
    }
})

go deeper

for a junior

Know that Kotest has an opt-in flag that gives a test virtual time instead of real delays, and that it is set in the test's config.

for a middle

Name the three places it can be set and the narrowest-wins precedence, and state that it installs a TestScope so delays are skipped.

for a senior

Discuss the coupling to testCoroutineScheduler, why project-wide enablement is risky for integration-flavoured tests, and that virtual time cannot skip waits owned by external systems.

for a principal

Set the policy: opt in per test or per spec for genuinely time-driven unit tests, keep real time for boundary tests, and account for the flag's experimental status when planning upgrades.

## The default, and what the flag changes By default a Kotest test body is a coroutine on a real dispatcher: time is wall-clock time and a `delay(10.seconds)` costs ten seconds of your build. That is correct for tests exercising real asynchrony, and painful for tests of timing logic — debounce windows, retry backoff, scheduled expiry — where the interesting behaviour is defined in terms of durations you do not want to actually spend. `coroutineTestScope = true` tells Kotest to run the test body inside a `TestScope` from kotlinx-coroutines-test rather than an ordinary coroutine. From that point on, the test inherits that library's virtual-time semantics: the scheduler owns the notion of "now", and delays are resolved against it instead of against the clock. The important framing for this topic is the *integration surface*: Kotest is not reimplementing virtual time. It is choosing which scope your test body runs in, so that the standard coroutines-test machinery applies. Everything about how virtual time itself behaves is the library's semantics, unchanged. ## Where you can set it The flag follows Kotest's usual configuration cascade, from narrowest to broadest: 1. **Per test case**, through the test's config block: `test("...").config(coroutineTestScope = true) { ... }`. Best default — the tests that need virtual time are usually a minority, and the opt-in documents itself at the call site. 2. **Per spec**, by setting `coroutineTestScope = true` in the spec body. Sensible when a whole spec is about timing logic. 3. **Project-wide**, by overriding `coroutineTestScope` in your `AbstractProjectConfig`. Sweeping: it changes the execution model for every test in the suite, including ones written on the assumption that delays are real. The narrower setting takes precedence over the broader one, which is the same precedence rule Kotest uses for its other configuration values. ## Reaching the scheduler When the flag is on, the test can access the scheduler backing its scope through `testCoroutineScheduler`. That is the handle you need when the test must advance time explicitly rather than merely have delays skipped — for example to assert that nothing has happened yet at the 400ms mark and that something has by 600ms. The constraint to remember: `testCoroutineScheduler` is only available in a test actually running with `coroutineTestScope` enabled. In an ordinary Kotest test there is no test scheduler to hand back, so requesting it fails. A team that enables the flag per-spec and then copies a test into another spec without the flag will meet exactly this failure, and the message is much clearer once you know the coupling. ## When to enable it — and when not to **Good fits:** unit-level tests of code whose contract is expressed in durations — a debouncer, a token that expires, a retry policy with backoff, a poller. These are the tests that otherwise either sleep for real or get "optimised" into asserting nothing meaningful. **Poor fits:** tests whose asynchrony crosses a boundary the scheduler does not control — a real HTTP server, a database, a message broker, work handed to a thread pool outside the test's scope. Virtual time cannot skip a real network round trip; enabling it there does not speed anything up and can make the test's model of time diverge from what the dependency is actually doing. This is why project-wide enablement deserves scrutiny. It is attractive ("all our tests get faster") and it silently changes the execution assumptions of integration-flavoured tests that were written against real time. Per-test or per-spec opt-in keeps the blast radius where the intent is. ## Interaction with Kotest's other facilities Be deliberate about combining virtual time with Kotest's polling assertions such as `eventually`. Those helpers exist for effects arriving from real, uncontrolled asynchrony and are built around waiting between attempts; a test that has taken control of time is usually one where you should be advancing the scheduler and asserting deterministically instead of polling. Mixing the two in one test is a sign the test is trying to do two different jobs. ## Experimental status In Kotest 5 this flag is annotated as experimental (`@ExperimentalKotest`), which in practice means you may need an opt-in and should expect the surface to move between versions. That is a reason to introduce it where it earns its keep, not to flip it on globally and forget about it.

  • Would you enable `coroutineTestScope` in `AbstractProjectConfig` for the whole suite?
    Usually not. It changes the execution model for every test, including integration-flavoured ones written on the assumption that time is real, and virtual time cannot skip waits owned by a database, broker or HTTP server anyway. Per-test or per-spec opt-in keeps the change where the timing logic actually lives and makes the intent visible at the call site.
  • What happens if you reference `testCoroutineScheduler` in a test that has not enabled the flag?
    There is no test scheduler for that test — it is running in an ordinary coroutine on a real dispatcher — so the access fails rather than silently giving you something inert. It is a common surprise when a test is copied out of a spec that set the flag into one that did not, so treat the flag and any scheduler usage as travelling together.

saying these in an interview costs you the question

  • Believing Kotest implements its own virtual-time engine rather than running the body in a TestScope from kotlinx-coroutines-test.
  • Expecting `testCoroutineScheduler` to work in a test that has not enabled `coroutineTestScope`.
  • Turning the flag on project-wide to "speed up the suite", including for tests that wait on real infrastructure.
  • Assuming virtual time can skip a real HTTP or database round trip.
  • Forgetting the flag is experimental in Kotest 5 and treating its surface as stable.

context