skip to content

Property-Based Testing

Kotest's property module generates hundreds of randomized and edge-case inputs per property and shrinks failures to minimal counterexamples. Interviewers ask about it to see if you can state invariants, build generators for your own types, and reproduce a failing run deterministically.

on this pageshow

explore

questions

20

In Kotest's property testing, how do you build a generator for a data class out of generators for its individual fields, and what does Kotest's Arb.bind give you that constructing the object by hand inside the test body does not?

level: middleimportance: must knowfreq 45%

answer

  1. Arb.bind(a, b) { … } → Arb<T>
  2. positional lambda, not named params
  3. hand-rolled field = no shrink, no edge cases, no replay
  4. nest binds for aggregates
  5. dependent fields → flatMap / arbitrary { }

basics

~20 s

Pass one Arb per constructor parameter to Kotest's Arb.bind, with a lambda that assembles the object: Arb.bind(Arb.string(), Arb.int(1..120)) { n, a -> Person(n, a) }. The result is a real Arb, so its fields keep their edge cases and shrink independently.

solid answer

~50 s

`Arb.bind` in kotest-property takes N component generators plus a builder lambda and returns an `Arb<T>`: ```kotlin val personArb = Arb.bind(Arb.string(minSize = 1, maxSize = 20), Arb.int(1..120)) { name, age -> Person(name, age) } ``` The alternative — taking `Arb.string()` in `checkAll` and building the rest of the object inside the test body from hand-rolled values — makes those fields invisible to the framework. Kotest can only vary, report and shrink what a generator produced, so hand-built fields never get edge cases, never shrink, and never appear in the counterexample it prints. With `bind`, each component contributes its own edge cases and its own shrinker, so a failure shrinks toward a minimal `Person` rather than a random one. `bind` nests: build an `Arb<Address>` and feed it into `Arb.bind` for `Arb<Customer>`. Overloads exist only up to a fixed arity; for very wide constructors, or when one field depends on another, use the `arbitrary { }` builder with `.bind()` instead.

code

kotlin · 10 lines
kotlin
data class Person(val name: String, val age: Int)

val personArb: Arb<Person> = Arb.bind(
   Arb.string(minSize = 1, maxSize = 20),
   Arb.int(1..120),
) { name, age -> Person(name, age) }

checkAll(personArb) { person ->
   person.age shouldBeGreaterThan 0
}

go deeper

for a junior

Be able to write the call: one Arb per constructor parameter plus a lambda that builds the object, and know the result is itself an Arb you pass to checkAll.

for a middle

Explain why generated-not-improvised matters — shrinking, edge cases, printed counterexamples, seed replay — and show nesting for aggregates.

for a senior

Add the limits: fixed arity, independent sampling, invariants and init-block validation, and when you switch to flatMap or the arbitrary { } builder.

for a principal

Frame it as fixture design: a named arb per domain type, composed upward, constrained so generators can only build legal values, shared across specs instead of re-declared.

