Inside a Kotest spec, what control do you have over which thread or dispatcher your tests run on, and what does Kotest's `blockingTest` option exist for?
answer
- dispatcherAffinity on by default = same thread per spec
- affinity ≠ parallelism setting
- blockingTest = dedicated thread for blocking bodies
- withContext for a single call, no config needed
- coroutineDebugProbes for hung suspending tests
basics
~20 sKotest confines a spec's tests to a single thread by default via its dispatcherAffinity setting; turning it off lets tests move between threads. blockingTest runs a test on a dedicated thread so code that blocks does not tie up the shared dispatcher. Within a body you can still switch context yourself.
solid answer
~60 sThree levers, at different layers. **`dispatcherAffinity`** — a Kotest setting (project-level, and overridable per spec) that keeps all tests in a spec on the same thread. It defaults to on, which is what makes thread-affine state — thread locals, some UI or driver handles, certain client libraries — behave predictably. Turning it off allows Kotest to dispatch tests across threads. **`blockingTest`** — a per-test (and per-spec) option for tests whose body genuinely blocks the thread. Kotest runs such a test on a dedicated thread so that the blocking does not occupy a shared dispatcher thread and interfere with timeout handling or other tests. Use it for legacy blocking clients, JDBC-heavy code, or anything doing `Thread.sleep`. **Plain `withContext`** — inside the body you are in ordinary Kotlin, so you can move a section onto a specific dispatcher yourself when the code under test demands it. A related diagnostic option, `coroutineDebugProbes`, installs coroutine debug probes so failures carry coroutine stack information — useful when a suspended test hangs and you cannot tell where.
code
kotlin · 13 linesclass LegacyClientTest : FunSpec({
// The whole body blocks: give it its own thread.
test("legacy socket client").config(blockingTest = true) {
legacyClient.fetchBlocking() shouldNotBe null
}
// Only one call needs IO dispatch: plain Kotlin, no framework config.
test("jdbc read") {
val rows = withContext(Dispatchers.IO) { jdbcClient.query(sql) }
rows shouldHaveSize 3
}
})go deeper
Know that a Kotest test runs in a coroutine and that you can use withContext inside a test body like anywhere else in Kotlin.
Name dispatcherAffinity as the same-thread-per-spec default and blockingTest as the dedicated-thread option for blocking bodies.
Separate framework-level placement from in-body dispatch, explain the thread-local failure mode when affinity is removed, and note that neither option makes blocking code cancellable. Mention coroutineDebugProbes as a diagnosis switch.
Treat these as suite-level defaults with consequences: affinity on unless a spec deliberately tests concurrency, blockingTest reserved for known blocking boundaries, and debug probes as a temporary investigation tool rather than a standing setting.
## Why thread control matters in a coroutine-based runner Kotest runs tests as coroutines, which means the thread a test executes on is a scheduling detail rather than a guarantee — unless the framework makes it one. Plenty of test code cares about the thread: - Libraries that stash state in a `ThreadLocal` (transaction contexts, security contexts, some MDC-based logging setups). - Clients or handles that are documented as single-threaded. - Code that blocks a thread for a long time and, in doing so, occupies a worker that other work needs. Kotest gives you a few distinct controls for these cases, and they solve different problems. ## `dispatcherAffinity` — one thread per spec Kotest's `dispatcherAffinity` setting governs whether the tests of a spec are confined to the same thread. It defaults to enabled, which is a deliberately conservative choice: with affinity on, thread-affine state set up in `beforeSpec` is still visible in each test, and libraries that quietly assume single-threaded use keep working. Disabling it lets Kotest spread a spec's tests across threads. That is only interesting when you are deliberately exercising concurrency or trying to squeeze throughput out of a suite, and it should be a considered decision — a suite that silently depends on affinity will fail in obscure, intermittent ways once it is removed. It is worth being precise about what this does *not* do: affinity is about which thread runs the tests, not about how many tests run at once. Kotest's parallelism settings are a separate concern with their own configuration, and confusing the two leads to expectations that neither setting delivers. ## `blockingTest` — give the blocker its own thread Some test bodies do not cooperate with the coroutine model at all: a JDBC call, a legacy client that blocks on a socket, an explicit `Thread.sleep`. Running those on a shared dispatcher thread ties up a worker for the duration. Kotest's `blockingTest` option marks a test (or spec) as one that blocks, and Kotest gives it a dedicated thread rather than running it on the shared dispatcher. The practical benefits: the blocking does not starve other work, and the framework's handling of a stuck test is more predictable because the blocked thread is isolated rather than borrowed from a pool everything else shares. The cost is a thread per such test, so this is a targeted tool for the tests that need it, not a global default. And it is worth being honest about what it does not fix: a blocking call still cannot be cancelled cooperatively, so a test that blocks forever is still a test that hangs — the option limits the collateral damage, it does not make blocking code interruptible. ## `withContext` — the ordinary Kotlin lever Inside a test body you are writing normal Kotlin, so nothing stops you from moving a section of the test onto a particular dispatcher yourself: ```kotlin test("reads through the blocking client") { val rows = withContext(Dispatchers.IO) { jdbcClient.query(sql) } rows shouldHaveSize 3 } ``` This is often the cleanest answer when only part of a test needs different dispatch: it is local, explicit, and requires no framework configuration. It also mirrors what production code does, which keeps the test honest. ## `coroutineDebugProbes` — seeing where a test is stuck A separate but adjacent option, `coroutineDebugProbes`, installs the coroutines debug probes for the test so that failure output carries coroutine stack information. When a suspending test hangs or times out, the ordinary stack trace often shows only the framework's machinery; the probe dump shows which coroutines exist and where they are suspended. It costs performance, so it is a debugging switch — enable it on the spec you are investigating rather than globally. ## Choosing between them - **State is thread-affine and breaks intermittently** → check `dispatcherAffinity` before blaming the test. - **A test blocks a thread for a long time** → `blockingTest` for that test. - **One call inside a test needs a particular dispatcher** → `withContext`, no configuration required. - **A suspending test hangs and the stack trace is useless** → `coroutineDebugProbes` while diagnosing. ## The reasoning an interviewer is listening for The strong answer distinguishes *where a test runs* (affinity, blocking isolation — framework configuration) from *where a piece of code inside the test runs* (dispatcher choice — ordinary Kotlin), and does not reach for framework configuration when a local `withContext` would do. It also acknowledges that none of these options make blocking code cancellable; they only bound its impact.
- A spec passes locally but fails intermittently after someone disables Kotest's `dispatcherAffinity`. What is the likely cause?Thread-affine state. With affinity on, every test in the spec ran on the same thread, so anything stored in a `ThreadLocal` during setup — a transaction context, a security context, MDC values — was visible in each test. Without affinity, tests can run on different threads and that state is simply absent, which shows up as intermittent nulls or missing context rather than a clean failure.
- Does `blockingTest` make a blocking test cancellable when it exceeds its timeout?No. Coroutine cancellation is cooperative and needs a suspension point, which blocking code never reaches. `blockingTest` isolates the blocking work on its own thread so it does not occupy a shared dispatcher worker and disrupt other tests — it bounds the collateral damage rather than making the test interruptible.
saying these in an interview costs you the question
- Confusing `dispatcherAffinity` with a parallelism setting — it controls thread confinement, not how many tests run concurrently.
- Expecting `blockingTest` to make blocking code cancellable or to fix a hang.
- Reaching for framework configuration when a local `withContext` inside the test body would do.
- Enabling `coroutineDebugProbes` permanently for the whole suite despite its performance cost.
- Assuming tests always run on the same thread with no configuration involved.