skip to content

Kotest's shrinking phase re-executes your property body many times with candidate inputs. What does that imply for how you write property tests, and when would you switch shrinking off?

level: middleimportance: should knowfreq 25%

answer

  1. shrinking = re-run the body per candidate
  2. stateful body → shrink phase pollutes itself
  3. non-deterministic body → shrinker chases noise
  4. cost multiplies after a failure
  5. ShrinkingMode.Off / Bounded(n) as the escape hatch

basics

~20 s

Because each shrink candidate is a real run of your test lambda, property bodies must be repeatable and side-effect-free — no accumulating inserts, no consumed queues, no leaked state. Set Kotest's ShrinkingMode.Off when the body is expensive or not safely repeatable.

solid answer

~50 s

Shrinking works by asking "does this smaller input still fail?", and the only way to answer is to run your property body again. So after a failure the body can execute many more times, with inputs you never anticipated. Consequences: - **The body must be repeatable.** If it inserts rows, publishes messages, consumes a queue or mutates a shared fixture, the extra executions can turn a clean failure into a cascade of unrelated errors — or make a candidate "fail" for reasons that have nothing to do with the input. - **Cost multiplies.** A property body doing real I/O turns a 200 ms failure into a long shrink phase. - **Assertions must be deterministic** in the input, otherwise the descent chases noise. The cure is usually to keep property bodies pure and fast; property testing belongs on logic, not on stateful integration flows. Where you cannot, bound the search with `ShrinkingMode.Bounded(n)` or disable it with `ShrinkingMode.Off` in `PropTestConfig` and accept the raw failing value.

code

kotlin · 4 lines
kotlin
// Expensive or not-safely-repeatable body: skip the shrink phase
checkAll(PropTestConfig(shrinkingMode = ShrinkingMode.Off), requestArb) { request ->
   client.send(request).status shouldBe 200
}

go deeper

for a junior

Know that after a failure Kotest runs your test body again with smaller inputs while it searches for a minimal counterexample.

for a middle

Draw the consequences — bodies must be repeatable, deterministic and fast — and name ShrinkingMode.Off/Bounded as the control.

for a senior

Diagnose the symptoms: counterexamples that do not reproduce standalone, secondary exceptions during the shrink phase, post-failure hangs; and push properties toward pure logic.

for a principal

Set the boundary of where property testing is applied at all — pure logic gets properties with full shrinking, stateful flows get example-based tests — so the re-execution cost never becomes a CI problem.

## The mechanism behind the constraint When a Kotest property fails, the framework looks for a smaller input that fails the same way. It has no way to reason about your assertions statically, so it evaluates: take a candidate smaller value, run the property body with it, see whether it fails. Each step of the descent is a full execution of your lambda. A failure can therefore be followed by dozens or hundreds of extra executions with inputs you did not choose and did not expect. That single fact drives a set of practical rules. ## Rule 1 — property bodies must be repeatable Suppose the property inserts a row keyed by the generated id: ```kotlin checkAll(Arb.string(1..10)) { name -> repo.insert(User(name)) // side effect repo.findByName(name) shouldBe User(name) } ``` This is already fragile across iterations, and shrinking makes it worse: the shrink phase re-inserts shrunken names into a store that already holds rows from the failed run. A candidate can now fail from a unique-constraint violation rather than from the bug, which sends the descent down a false path and produces a counterexample that does not reproduce in isolation. The fix is structural, not configurational: property tests are at their best on pure functions — parsers, encoders, comparators, calculations, state transitions expressed as values. If you must touch a store, make the body self-contained (create and clean up inside the body, or key everything by the generated input so runs cannot collide) and be aware you are paying for it on every shrink step. ## Rule 2 — the assertion must depend only on the input If the body's outcome depends on the clock, on a shared counter, or on order relative to other tests, the shrinker is searching over a moving target. It may report a "minimal" input that does not fail on its own, which is one of the most confusing outcomes in property testing. Deterministic bodies keep the descent honest. ## Rule 3 — cost multiplies, so keep the body fast A 5 ms body makes shrinking invisible. A 500 ms body with network I/O turns a shrink into a coffee break, and in CI it can push a job past its timeout — with the added insult that the job then reports a timeout instead of the actual property failure. If a slow body is unavoidable, bound the search. ## Controlling the search `PropTestConfig` carries `ShrinkingMode`: ```kotlin checkAll(PropTestConfig(shrinkingMode = ShrinkingMode.Off), userArb) { user -> ... } ``` - **`ShrinkingMode.Off`** — no shrink phase; the original failing input is reported. Choose it when the body is expensive, when repeated execution is unsafe, or when you are triaging and only need to know that a failure exists. - **`ShrinkingMode.Bounded(n)`** — cap the number of steps, trading counterexample quality for time. The pragmatic middle ground for slower bodies. - **`ShrinkingMode.Unbounded`** — let the descent run out; worth it for cheap bodies over complex structures where the smallest counterexample really pays. Set it per property via `PropTestConfig` where the cost lives, rather than globally — most of your properties are cheap and benefit from full shrinking. ## Diagnosing shrink-phase weirdness Symptoms that point at re-execution rather than at your logic: - The reported counterexample passes when you run it by hand → the body is stateful or non-deterministic, or a previous execution polluted the environment. - The failure message from the shrink phase names a different exception than the original failure → later executions are hitting a secondary failure (constraint violation, exhausted resource) rather than the bug. - The test hangs after a failure → the shrink search is running an expensive body many times; bound or disable it. ## Summary for an interview Say the mechanism in one sentence (shrinking must run the body to know whether a candidate still fails), draw the three consequences (repeatability, determinism, cost), and name `ShrinkingMode.Off`/`Bounded` as the escape hatch — while making clear that the preferred fix is a pure, fast property body.

  • A property fails, and the counterexample Kotest reports passes when you run it as a standalone test. What is the most likely cause?
    The property body is not self-contained: state from earlier iterations or from earlier shrink steps changed the outcome, or the body reads something non-deterministic. Because each shrink candidate re-runs the body against that polluted state, the descent can converge on an input that only fails in sequence. Make the body pure or fully self-cleaning, then re-run with the reported seed.
  • Is disabling shrinking a reasonable permanent setting for a slow integration-style property?
    It is a defensible trade — you keep the failure signal and lose the minimisation, which is often the cheaper half. But the better question is whether a property test belongs there at all: property testing pays off most on pure logic, and a slow stateful flow is usually better served by a small number of example-based integration tests plus properties over the pure parts.

saying these in an interview costs you the question

  • Assuming the property body runs exactly once per generated input, failures included
  • Writing property bodies that insert, publish or consume without cleanup
  • Blaming the framework when the shrunk counterexample does not reproduce standalone
  • Turning shrinking off globally instead of per-property where the cost actually is
  • Treating a post-failure hang as a deadlock rather than an expensive shrink search

context