## The problem bind solves Property-based testing feeds generated values into a test body many times. In Kotest the generator type is `Arb<A>` (arbitrary) from the `kotest-property` module. Kotest ships generators for primitives and collections — `Arb.string()`, `Arb.int(1..120)`, `Arb.boolean()`, `Arb.list(...)` — but your domain is made of data classes, not primitives. `Arb.bind` is the standard way to lift field generators into an object generator. ```kotlin data class Person(val name: String, val age: Int) val personArb: Arb<Person> = Arb.bind( Arb.string(minSize = 1, maxSize = 20), Arb.int(1..120), ) { name, age -> Person(name, age) } ``` The signature is: N generators, then a lambda receiving one sampled value from each, returning the assembled object. Positional order matters — the lambda parameters line up with the generator arguments, not with the constructor's parameter names, so swapping two same-typed generators silently produces a wrong-but-compiling generator. ## Why not build the object inside the test body A very common shortcut is: ```kotlin checkAll(Arb.string()) { name -> val person = Person(name, Random.nextInt(1, 120)) // hidden input ... } ``` This compiles and it does test something, but the age is invisible to the framework, and that has four concrete consequences: 1. **No reporting.** When the property fails, Kotest prints the arguments it generated. The hand-rolled age is not one of them, so the printed counterexample does not describe the failing case. 2. **No shrinking.** Shrinking is the search for a smaller failing input; Kotest can only shrink values that came out of an `Arb`. A hand-rolled field stays at whatever random value it had. 3. **No edge cases.** Kotest's generators mix in known-nasty values (empty string, zero, boundary integers) alongside random samples. Hand-rolled values get none of that. 4. **No replay.** Kotest's seed pins its own random source. `Random.nextInt` outside that source is not reproduced by re-running with the printed seed, so the test becomes non-reproducible. Everything you want generated should therefore come from a generator. ## Composition and nesting `bind` returns a plain `Arb`, so it composes like any other: ```kotlin val addressArb = Arb.bind(Arb.string(), Arb.string()) { street, city -> Address(street, city) } val customerArb = Arb.bind(personArb, addressArb, Arb.boolean()) { p, a, active -> Customer(p, a, active) } ``` This is how you build a generator for a deep aggregate: a small named `Arb` per type, then compose upward. Named, reusable arbs in a test fixture beat re-declaring the same shape in five specs. ## Limits to know - **Arity.** The `bind` overloads stop at a fixed number of components. A constructor wider than that needs the `arbitrary { }` builder, where you call `.bind()` on each generator inside the block. - **Independent fields only.** `bind` samples every component independently before your lambda runs. If field B must depend on the sampled value of field A (`endDate` after `startDate`, list index within list size), `bind` cannot express it — use `flatMap` or `arbitrary { }`. - **Invariants.** `bind` will happily build objects your domain considers illegal. Enforce the invariant by constructing valid values (derive the dependent field), not by wrapping the whole generator in `filter`, which throws work away. - **Init blocks that throw.** If the data class validates in `init`, an out-of-range component makes the generator itself throw mid-run. Constrain the component generator (`Arb.int(1..120)` rather than `Arb.int()`) so the generator can only produce constructible values. ## What a good answer sounds like Name the API, show the positional lambda, then justify it in terms of shrinking, edge cases, reporting and reproducibility — those four are the reason the framework wants your inputs declared as generators rather than improvised in the body. Mention nesting for aggregates, and that dependent fields need `flatMap`/`arbitrary { }` instead.

  • Your data class validates its arguments in an init block and the property run blows up before the test body even executes. What went wrong?
    A component generator produced a value the constructor rejects, so the exception comes from the generator's builder lambda, not from your assertion. Fix it by constraining the component — `Arb.int(1..120)` instead of `Arb.int()`, `Arb.string(minSize = 1)` instead of an unbounded string. Filtering the assembled object afterwards also works but wastes samples and skews the distribution.
  • Two of the constructor's parameters are both Strings. What is the failure mode with Arb.bind?
    The lambda parameters match the generator arguments positionally, so swapping the two generators still type-checks and still compiles. You end up generating e-mails into the name field with no compiler error. Guard against it by naming the arbs (`emailArb`, `nameArb`) and by naming the constructor arguments inside the lambda body.

saying these in an interview costs you the question

  • Generating one field with an Arb and improvising the rest inside the test body with kotlin.random
  • Thinking Arb.bind can express a dependency between two fields
  • Assuming the lambda parameters bind by constructor parameter name rather than by position
  • Wrapping the whole bound generator in filter to enforce an invariant that could just be constructed correctly
  • Believing hand-rolled values still appear in Kotest's printed counterexample

context

open as a page

In Kotest's property-testing module, what is the difference in contract between `checkAll`, `forAll` and `forNone`, and how does each one decide that a property has failed?

level: middleimportance: must knowfreq 45%

basics

~20 s

checkAll's lambda returns Unit and fails when an assertion inside it throws. forAll's lambda must return Boolean and fails when it returns false. forNone is the inverse — it fails when the predicate returns true.

open as a page

Kotest's `Arb` is usually described as producing random values, yet property runs frequently hit values like 0, `Int.MIN_VALUE`, empty strings and empty lists. What is actually in an Arb's output stream?

level: middleimportance: must knowfreq 42%

basics

~20 s

An Arb is not purely random: it declares a set of edge cases alongside its random sampler, and Kotest interleaves those edge cases into the value stream with a small, configurable probability, so boundary values appear far more often than chance would produce.

open as a page

A Kotest property test failed once in CI and passes on your machine. The failure output includes a seed value. How do you use it to reproduce the failure, and what does it not guarantee?

level: middleimportance: must knowfreq 40%

basics

~20 s

Kotest prints the seed that drove the run's random source. Re-run the property with PropTestConfig(seed = thatValue) and the same generators produce the same samples in the same order, reproducing the failure — unless the test depends on anything outside that random source.

open as a page

Kotest's Arb type offers map, flatMap and filter. Explain what each does to a generator, and why filter is the one to be careful with.

level: middleimportance: should knowfreq 40%

basics

~20 s

map transforms each sampled value (Arb<A> to Arb<B>). flatMap feeds a sampled value into a function that picks the next generator, so it expresses dependent values. filter re-samples until a predicate passes — it wastes samples, can skew the distribution and can stall when matches are rare.

open as a page

How do you drive a Kotest property test with several generators at once, and what happens if you call Kotest's `checkAll<A, B>()` with type arguments but no generator arguments?

level: middleimportance: should knowfreq 32%

basics

~20 s

Pass one generator per lambda parameter — checkAll(Arb.int(), Arb.string()) { a, b -> ... } — using the arity overloads. With only type arguments, Kotest resolves a default Arb for each type reflectively and errors if a type has none.

open as a page

In Kotest's property module, what does an `Exhaustive` generator such as `Exhaustive.enum<T>()` or `Exhaustive.of(...)` guarantee about the values a test sees, and what happens when the iteration count is larger or smaller than the domain?

level: middleimportance: should knowfreq 34%

basics

~20 s

