skip to content

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%

answer

  1. choice = uniform pick among arbs
  2. choose = weight to arb pairs
  3. merge = blend two streams, fluent on the receiver
  4. sealed class → one arb per subtype + choice
  5. don't choice(a, b).filter — just use a

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.

solid answer

~50 s

For union-shaped inputs, kotest-property gives you three options: - **`Arb.choice(arbA, arbB, …)`** — each sample comes from one of the generators, chosen uniformly. - **`Arb.choose(3 to arbA, 1 to arbB)`** — the same idea with integer weights, so you can say "mostly well-formed, occasionally garbage". Weights must be positive; the ratio, not the absolute numbers, is what matters. - **`arbA.merge(arbB)`** — an instance method that combines two generators of the same (or a common super-) type by alternating between their sample streams. They are all just `Arb<A>`, so `Arb.choice(validId, malformedId).map(::Request)` composes normally. Which to reach for: `choose` when the mix matters (a realistic 95/5 valid-to-invalid split), `choice` when the alternatives are peers (each subtype of a sealed hierarchy), `merge` when you literally want two streams blended. For a sealed class, one `Arb` per subtype fed into `Arb.choice` is the idiomatic shape.

code

kotlin · 8 lines
kotlin
sealed interface Event
data class Created(val id: String) : Event
data class Deleted(val id: String, val reason: String) : Event

val createdArb = Arb.string(1..10).map(::Created)
val deletedArb = Arb.bind(Arb.string(1..10), Arb.string()) { id, r -> Deleted(id, r) }

val eventArb: Arb<Event> = Arb.choice(createdArb, deletedArb)

go deeper

for a junior

Know that Kotest can combine several generators into one: Arb.choice for an even pick, Arb.choose when you want weights.

for a middle

Explain the difference between choice, weighted choose and merge, and give the sealed-hierarchy pattern of one arb per subtype.

for a senior

Talk about input-mix design — where the iteration budget is spent — and about shrinking staying inside the branch that failed.

for a principal

Treat the union generator as a model of the real input distribution: weight it to match production traffic, keep branch generators reusable, and review the mix when the failure profile changes.

## Union-shaped inputs Many real inputs are not one shape but several: a request that may be valid or malformed, a sealed `Result` hierarchy, an ID that may be a UUID or a legacy numeric string. kotest-property expresses this with alternative-combining generators rather than with a single clever `Arb`. ## Arb.choice ```kotlin val eventArb: Arb<Event> = Arb.choice(createdArb, updatedArb, deletedArb) ``` `choice` takes a set of generators and, for each sample, picks one of them uniformly, then draws from it. This is the natural fit for a sealed hierarchy: write one small `Arb` per subtype (usually with `Arb.bind`), then `choice` them together to get an `Arb` of the parent type. The result is a normal generator — nest it, map it, put it into another `bind`. ## Arb.choose ```kotlin val idArb: Arb<String> = Arb.choose( 19 to wellFormedUuid, 1 to malformedId, ) ``` `choose` is the weighted variant: pairs of `weight to arb`. It matters when the distribution is part of what you are testing. A parser fuzzing property that draws 50% garbage spends half its budget re-confirming the same rejection path; a 19:1 mix keeps most iterations on the interesting happy path while still hammering the error branch regularly. Weights must be positive, and only their ratio matters — `2 to a, 1 to b` and `20 to a, 10 to b` behave the same. ## merge ```kotlin val combined = smallInts.merge(hugeInts) ``` `merge` is an instance function on `Arb` that blends two generators of the same or compatible type by taking from them in alternation. It reads well when you are extending an existing generator with a second family of values — "the normal cases, plus these pathological ones" — and it chains: `a.merge(b).merge(c)`. Because `merge` is an extension on the receiver, it composes fluently in a pipeline (`base.merge(extra).map(::wrap)`), whereas `choice`/`choose` are companion functions you call up front. That is mostly ergonomics; the choice between them is about whether you want a stated mix (`choose`), peers (`choice`) or a blended stream (`merge`). ## Practical guidance **Edge cases.** Each alternative generator brings its own edge cases; combining them does not remove that. A union generator therefore covers more boundary values than a hand-rolled `when(Random.nextInt(3))` inside your test body — which, as always, would also be invisible to shrinking and to seed replay. **Shrinking.** Shrinking works within whichever alternative produced the failing sample. It will not helpfully convert a malformed ID into a well-formed one — the alternatives are different families, not points on one scale. Expect the counterexample to be a minimal member of the branch that failed, which is normally what you want. **Don't smuggle in a filter.** A tempting anti-pattern is `Arb.choice(a, b).filter { it.isValid }` — that is a slow way of saying `a`. If you only want one branch, use one branch. **Keep branch generators small and named.** `Arb.choice(createdArb, updatedArb, deletedArb)` documents the input space; a single 40-line inline lambda does not. Named per-subtype arbs also get reused by other specs. **Sealed hierarchies are the canonical use.** Kotlin's sealed classes and property testing pair naturally: one generator per subtype, one `choice` at the top, and a property that asserts the invariant every subtype must satisfy (round-tripping through serialization is the classic). ## What interviewers listen for That you know union generation is first-class, that you can name at least `choice` and the weighted `choose`, and that you can say *why* weighting matters — realistic input mixes and budget spent where the bugs are. Bonus signal: mapping the sealed-class case onto one-arb-per-subtype.

  • Why would you weight a fuzzing generator 19:1 toward well-formed input instead of 50:50?
    Because iteration budget is finite and the error path is usually shallow. A 50/50 mix spends half the run re-confirming the same rejection branch, while the deep logic behind successful parsing gets half the samples. A skewed mix keeps most iterations exercising the complex path while still hitting the error branch many times over a few hundred iterations.
  • How does shrinking behave for a value produced by Arb.choice?
    Shrinking happens inside whichever alternative produced the failing sample, using that generator's shrinker. It does not migrate the counterexample from one branch to another, because the alternatives are distinct families rather than points on a single scale. You get a minimal member of the failing branch, which is normally the useful outcome.

saying these in an interview costs you the question

  • Rolling a random branch inside the test body with when(Random.nextInt(n)) instead of a union generator
  • Thinking Arb.choose weights are probabilities that must sum to 1
  • Combining alternatives with choice and then filtering back down to one of them
  • Expecting shrinking to convert a counterexample from one alternative into another
  • Writing one giant inline generator instead of one named arb per sealed subtype

context