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?
answer
- generators inject known-nasty boundary values on purpose
- impossible vs merely inconvenient — decide first
- constrain at the source: Arb.int(0..120)
- withEdgecases replaces the boundary set
- suppressing edges keeps the tick green and loses the value
basics
~20 sDecide 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.
solid answer
~50 sKotest's generators deliberately mix known-nasty boundary values into their samples, which is why they find bugs random values would miss. When one of those values fails, the judgement call is whether the value is *impossible* or merely *inconvenient*. - **Impossible in production** (the field is validated upstream, the type is bounded): the generator is wrong, not the code. Express the real domain — `Arb.int(1..120)` instead of `Arb.int()`, `Arb.string(minSize = 1)` instead of an unbounded string — or keep the generator and replace its boundary values with domain-appropriate ones using `withEdgecases(...)`. - **Merely unlikely**: the property has found a real defect. Fix the code, or state the precondition honestly in the property. The risk of reaching for `withEdgecases` reflexively is that it removes exactly the samples most likely to expose bugs. Prefer constraining at the source, so the generator documents the legal input space; use `withEdgecases` when the shape is right but the built-in boundaries are not.
code
kotlin · 5 lines// Preferred: state the real domain, keep meaningful boundaries (0 and 120)
val ageArb = Arb.int(0..120)
// Precise: shape is right, boundaries are domain-specific
val quantityArb = Arb.int(1..100).withEdgecases(1, 100)go deeper
Know that Kotest deliberately feeds boundary values like empty strings and extreme numbers, and that a failure on one usually means real code missing a case.
Show both fixes — a constrained generator, or withEdgecases to replace the boundary set — and pick constraining as the default.
Lead with the impossible-versus-inconvenient judgement and name the failure mode of suppressing boundaries, plus the effect on seed replay.
Make it a review rule: generators state the legal domain, edge-case overrides carry a written justification, and no boundary is suppressed to fix a red build.
## Why the boundary value showed up kotest-property generators do not sample uniformly at random. Alongside random values they inject *edge cases* — the values that historically break code: empty strings and empty lists, zero, negative numbers, minimum and maximum values of a numeric type, nulls for nullable generators. This is a deliberate policy: purely random sampling from a 64-bit space almost never produces zero, and zero is where the bugs are. So when your property fails on `""` or `Int.MIN_VALUE`, the framework is doing its job. The question is whether *your* code is supposed to handle that value. ## The decision **Case A — the value cannot occur.** The field is validated at the boundary of the system, or the type genuinely constrains it: an `age` column is `CHECK (age BETWEEN 0 AND 120)`, a request DTO is rejected before this function is reached. Then the generator is over-broad: it is testing a contract you never promised. The fix belongs in the generator. **Case B — the value can occur, you just did not think about it.** Empty strings arrive from web forms all the time; a quantity of zero is a legal cart state; `Int.MIN_VALUE` reaches an `abs()` call in real code. Then the property has found a genuine defect and the only correct response is to fix the code or, where the precondition is real and enforced elsewhere, make the precondition explicit rather than silently deleting the sample. Most teams get this wrong in the same direction: they suppress the edge case because the build is red, and lose the finding. Answer Case A vs Case B out loud before touching the generator. ## Expressing the real domain The best fix is almost always to constrain at the source, because a constrained generator documents the legal input space for the next reader: ```kotlin val ageArb = Arb.int(0..120) // not Arb.int().filter { it in 0..120 } val nameArb = Arb.string(minSize = 1, maxSize = 64) ``` A bounded generator still injects boundary values — but *its* boundaries (0 and 120), which are exactly the ones you want tested. When the generator's shape is right and only the boundary set is wrong, kotest-property lets you replace it: ```kotlin val currencyArb = Arb.of("EUR", "USD", "GBP").withEdgecases("EUR") val quantityArb = Arb.int(1..100).withEdgecases(1, 100) ``` `withEdgecases` returns a generator with your list of edge cases in place of the built-in ones — useful for domain types where "interesting boundary" means something specific (the smallest legal order, the last day of a billing period) that Kotest cannot guess. You can also pass an empty list to stop boundary injection for that generator entirely; do that consciously, knowing what you are giving up. ## The risk, stated plainly Edge cases are the highest-yield samples in the run. Removing them makes a suite *look* the same — same iteration count, same green tick — while quietly losing most of its bug-finding power. Two guard rails: 1. **Never suppress an edge case to make a red build green** without first deciding it is impossible in production. Write the reason in a comment next to the generator. 2. **Prefer constraining over suppressing.** `Arb.int(1..100)` says "the domain is 1..100" and still tests its own boundaries; `Arb.int().withEdgecases()` says nothing and tests fewer interesting values. ## Interaction with shrinking and replay Edge cases participate in the same machinery as everything else: a failure on an injected boundary shrinks like any other failure, and the run's seed reproduces it. Changing a generator's edge cases changes what a given seed produces, so an old seed will not replay after you edit the generator — capture the shrunk counterexample as an example-based test before you start editing. ## What interviewers are listening for The judgement, not the API. A strong answer separates "impossible" from "inconvenient", names constraining as the preferred fix and `withEdgecases` as the precise one, and explicitly flags the failure mode of suppressing boundaries to keep a build green.
- How do you tell whether an injected boundary value is a real defect or a generator that is too broad?Ask where the value could come from in production. If an upstream validator, database constraint or type guarantees it cannot reach this function, the generator is over-broad and should express the real domain. If any real caller — a web form, an external API, a migration — could supply it, the property has found a genuine defect and the code must handle it. Writing that reasoning next to the generator keeps the decision reviewable.
- You edit a generator's edge cases while investigating a failure. What happens to the seed you were using to reproduce it?It stops being useful. The seed pins the random source, not the values, so changing what the generator produces changes how that stream maps onto samples and the failing iteration will not come back. Capture the shrunk counterexample as a concrete example-based test before editing generators, so the reproduction survives the change.
saying these in an interview costs you the question
- Suppressing edge cases to turn a red build green without deciding whether the value is possible
- Assuming Kotest samples uniformly and the boundary value was just bad luck
- Reaching for withEdgecases when a bounded generator would express the domain better
- Believing a constrained generator no longer injects boundary values (it injects its own)
- Expecting an old seed to still replay after the generator's edge cases changed