How do you attach per-test configuration — a timeout, repeated invocations, or disabling a single case — to one individual test in Kotest's flat spec styles, and how does the syntax differ between StringSpec and FunSpec?
answer
- Same .config builder, different receiver
- StringSpec: "name".config(...) { }
- FunSpec: test("name").config(...) { }
- AnnotationSpec: no .config — only @Ignore / expected
- enabled, enabledIf, timeout (Duration in 5.x), invocations, tags
basics
~20 sBoth use a .config(...) builder, but it hangs off different things: in StringSpec it chains off the name string — "name".config(timeout = 2.seconds) { } — and in FunSpec off the registration call — test("name").config(invocations = 5) { }. AnnotationSpec has no equivalent.
solid answer
~50 sKotest attaches per-test settings through a **`.config(...)`** call placed between the test's name and its body; the difference between the flat styles is only **what `config` is called on**. - **StringSpec** registers via a scoped `String.invoke`, so config chains off the *string*: `"reads the cache".config(enabled = false) { ... }`. - **FunSpec** (and ShouldSpec, with `should(...)`) registers via a function call, so config chains off *that call*: `test("reads the cache").config(timeout = 2.seconds) { ... }`. - **AnnotationSpec** has no `.config` at all — a method has nowhere to hang it; you get only `@Ignore` and `@Test(expected = ...)`. Common parameters include `enabled` / `enabledIf`, `timeout` (a `kotlin.time.Duration` in Kotest 5), `invocations`, and `tags`. Anything you do not set falls back to the spec's defaults and then to project-level defaults, so `.config` is the narrowest scope in a three-tier override chain — reach for it for a genuine per-test exception, not as the place to express a policy that belongs one level up.
code
kotlin · 13 linesclass CacheTest : StringSpec({
"finishes inside the SLA".config(timeout = 2.seconds) {
cache.warm() shouldBe true
}
"is skipped until the parser lands".config(enabled = false) { }
})
class CacheFunTest : FunSpec({
test("finishes inside the SLA").config(timeout = 2.seconds) {
cache.warm() shouldBe true
}
test("is exercised repeatedly").config(invocations = 10) { }
})go deeper
Know that .config(...) sits between the name and the body, and name a couple of parameters such as enabled and timeout.
Get the receiver difference right across styles and explain that unset parameters fall back to spec then project defaults.
Bring judgement: hoist repeated config to spec level, treat enabled = false as debt, and scope timeouts as tightly as possible so the global safety net stays meaningful.
Frame it as a policy layering question — what belongs in project config versus a spec versus one test — and how that keeps the suite's guarantees legible as it grows.
## The shape of per-test configuration Kotest lets you configure a test at three scopes: **project-wide** defaults, **per-spec** overrides, and **per-test** settings. This question is about the narrowest one. In the DSL styles the mechanism is a `config(...)` call inserted between naming a test and supplying its body, and the only thing that varies by style is the receiver it is called on. ### StringSpec StringSpec registers a test by invoking a scoped extension on the name string, so there is no function call to chain from — `config` is an extension on the **String** itself: ```kotlin class CacheTest : StringSpec({ "is skipped until the parser lands".config(enabled = false) { } "finishes inside the SLA".config(timeout = 2.seconds) { } }) ``` Read it as: take the name, decorate it with settings, then apply the body. ### FunSpec (and ShouldSpec) Here the registration is already a function call, and `config` chains from its result: ```kotlin class CacheTest : FunSpec({ test("is skipped until the parser lands").config(enabled = false) { } test("is flaky under load").config(invocations = 10) { } }) ``` ShouldSpec follows the same pattern with `should("...").config(...) { }`. The mental model is identical; only the token before `.config` changes. Candidates who have used one style and guess at the other usually guess wrong, which is why the question discriminates. ### AnnotationSpec AnnotationSpec discovers tests reflectively from annotated methods, and a method has no expression to chain onto. There is no `.config`. Your per-test controls are the annotation surface — `@Ignore` to skip, `@Test(expected = ...)` for an expected exception — and everything else has to be set at spec or project scope. This is one of the concrete costs of choosing that style. ## What the parameters mean The frequently used ones: - **`enabled: Boolean`** — statically switch a test off. The test is reported as skipped/ignored rather than silently absent. - **`enabledIf`** — the same decision expressed as a predicate evaluated at run time, so the condition can consult the environment instead of being baked in as a literal. - **`timeout`** — the wall-clock budget for the test body. In **Kotest 5** this is a `kotlin.time.Duration` (`2.seconds`), where earlier 4.x versions took a `Long` of milliseconds; a timed-out test fails rather than hanging the run. - **`invocations`** — run the same test body more than once. Useful for smoking out flakiness or exercising randomised input, and worth understanding as *repetition of one test*, not as parallelism. - **`tags`** — attach Kotest tag objects to this single test so it can be included or excluded by a tag expression at run time. Unspecified parameters are simply not overridden: the value resolves from the spec's defaults and then the project's defaults. That layering is the design point — `.config` is a scalpel. ## Judgement: when per-test config is the wrong tool Three recurring smells: 1. **The same `.config` repeated on every test in a file.** That is a spec-level default expressed N times; hoist it, so the file has one statement of intent and the diff for changing it is one line. 2. **`enabled = false` used as a to-do.** A hard-disabled test is invisible in green runs and rots. Either delete it, or gate it on a condition that is *true somewhere* — a predicate or a tag that a specific pipeline job includes — so the coverage is actually exercised by someone. 3. **`invocations` used to paper over flakiness.** Repeating a test that is nondeterministic makes it *more* likely to fail, not less; repeating one you have made deterministic is a way to *prove* it. If the intent is "tolerate an occasional failure", repetition is not that tool, and the underlying nondeterminism should be attacked directly. ## Timeouts specifically A per-test timeout is the right scope for one genuinely slow test in an otherwise fast spec — pushing the whole project's default up to accommodate it removes the safety net everywhere else. Conversely, when an entire spec talks to a slow dependency, set the budget once on the spec. Aim to state the *tightest* budget that is comfortably above the observed duration; a timeout that no realistic regression could ever trip is documentation, not protection. ## Summary answer to give "Same builder, different receiver: `"name".config(...) { }` in StringSpec because registration is a string invoke, `test("name").config(...) { }` in FunSpec because registration is a function call, and nothing in AnnotationSpec because a method can't be chained. Parameters like `enabled`, `enabledIf`, `timeout`, `invocations` and `tags` override spec and project defaults, and repeated identical `.config` calls across a file are a sign the setting belongs at spec level."
- Every test in a spec carries the same `.config(timeout = 30.seconds)`. What would you change?Hoist it to the spec level so the intent is stated once and can be changed in one line, leaving `.config` for the genuine exception. Repeated identical per-test config is duplication that drifts — someone adds a test and forgets the setting, and the file no longer means what it appears to mean.
- Is `invocations = 20` a reasonable way to deal with a flaky test?No — repeating a nondeterministic test increases the chance the run fails, since any single failing invocation fails the test. Repetition is useful to *expose* flakiness or to exercise randomised input, not to tolerate it. The fix is to remove the nondeterminism, typically real time, real concurrency or shared state between tests.
- Why prefer a per-test timeout over raising the project-wide default?Because the project default is the safety net for every other test; raising it to accommodate one slow case removes protection everywhere and lets future regressions hide. Set the tightest budget you can at the narrowest scope that needs it — per test for one outlier, per spec when the whole spec shares a slow dependency.
saying these in an interview costs you the question
- Assuming `test("x").config(...)` syntax also works in StringSpec, or `"x".config(...)` in FunSpec
- Expecting `.config` to exist on AnnotationSpec methods
- Describing `invocations` as running the test concurrently or as tolerating intermittent failures
- Using `enabled = false` as a permanent to-do marker instead of deleting or conditionally gating the test
- Raising the project-wide timeout default to accommodate one slow test