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?
answer
- arbitrary { } + someArb.bind() per sample
- use when: dependency, arity, varying structure
- derive fields — don't filter for them
- hand-assembled → supply a Shrinker if you want good counterexamples
- no Random, no I/O, no side effects inside the block
basics
~20 sUse 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.
solid answer
~50 s`Arb.bind` covers the easy case: independent components, fixed arity, one builder lambda. The `arbitrary { }` builder covers the rest: ```kotlin val orderArb: Arb<Order> = arbitrary { val lines = Arb.list(lineArb, 1..5).bind() val total = lines.sumOf { it.amount } // derived, not generated Order(id = Arb.uuid().bind().toString(), lines = lines, total = total) } ``` Inside the block, `someArb.bind()` pulls one sample from that generator, so you can interleave generation with ordinary Kotlin: derive one field from another, generate a list and then an index inside it, branch on a sampled boolean, or feed a dozen fields into a wide constructor that `Arb.bind` has no overload for. The block is a suspend context, so bind calls sit naturally in sequence. The trade-off is shrinking: a hand-assembled value shrinks only as well as the machinery you give it, so for a value you expect to see in counterexamples, supply a `Shrinker` to the builder or keep the composition inside `map`/`bind` where the components' own shrinkers apply.
code
kotlin · 10 linesval orderArb: Arb<Order> = arbitrary {
val lines = Arb.list(lineArb, 1..5).bind()
val discount = Arb.int(0..100).bind()
Order(
id = Arb.uuid().bind().toString(),
lines = lines,
lineCount = lines.size, // derived, always consistent
discountPercent = discount,
)
}go deeper
Know that arbitrary { } exists as the freeform generator builder and that calling .bind() on an Arb inside it gives you one sampled value.
Name the cases Arb.bind cannot cover — dependent fields, too many parameters, varying structure — and show a derived-field example.
Volunteer the shrinking trade-off and the purity rule (no external randomness, no side effects), and keep the imperative part minimal.
Set the team convention: named arbs per domain type, construction over filtering, shrinkers supplied for the aggregates that appear in counterexamples, generators reviewed like production code.
## Where Arb.bind runs out `Arb.bind(arbA, arbB) { a, b -> T(a, b) }` samples every component *independently* and only then calls your lambda. Three situations break that model: 1. **Dependency.** `total` must equal the sum of the generated lines; `endDate` must follow `startDate`; the index must be inside the list. 2. **Arity.** `bind` has typed overloads up to a fixed number of components. A wide constructor exceeds them. 3. **Structure.** The shape itself varies: sometimes an optional block is present, sometimes the generated tree recurses one level deeper. All three want *imperative* generation, and that is what the `arbitrary { }` builder gives you. ## The builder and .bind() ```kotlin val userArb: Arb<User> = arbitrary { val first = Arb.string(minSize = 1, maxSize = 10).bind() val last = Arb.string(minSize = 1, maxSize = 10).bind() User( firstName = first, lastName = last, displayName = "$first $last", // derived — never contradicts the parts age = Arb.int(18..99).bind(), ) } ``` `arbitrary { }` returns an `Arb<T>`; the block runs once per sample. Inside it, `.bind()` on any generator yields one sampled value from that generator, and everything else is plain Kotlin: `let`, `if`, loops, derivations. This composes with everything else — `arbitrary { }` generators can be nested inside `Arb.bind`, mapped, merged. The block is a suspending context, which is why `.bind()` can be called in sequence like a coroutine-style DSL; it does not mean the block should do I/O. Keep generation pure: no database calls, no clock reads, no `kotlin.random.Random`. Anything sourced outside the framework's random source breaks seed-based replay, which is the whole point of deterministic property testing. ## Shrinking — the real trade-off When a property fails, Kotest tries to find a smaller failing input. A shrinker needs to know how to make a value smaller. When you compose with `map`, the source's shrinker still applies and the mapping is re-run on each candidate. When you assemble a value imperatively in `arbitrary { }`, the framework has no structural knowledge of what you built, so you should not assume a rich shrink search happens for free. The builder accepts a `Shrinker` for exactly this reason: pass one when the type is worth shrinking (a big aggregate, a list-shaped payload), and accept coarse counterexamples when it is not. Practical policy: - Prefer `map`/`Arb.bind` where the shape allows — you inherit shrinking without effort. - Use `arbitrary { }` where you must, and keep the imperative part as small as possible: generate the primitive components with real arbs and do only the assembly by hand. - Supply edge cases and a shrinker explicitly for generators that sit at the centre of your test suite. ## Deriving instead of filtering The biggest win from the builder is that dependencies get *constructed* rather than *filtered*. Compare: ```kotlin // rejection sampling: throws away roughly half the samples Arb.bind(dateArb, dateArb) { a, b -> a to b }.filter { (a, b) -> a < b } // construction: every sample is valid by design arbitrary { val start = dateArb.bind() val end = start.plusDays(Arb.long(1..365).bind()) start to end } ``` The second version never wastes a draw, keeps the configured distribution, and cannot stall. ## Anti-patterns - **External randomness.** `Random.nextInt()` inside the block. Non-reproducible under a fixed seed; use an `Arb` and `.bind()`. - **Side effects.** Persisting entities or mutating shared fixtures inside the generator. Generation may run more often than you expect — including during shrinking, which re-runs the property body with candidate values. - **Ten lines of business logic.** If the generator re-implements the production algorithm to compute a derived field, the property becomes a tautology. Derive only what is structurally required; let the code under test do the interesting computation. - **One giant generator.** Break it into named arbs per type and assemble; you get reuse and much better failure output. ## The interview signal A strong answer names the three situations `Arb.bind` cannot handle, shows `.bind()` inside `arbitrary { }`, and then volunteers the shrinking trade-off and the purity requirement without being prompted — that combination shows you have actually debugged a property suite, not just read the DSL.
- What breaks if you call kotlin.random.Random inside an arbitrary { } block?Reproducibility. Kotest replays a failing property by re-running it with the random source pinned to the printed seed; a value taken from an unrelated Random is not part of that source, so the same seed can produce a different input and the failure will not reproduce. It also means the value is not shrunk and does not appear in the reported counterexample.
- Your arbitrary { } generator produces a big aggregate, and counterexamples come back huge and hard to read. What can you do?Either move the composition into map/Arb.bind so the components' own shrinkers apply, or supply a Shrinker to the builder that knows how to make your aggregate smaller — dropping list elements, zeroing optional fields. Failing that, shrink the problem by hand: once you have a counterexample, pin it as an ordinary example-based test and simplify it there.
saying these in an interview costs you the question
- Using arbitrary { } for plain independent fields where Arb.bind is simpler and shrinks better
- Calling kotlin.random.Random or reading the clock inside the generator block
- Assuming a hand-assembled value automatically shrinks as well as a mapped one
- Doing I/O or mutating shared fixtures inside a generator that may run hundreds of times
- Re-implementing the production calculation inside the generator so the property can no longer fail