An Exhaustive holds a finite list of values and the run walks it in order, cycling when there are more iterations than values. Every value is covered only if iterations are at least the domain size; fewer iterations means some values are never tested.

open as a page

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%

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.

open as a page

When would you reach for Kotest's arbitrary { } generator builder with its .bind() shorthand instead of Arb.bind, and what do you have to think about when you do?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Use kotest-property's arbitrary { } builder when the object cannot be assembled from independently sampled fields: many parameters, fields that depend on each other, or conditional structure. Inside the block, calling .bind() on an Arb yields a sampled value. Watch shrinking — supply a Shrinker if you need it.

open as a page

What do the `minSuccess` and `maxFailure` settings on Kotest's `PropTestConfig` do, and when is it legitimate to move them off their defaults?

level: seniorimportance: should knowfreq 22%

basics

~20 s

They set a failure tolerance. By default every iteration must pass — one failure fails the property. Raising maxFailure lets that many iterations fail before the test fails; minSuccess requires at least that many passing iterations overall. Use only for genuinely statistical properties.

open as a page

How do you constrain Kotest's built-in generators — integers, strings and collections — to a specific range, size or character set, and what goes wrong if you leave the defaults in place?

level: seniorimportance: should knowfreq 30%

basics

~10 s

Pass the domain to the generator: Arb.int(1..100), Arb.string(minSize, maxSize, codepoints), Arb.list(elementArb, sizeRange). Unconstrained defaults generate values outside your contract — huge numbers, wide unicode, long collections — producing false failures and slow runs.

open as a page

A Kotest property keeps failing on an injected boundary value — an empty string, or an extreme integer — that your domain can never actually contain. How do you deal with that, and what is the risk of the obvious fix?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Decide first whether the value is genuinely impossible in production. If it is, express the real domain in the generator — constrain it, or override its edge cases with kotest-property's withEdgecases. If it merely feels unlikely, fix the code: suppressing the edge case hides the bug the framework just found.

open as a page

You need a Kotest generator that produces values from several alternative sources — say valid IDs from one generator and malformed ones from another. What does kotest-property offer for that, and how do Arb.merge, Arb.choice and Arb.choose differ?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

Kotest's Arb.choice(a, b, ...) picks one of the supplied generators uniformly per sample; Arb.choose(3 to a, 1 to b) picks by integer weight; and a.merge(b) combines two generators of compatible type by alternating between them. All three return a normal Arb you can compose further.

open as a page

What does Kotest's `Arb<A>.orNull()` produce, and how would you use it to exercise a nullable API contract without swamping the run with nulls?

level: middleimportance: nice to knowfreq 22%

basics

~10 s

orNull turns an Arb<A> into an Arb<A?> that emits null with a small probability alongside the underlying values. The nullProbability parameter tunes that rate, so you can make nulls rare, frequent, or effectively absent.

open as a page

kotest-property offers a reflective form of Arb.bind that builds a generator for a class without you naming any field generators. How does it work, and why would you not use it for your core domain types?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

The reified Arb.bind<T>() form inspects the class's primary constructor and resolves a generator for each parameter from Kotest's built-in defaults. It is convenient for throwaway or wide DTOs, but it ignores your domain constraints and fails at runtime for parameter types it has no default for.

open as a page

Your team's Kotest property tests each declare their own generators inline, and the same domain objects are generated five slightly different ways across the suite. How would you organise generators for a real domain model, and what conventions would you set?

level: principalimportance: nice to knowfreq 18%

basics

~20 s

Treat generators as a shared fixture library: one named Arb per domain type in a test-fixtures source set, composed upward with Arb.bind, constrained so every sample is legal by construction, with per-test narrowing done by composition rather than by copying and filtering.

open as a page

A Kotest property test fails on roughly one CI run in twenty. A teammate proposes setting `maxFailure` on Kotest's `PropTestConfig` so the build goes green again. How would you evaluate and handle that?

level: principalimportance: nice to knowfreq 16%

basics

~20 s

Diagnose first: reproduce from the printed seed, look at the shrunk input, and decide whether it is a real corner-case bug, an out-of-contract generator, an over-strong assertion, or a nondeterministic system. A failure budget is only correct for a genuinely statistical specification.

open as a page

How do you decide how tightly to constrain the generators in a property suite — matching the production input distribution versus maximising boundary coverage — and how do you keep those decisions reviewable as a codebase grows?

level: principalimportance: nice to knowfreq 15%

basics

~20 s

Constrain to the documented contract, not to production frequency. Generators should span every input the system promises to accept — including boundaries and absent values — and exclude only what a stated rule forbids. Share them as named, reviewed generators per domain type.

open as a page

Property tests in your CI pipeline occasionally fail on inputs nobody has seen before, and the team is debating pinning fixed seeds so the suite becomes deterministic. How would you decide, and what policy would you put in place?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Do not pin seeds permanently — that trades away the exploration that justifies property testing. Keep properties random, capture every failure's seed and shrunk counterexample, promote counterexamples to example-based regression tests, and treat a genuinely flaky property as a bug in the property or the code.

open as a page