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?
answer
- Arb.bind(a, b) { … } → Arb<T>
- positional lambda, not named params
- hand-rolled field = no shrink, no edge cases, no replay
- nest binds for aggregates
- dependent fields → flatMap / arbitrary { }
basics
~20 sPass 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 linesdata 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
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.
Explain why generated-not-improvised matters — shrinking, edge cases, printed counterexamples, seed replay — and show nesting for aggregates.
Add the limits: fixed arity, independent sampling, invariants and init-block validation, and when you switch to flatMap or the arbitrary { } builder.
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