skip to content

What is shrinking in Kotest property tests, why does it matter, and how does it interact with custom Arb generators?

level: seniorimportance: should knowfreq 40%

answer

  1. Shrink = simplify failing input to minimal counterexample
  2. Sample carries an RTree of lazy shrink candidates
  3. Ints→0, lists→empty, strings→shorter/blank
  4. map preserves shrinking; arbitrary{} needs a Shrinker
  5. ShrinkingMode.Off / Bounded(n) / Unbounded

basics

~20 s

When a property fails, Kotest tries simpler versions of the failing input until it finds the smallest one that still breaks the test. That minimal counterexample makes debugging far easier than a huge random value.

solid answer

~40 s

Shrinking is the process where, after a property fails on some random input, Kotest searches for the *simplest* input that still reproduces the failure and reports that minimal counterexample. Built-in Arbs ship with shrinkers (ints shrink toward 0, lists toward empty, strings toward shorter/blank). This turns an unhelpful failing value like `849213` into `1`, exposing the real boundary. Shrinking is driven by the `Arb`'s `RTree`/`Sample` structure: each generated value carries lazily-computed shrink candidates. When you build Arbs with `map`, shrinking is preserved; with the `arbitrary { }` builder you may lose good shrinking unless you provide a shrinker (e.g. `arbitrary(shrinker)`). `filter` can prune shrink candidates. You can disable it with `PropTestConfig(shrinkingMode = ShrinkingMode.Off)` or bound it (`Bounded(n)`).

code

kotlin · 18 lines
kotlin
import io.kotest.property.*
import io.kotest.property.arbitrary.*

data class Port(val value: Int)

val portShrinker = Shrinker<Port> { p ->
    if (p.value <= 0) emptyList() else listOf(Port(p.value / 2), Port(0))
}

val portArb = arbitrary(shrinker = portShrinker) { rs ->
    Port(rs.random.nextInt(0, 65_535))
}

suspend fun example() {
    checkAll(PropTestConfig(shrinkingMode = ShrinkingMode.Bounded(100)), portArb) { p ->
        // property over p; on failure it shrinks toward Port(0)
    }
}

go deeper

for a junior

Can state that shrinking finds a smaller failing input to ease debugging.

for a middle

Knows built-in shrink directions (ints→0, lists→empty) and that map preserves shrinking.

for a senior

Writes a Shrinker<T> for a custom Arb, controls ShrinkingMode, and uses seeds to reproduce failures.

for a principal

Reasons about shrink-tree cost vs. diagnostic value, designs domain generators with good shrinkers, and standardizes reproducibility (seed logging) across CI.

## The problem shrinking solves Random generation finds *a* failing input, but it's often huge and noisy — e.g. a list of 200 random ints. You don't know *which part* matters. **Shrinking** automatically simplifies the failing input toward the smallest value that *still fails*, producing a minimal, readable **counterexample**. ## How Kotest models it Each `Arb` produces a `Sample<T>`, which carries: - the generated **value**, and - an **`RTree<T>`** of lazily computed **shrink candidates** (smaller/simpler variants). When the property fails on value `v`, Kotest walks the shrink tree: it tries simpler candidates, and whenever one *also fails*, it recurses into that branch, repeating until no simpler candidate fails. The last failing value is the reported minimal counterexample. Built-in shrink directions: - **Ints/Longs** shrink toward `0`. - **Lists** shrink toward **empty** (and drop/halve elements). - **Strings** shrink toward **shorter** and **blank**. ## Why it matters ```kotlin checkAll(Arb.list(Arb.int())) { xs -> xs.sum() shouldBe xs.fold(0) { a, b -> a + b } } ``` If an overflow bug exists, without shrinking you might see a 150-element list; with shrinking Kotest reports the minimal `[Int.MAX_VALUE, 1]` or similar — instantly diagnostic. ## Interaction with custom generators - **`map`** preserves shrinking: Kotest maps the shrink tree too, so `Arb.int().map { it * 2 }` still shrinks toward `0`. - **`filter`** keeps shrinking but discards candidates that fail the predicate, which can weaken it and slow things down. - **The `arbitrary { rs -> … }` builder** with only an edge-case list and a sample function does NOT automatically know how to shrink your custom value. To get good shrinking, supply a **`Shrinker<T>`**: ```kotlin import io.kotest.property.arbitrary.arbitrary import io.kotest.property.Shrinker data class Money(val cents: Int) val moneyShrinker = Shrinker<Money> { m -> if (m.cents == 0) emptyList() else listOf(Money(m.cents / 2), Money(0)) } val moneyArb = arbitrary(shrinker = moneyShrinker) { rs -> Money(rs.random.nextInt(0, 100_000)) } ``` A `Shrinker<T>` is `(T) -> List<T>`: given a failing value, return simpler candidates. Returning an empty list stops shrinking. ## Controlling shrinking `PropTestConfig` exposes `shrinkingMode`: - `ShrinkingMode.Off` — report the raw failing value (fast, no minimization). - `ShrinkingMode.Bounded(n)` — cap shrink steps at `n`. - `ShrinkingMode.Unbounded` — shrink until no simpler failing value (default-ish, can be slower). ```kotlin checkAll(PropTestConfig(shrinkingMode = ShrinkingMode.Bounded(50)), Arb.int()) { ... } ``` ## Reproducibility Failures print the **seed**; rerun with that seed (`PropTestConfig(seed = …)`) to reproduce the exact sequence, then the shrinker deterministically reduces it. Keep generators pure of external randomness so seeds stay meaningful.

  • Why can a custom arbitrary{} generator report an ugly counterexample?
    Without a supplied Shrinker, Kotest has no shrink tree for the custom type and can only report the raw failing value; add a Shrinker<T> to minimize it.
  • How do you reproduce a failing property run exactly?
    Kotest prints the seed; pass it back via PropTestConfig(seed = …) so the same generation sequence (and thus failure) recurs deterministically.

Like a doctor narrowing a vague complaint to the single symptom that reproduces the disease.

saying these in an interview costs you the question

  • Thinks shrinking changes which inputs are generated rather than minimizing a failure
  • Believes custom arbitrary{} generators shrink automatically with no Shrinker
  • Cannot name a way to disable or bound shrinking
  • Confuses the seed (reproducibility) with the shrinker (minimization)

context