Property-based testing beyond the tutorial — domain generators, model-based stateful tests, and shrinking a red property to a one-line repro.
The tutorial version of property-based testing is easy to enjoy: write @Property, generate a hundred random integers, assert an invariant, watch it pass. The version that finds production bugs is harder — it needs generators that produce realistic domain objects, properties that capture actual invariants, and stateful tests that drive a system the way users do. This post is about the jump between the two. If the jqwik basics — arbitraries, shrinking, first properties — are unfamiliar, the property-based testing with jqwik tutorial is the right starting point; this post assumes it.
A property over random integers proves your Math code. Your domain does not run on integers; it runs on orders, prices, tax bands and market states. The generator is where property-based testing is won or lost:
@Provide
Arbitrary<Order> orders() {
return Combinators.combine(
Arbitraries.integers().between(1, 10_000), // stake pence
Arbitraries.doubles().between(1.01, 1000.0), // back odds
Arbitraries.of(Tenor.class)) // maturity bucket
.as(Order::new);
}
The art is in the bounds. A stake generator that includes zero and negative values will fail your properties for reasons that have nothing to do with the code under test — but excluding them hides real bugs. The convention I use: keep the generator domain-realistic (valid stakes, valid odds) and push the boundary values in explicitly as edge arbitraries (Arbitraries.just(0), just(max)), so both the happy path and the boundaries get exercised on purpose.
A property is only as good as the invariant it encodes. The invariants worth encoding are the ones a developer would otherwise forget:
The last one is the shape of a genuinely useful property: it encodes a business rule (the exchange’s stake conservation) as a mathematical statement that holds for every input, not the examples you happened to think of.
Stateless properties test one call. Most bugs live in sequences: a state machine that misbehaves only after a particular chain of events. jqwik’s stateful testing lets you declare commands and a model of what correct behaviour looks like, then throw random command sequences at the system and compare:
@Property
void orderLifecycleIsConsistent(@ForAll("sequences") List<Command> commands) {
OrderModel model = new OrderModel();
OrderService system = new OrderService();
for (Command c : commands) {
c.run(model);
c.run(system);
assertEquals(model, system); // system must mirror the model
}
}
The model is a tiny, obviously-correct implementation; the system is the real code. Random sequences of place, match, cancel and settle commands must leave the two in agreement. Bugs that need “place, place, cancel, match, settle” in exactly that order to appear are exactly the bugs this finds — and the one thing example-based tests almost never hit. This pairs naturally with the records-and-sealed-interfaces state machine pattern, where the transition function is deterministic and trivially modelable.
When a property fails, jqwik doesn’t just report the failure — it shrinks it: it repeatedly simplifies the failing input while the failure persists, until it finds the minimal case. A 200-command sequence that violates conservation shrinks to match; cancel or even a single command. The shrinking report is the debugging session: the one-line repro is usually the bug, read aloud. The workflow that makes this productive:
The last step matters: the property stays as the general guard, the shrunk example becomes the documented regression test that everyone can read.
Property-based testing earns its keep on code with inputs that have structure and outputs that have invariants: parsers, calculators, serialisers, state machines, validation logic, and anything with round-trip or conservation semantics. A concrete example from this site: the overround calculator normalises implied probabilities from any set of odds — and the property “normalised probabilities always sum to 1.0 and are never negative” holds for every generated odds set, catching the rounding edge cases that hand-picked examples miss.
It is the wrong tool for UI flows, for code dominated by I/O, and for one-off scripts where the invariant is “it didn’t crash”. The tell is whether you can state the invariant in one sentence. If you can, property it; if you can’t, an example test is more honest.
Add one property per module, not a suite of forty. A single conservation property on the core domain object, a round-trip property on the DTOs, a model-based test on the one state machine — that is a suite that earns its keep without becoming a maintenance burden. Property-based testing is not a replacement for example tests; it is the layer that finds the bugs examples can’t, and the shrunk output tells you exactly what the bug is.
If you’re adopting property-based testing on a real codebase and want to know which invariants are worth encoding, drop me a line.