Property Based Testing in Go With the rapid Package
Property based testing in Go with rapid: state a rule that holds for every input, shrink failures to a minimal case and test stateful code against a model.

An example based test says: for this input, I expect this output. It is only as good as the examples you thought of, and the bug is usually in one you did not. A property based test says something stronger: for every input of this kind, this rule holds. A generator produces hundreds of inputs, the framework looks for one that breaks the rule, and when it finds one it shrinks it to the smallest case that still fails. You get a counterexample you could not have written by hand and a minimal one at that.
Tested with Go 1.25.6, pgregory.net/rapid v1.3.0, macOS on Apple Silicon (arm64)
Go has this in two forms: testing/quick in the standard library, and third party packages. This article uses rapid, which shrinks well, generates structured data and tests stateful code, on Go 1.25.6. It builds on the same ideas as fuzzing, and the two are complements, not competitors.
What a property is
A property is an invariant you can state without computing the answer. That is the hard part and the useful one. You rarely have a second implementation to compare against, but you often know a rule: sorting twice equals sorting once, encoding then decoding gives back the input, the parts of a split add up to the whole, a balance never goes negative. The patterns that come up most:
- Round trip.
decode(encode(x)) == x. - Invariant. Something that must hold for any valid input: a total, an ordering, a size limit.
- Idempotence.
f(f(x)) == f(x), as for normalisation and deduplication. - Oracle. Compare a fast or clever implementation with a slow one that is easy to trust.
- Model. Run the same operations against your type and a trivial stand-in, and compare.
All of them are things a person knows about the code without enumerating cases.
A first property
package main
// SplitBill shares total cents between n people. The first version drops the remainder.
func SplitBill(total, n int) []int {
shares := make([]int, n)
for i := range shares {
shares[i] = total / n
}
return shares
}
// SplitBillFixed hands the leftover cents to the first people.
func SplitBillFixed(total, n int) []int {
shares := make([]int, n)
for i := range shares {
shares[i] = total / n
if i < total%n {
shares[i]++
}
}
return shares
}
type Queue struct {
items []int
cap int
}
func NewQueue(cap int) *Queue { return &Queue{cap: cap} }
func (q *Queue) Push(v int) bool {
if len(q.items) >= q.cap {
return false
}
q.items = append(q.items, v)
return true
}
func (q *Queue) Pop() (int, bool) {
if len(q.items) == 0 {
return 0, false
}
v := q.items[0]
q.items = q.items[1:]
return v, true
}
func (q *Queue) Len() int { return len(q.items) }
func main() {}SplitBill shares an amount in cents between n people. The first version divides and stops, which is wrong whenever the total is not a multiple of n: the remainder disappears. Anyone can spot it by reading, but suppose you had not. The property is easy to state: the shares must add up to the total.
package main
import (
"slices"
"testing"
"pgregory.net/rapid"
)
func sum(xs []int) int {
t := 0
for _, x := range xs {
t += x
}
return t
}
func TestSplitBillFixedProperties(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
total := rapid.IntRange(0, 1_000_000).Draw(t, "total")
n := rapid.IntRange(1, 50).Draw(t, "n")
shares := SplitBillFixed(total, n)
if sum(shares) != total {
t.Fatalf("shares %v do not add up to %d", shares, total)
}
if slices.Max(shares)-slices.Min(shares) > 1 {
t.Fatalf("shares %v differ by more than one cent", shares)
}
})
}
func TestSplitBillBuggy(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
total := rapid.IntRange(0, 1_000_000).Draw(t, "total")
n := rapid.IntRange(1, 50).Draw(t, "n")
if got := sum(SplitBill(total, n)); got != total {
t.Fatalf("shares add up to %d, want %d", got, total)
}
})
}
// queueMachine checks the real queue against a plain slice after any sequence of operations.
type queueMachine struct {
q *Queue
model []int
}
func (m *queueMachine) Push(t *rapid.T) {
v := rapid.Int().Draw(t, "v")
ok := m.q.Push(v)
if ok != (len(m.model) < 3) {
t.Fatalf("Push accepted=%v with %d items", ok, len(m.model))
}
if ok {
m.model = append(m.model, v)
}
}
func (m *queueMachine) Pop(t *rapid.T) {
v, ok := m.q.Pop()
if ok != (len(m.model) > 0) {
t.Fatalf("Pop ok=%v with %d items", ok, len(m.model))
}
if ok {
if v != m.model[0] {
t.Fatalf("Pop = %d, want %d", v, m.model[0])
}
m.model = m.model[1:]
}
}
func (m *queueMachine) Check(t *rapid.T) {
if m.q.Len() != len(m.model) {
t.Fatalf("Len = %d, model has %d", m.q.Len(), len(m.model))
}
}
func TestQueueStateMachine(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
m := &queueMachine{q: NewQueue(3)}
t.Repeat(rapid.StateMachineActions(m))
})
}rapid.Check runs the function many times. Inside it, rapid.IntRange(0, 1_000_000).Draw(t, "total") asks a generator for a value and gives it a name that appears in failure output. The test then calls the code and checks the rule with t.Fatalf, using the *rapid.T the callback receives, which is a testing.TB with generator support.
F='s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #; s/\[rapid\] flaky.*//; s/rapid\.seed=[0-9]+/rapid.seed=<seed>/; s/[0-9.]+s$/<time>/'
go test -count=1 -v -run 'Fixed|Queue' . 2>&1 | grep -vE '^(=== RUN|PASS)' | sed -E "$F"
echo "# the buggy version"
go test -count=1 -run 'Buggy' . 2>&1 | grep -vE '^(=== RUN|PASS)' | grep -v "To reproduce" | sed -E "$F" | head -12 [rapid] OK, passed 100 tests (242.458µs)
--- PASS: TestSplitBillFixedProperties
[rapid] OK, passed 100 tests (4.376625ms)
--- PASS: TestQueueStateMachine
ok example.test/lab <time>
# the buggy version
--- FAIL: TestSplitBillBuggy
[rapid] failed after 0 tests: shares add up to 0, want 1
Failed test output:
[rapid] draw total: 1
[rapid] draw n: 2
shares add up to 0, want 1
FAIL
FAIL example.test/lab <time>
FAILThe buggy test failed on the first generated case. The report is the valuable part: draw total: 1, draw n: 2, and the message shares add up to 0, want 1. Rapid did not stop at the first failing input, which was probably a large random pair. It shrank the draws toward simpler values, and the case that survived is one cent between two people, the smallest example of the bug. A good property test hands you the same input you would have written after a day of debugging, and it does so on the first run, before anyone has filed a bug and before the code has reached a customer.
For a fixed version, two properties pass, 100 cases each: the shares sum to the total, and slices.Max(shares) - slices.Min(shares) <= 1, which says the leftover cents are spread, not dumped on one person. The second property is the kind a hand-written table forgets. It catches a different mistake, giving the whole remainder to the last share, that the sum alone allows.
Generators
Everything depends on generating the right inputs. Rapid ships generators for the basic types (Int, Float64, String, Bool, Byte), range-limited ones (IntRange, StringN), collections (SliceOf, MapOf, SliceOfDistinct) and combinators (OneOf, Just, SampledFrom, Custom). A struct is built by drawing each field, usually in a small helper:
func genUser() *rapid.Generator[User] {
return rapid.Custom(func(t *rapid.T) User {
return User{
Name: rapid.StringN(1, 20, 40).Draw(t, "name"),
Age: rapid.IntRange(0, 120).Draw(t, "age"),
}
})
}Constrain the generator to the inputs your function accepts. If SplitBill requires n >= 1, draw n from IntRange(1, 50) rather than drawing any int and calling t.Skip for invalid ones. A generator that mostly produces rejected cases wastes the run and makes the property look stronger than it is. Keep ranges honest about production too: if totals are at most a million cents in your system, a generator up to math.MaxInt finds overflow bugs that cannot happen, and you will learn to ignore its failures.
Shrinking and reproducing
Shrinking works because rapid records the random bytes behind each draw and, on failure, tries simpler byte sequences: shorter, smaller, earlier. It re-runs the test on each and keeps what still fails. That is why the output above is 1 and 2 and not 737412 and 31. Names in Draw matter here: they label the minimal values in the report.
Every failure writes a .fail file under testdata/rapid/<TestName>/, and the report prints how to replay it with -rapid.failfile. Re-running go test replays recorded failures first, so a counterexample cannot silently vanish when you fix something nearby. Commit the file when the case is interesting, and delete it when the bug is fixed and covered by a normal test. The seed flag -rapid.seed reproduces a whole run, and -rapid.checks=1000 raises the number of cases when you want to look harder.
Treat a counterexample as a bug report and write the example based test for it. A property found it, but a one-line table row keeps it fixed and is readable by the next person.
Testing stateful code against a model
Pure functions are the easy case. The harder bugs live in types whose behaviour depends on the order of operations: queues, caches, buffers, state machines. For those, rapid has a state machine mode. You write a struct holding the real implementation and a model, a method per operation, and a Check method for invariants.
The model here is a plain slice. Push on the real queue must succeed exactly when the model holds fewer than three items, and Pop must return the model's first element. Check compares lengths after every step. t.Repeat(rapid.StateMachineActions(m)) then generates random sequences of Push and Pop calls, with random arguments, and runs them against both. The queue passed 100 sequences.
You did not write a single sequence. Rapid chose the operations, the order and the values, and would shrink a failing sequence to the shortest one. A bug such as "pop after three pushes and a failed push returns the wrong item" needs a particular sequence, and sequences are the thing a human enumerates worst. The model has to be simpler than the real code, or you are testing two copies of the same mistake.
Finding properties when none come to mind
The usual objection is "I cannot think of a property". Most people look for the complete specification, which is the function itself. Look for weaker statements, and you will find several in a few minutes. Take the bill splitter and ask what would be embarrassing if it were false.
- Conservation. Nothing is created or destroyed: the shares sum to the total. This is the property that found the bug.
- Fairness. Nobody pays more than a cent above anyone else. It is a different failure from the first one, and it needs a different test.
- Shape. The result has exactly
nentries and none is negative. These are cheap and catch off by one errors in loops. - Monotonicity. A larger total never gives anyone a smaller share. It is rarely violated, but it is the kind of statement that protects a later optimisation.
- Stability. The same inputs give the same output. This catches map iteration order and hidden randomness.
Write them as separate rapid.Check calls or as separate assertions in one. Separate checks make the failure message say which rule broke, and a property that fails deserves a name. If you cannot think of any, start from the bug tracker: every bug that was fixed is a property that was missing, and it can usually be generalised from "this input" to "inputs like this".
Running them in CI
A property test is an ordinary test to go test, and the number of cases is the setting that matters. The default of 100 checks is a sensible local value, since it keeps the suite fast. In CI, where nobody is waiting, raise it:
go test -rapid.checks=2000 ./...A bigger number means each run explores more of the space, and since the seed differs every run, repeated runs across a week explore more still. That is also why failures can appear on the main branch that did not appear on the pull request. This is a feature: the bug existed before the run found it. Make sure CI prints the seed or keeps the testdata/rapid files as artefacts, so you can replay a failure that you did not see locally. The report already prints -rapid.seed and the failfile path, so capture the output of a failed job and do not truncate it.
Rapid can also drive the Go fuzzing engine: rapid.MakeFuzz turns a property into a function you pass to f.Fuzz. The fuzzer then supplies the random bytes behind the draws, with its coverage feedback, instead of rapid's own generator. You keep the typed generators and the shrinking, and you gain the engine's habit of chasing new code paths. Run it with go test -fuzz for a nightly job; the same property still runs as a plain test in every build.
Choosing between the options
testing/quick is in the standard library and needs nothing. It generates values by reflection, cannot shrink, and gives you little control over distributions, so a failure reports whatever random value hit it. For a quick check on a pure function it is fine. gopter is older and more configurable than rapid, with more ceremony. Rapid's draw-based API reads as ordinary Go and its shrinking is the best of the three, which is why this article uses it. Whichever you choose, the habit matters more than the tool, and the cost of trying is one extra import and an afternoon on a function you already know well: the first time a property test hands you a minimal counterexample for a bug that three reviewers missed, you will write the next one without being asked.
Where it fits with other tests
Property tests do not replace examples. They are slower, they are harder to read, and when one fails the cause is sometimes your property rather than your code. Use them where the input space is large and the rule is crisp: parsers and encoders, money and unit arithmetic, normalisation, data structures, protocol state machines. Use examples for the specific cases that document behaviour and for the regression a property found.
Compared with fuzzing, a property test runs in the normal go test and takes a fraction of a second, generates structured input directly, and is guided by your generators rather than by coverage. Fuzzing explores the code paths of one function with arbitrary bytes for as long as you let it. A parser benefits from both: fuzz for crashes, and use properties for the round trip. The fuzz target article shows the fuzzing half.
Limits
- The property is the test. A weak property passes while the code is wrong. Test the test: break the code on purpose and see whether it fails.
- Randomness is not coverage. Rapid runs 100 cases by default. Edge cases such as zero, one and the maximum are generated often, but there is no guarantee. Add an example for a boundary you care about.
- Slow code, few cases. A property that runs a database query per case should lower
-rapid.checks, or use a model instead. - Time and randomness inside the code make properties flaky unless you inject them, as in testing determinism.
- Failures need reading. The shrunk case is minimal, not explanatory. Reproduce it by hand before fixing.
The rapid documentation covers the full generator list and the StateMachineActions details, and the Go team's testing/quick package is the dependency-free option, though it does not shrink. Pin the version in go.mod: v1.3.0 is the one this article ran.


