Property-Based Testing in Go: Testing Invariants with Generated Inputs
Using Rapid to test serialization round trips, deterministic event IDs and stateful behavior with generated inputs.
I once ran into a Go type that could be serialized and deserialized without an error, but the value that came back was different from the original. The tests passed because they only covered the inputs I had written down.
Property-based testing (PBT) helps with this kind of gap. Instead of listing more examples, I describe the invariant and use generated values to test it. When a test fails, the library shrinks the input to a smaller counterexample. I still keep example-based tests for cases worth naming and documenting.
The same method works for deterministic IDs, sorting and state transitions, provided I can state the expected behavior precisely. I’ll use serialization first because the example is small:
deserialize(serialize(value)) == value
For a real type, I need to define what == means.
1. The example-based test
Suppose I have a small Go type that needs to be encoded as JSON:
package record
import "time"
type Record struct {
ID string `json:"id"`
Labels []string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
I might start with this round-trip test:
func TestRecordJSONRoundTrip(t *testing.T) {
original := Record{
ID: "order-123",
Labels: []string{"priority", "paid"},
CreatedAt: time.Date(2026, time.July, 28, 10, 30, 0, 0, time.UTC),
}
encoded, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var decoded Record
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(original, decoded) {
t.Fatalf("round trip changed the record:\noriginal: %#v\ndecoded: %#v",
original, decoded)
}
}
This test passes because it uses non-empty labels and a UTC timestamp. I could add more table entries, but each one still depends on me anticipating the case.
2. Generating values with Rapid
For Go, I use Rapid. It provides typed generators and shrinks failing inputs.
$ go get pgregory.net/rapid
Rapid uses Go’s standard testing.T, so property-based tests run alongside other unit tests:
$ go test ./...
The generator for Record looks like this:
func recordGenerator() *rapid.Generator[Record] {
return rapid.Custom(func(t *rapid.T) Record {
labelCount := rapid.IntRange(0, 4).Draw(t, "label count")
labels := make([]string, labelCount)
for i := range labels {
labels[i] = rapid.String().Draw(t, "label")
}
seconds := rapid.Int64Range(
1_577_836_800, // 2020-01-01
1_893_456_000, // 2030-01-01
).Draw(t, "seconds")
nanoseconds := rapid.Int64Range(0, 999_999_999).
Draw(t, "nanoseconds")
return Record{
ID: rapid.String().Draw(t, "id"),
Labels: labels,
CreatedAt: time.Unix(seconds, nanoseconds).UTC(),
}
})
}
This generator covers empty and non-empty strings, zero to four labels and nanosecond-precision timestamps.
The round-trip property reuses the same serialization code:
func TestRecordJSONRoundTripProperty(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
original := recordGenerator().Draw(t, "record")
encoded, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var decoded Record
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(original, decoded) {
t.Fatalf("round trip changed the record:\noriginal: %#v\ndecoded: %#v\njson: %s",
original, decoded, encoded)
}
})
}
Rapid finds a failing Record and shrinks it until Labels is an empty, non-nil slice:
original: record.Record{ID:"", Labels:[]string{}, ...}
decoded: record.Record{ID:"", Labels:[]string(nil), ...}
The omitempty tag removes the empty slice from the JSON. Deserializing an absent field leaves Labels as nil, and reflect.DeepEqual treats that as different from an allocated empty slice. The code is behaving as configured. I need to decide whether the application considers those two values equivalent.
Case 1: non-empty labels
["paid"] -> JSON ["paid"] -> ["paid"]
representation equality: PASS
semantic equality: PASS
Case 2: empty labels
[] (non-nil) -> JSON field omitted -> nil
representation equality: FAIL
semantic equality: PASS
3. Equality for Record
Serialization tests commonly need three kinds of equality:
- Representation equality preserves every field and representation detail.
- Semantic equality compares the fields that define application meaning.
- Canonical encoding gives equivalent values one stable byte representation.
Suppose the application treats nil and empty labels as equivalent. For CreatedAt, it compares the represented instant and ignores the internal representation:
func (r Record) Equal(other Record) bool {
return r.ID == other.ID &&
slices.Equal(r.Labels, other.Labels) &&
r.CreatedAt.Equal(other.CreatedAt)
}
slices.Equal treats nil and empty slices as equal. time.Time.Equal compares instants, which is usually what I want after serialization because a monotonic clock reading may be discarded and an offset does not preserve its location.
I can replace reflect.DeepEqual with the domain comparison:
if !original.Equal(decoded) {
t.Fatalf(
"round trip changed the record:\noriginal: %#v\ndecoded: %#v\njson: %s",
original,
decoded,
encoded,
)
}
Applications that distinguish nil from an empty slice need a different encoding contract. I would remove omitempty, use a custom representation or reject values that cannot be represented without losing information.
4. Other serialization properties
Depending on the format, I may also test these properties:
semantic round trip: semantically_equal(deserialize(serialize(value)), value)
stable encoding: serialize(deserialize(serialize(value))) == serialize(value)
invalid input: deserialize(invalid bytes) returns an error and does not panic
Stable encoding matters when bytes are hashed, signed, cached or compared. Invalid-input tests need a generator for arbitrary or malformed bytes rather than valid domain values.
I pay particular attention to values that formats often lose or normalize:
- numeric limits, conversions and precision
- timestamps, offsets, locations and monotonic readings
- nil, empty and omitted values
- Unicode, invalid UTF-8 and normalization
- maps, tagged unions and unknown variants
- custom marshalers and versioned payloads
5. Properties outside serialization
I use the same pattern when I can describe behavior as a relationship across inputs or operations:
idempotency: f(f(value)) == f(value)
inverse operations: undo(do(value)) == value
model agreement: optimized(value) == reference(value)
Keep event IDs stable across retries
Event delivery is another case I have dealt with. If a publisher retries after a timeout, the retry is still the same logical event and should keep the same ID. Generating a random UUID for each attempt makes one event look like several.
UUID version 5 derives an ID from a namespace and a name. Its output is deterministic for the same inputs. The github.com/google/uuid package exposes it as uuid.NewSHA1:
$ go get github.com/google/uuid
type EventIdentity struct {
AggregateID string
Kind string
Sequence uint64
}
type Event struct {
Identity EventIdentity
Attempt int
SentAt time.Time
}
func eventID(namespace uuid.UUID, identity EventIdentity) uuid.UUID {
name := url.Values{
"aggregate_id": []string{identity.AggregateID},
"kind": []string{identity.Kind},
"sequence": []string{strconv.FormatUint(identity.Sequence, 10)},
}.Encode()
return uuid.NewSHA1(namespace, []byte(name))
}
func (e Event) ID(namespace uuid.UUID) uuid.UUID {
return eventID(namespace, e.Identity)
}
I would generate the namespace once for the application or event family and keep it in configuration. The name encoding also needs to remain stable and unambiguous. Attempt and SentAt describe delivery rather than the event itself, so they are excluded from the identity.
identity fields:
aggregate ID
kind
sequence
|
v
canonical name
|
v
UUIDv5(namespace, canonical name)
|
v
stable event ID
excluded:
attempt
sent time
retry 1, retry 2, ... retry N
|
v
same identity -> same event ID
The property varies the delivery metadata while keeping the event identity fixed:
func TestEventIDIsStableAcrossRetries(t *testing.T) {
namespace := uuid.MustParse("2a32e1ac-f6cd-4253-b052-f5f78f523bd0")
rapid.Check(t, func(t *rapid.T) {
identity := EventIdentity{
AggregateID: rapid.String().Draw(t, "aggregate id"),
Kind: rapid.SampledFrom(
[]string{"created", "updated", "cancelled"},
).Draw(t, "kind"),
Sequence: uint64(rapid.Int64Range(0, 1_000_000).
Draw(t, "sequence")),
}
first := Event{
Identity: identity,
Attempt: 1,
SentAt: time.Unix(0, 0).UTC(),
}
retry := first
retry.Attempt = rapid.IntRange(2, 100).Draw(t, "retry attempt")
retry.SentAt = time.Unix(int64(retry.Attempt), 0).UTC()
if first.ID(namespace) != retry.ID(namespace) {
t.Fatalf("event ID changed across retry")
}
})
}
The test fails if a refactor starts hashing the whole Event, including its delivery metadata. UUIDv5 is hash-based, so collisions between distinct identities remain possible. Here it provides a stable identifier. Authentication and confidentiality require separate mechanisms.
For sorting, I can check that the output is ordered and contains the same elements as the input. An optimized algorithm, query planner or compatibility layer can be compared with a slower reference implementation that is easier to trust.
For a stateful system, the generator can produce a sequence of commands and the test can check invariants after every step:
- processing the same idempotency key twice does not apply the effect twice
- balances and inventory never cross forbidden boundaries
- replicas converge after receiving the same set of events in different orders
- retry, timeout and failover paths preserve committed state
Concurrent and distributed failures often depend on an unexpected sequence. Shrinking can reduce it to the few operations needed to reproduce the problem.
For a parser or validator, I usually begin with the requirement that arbitrary input must not cause a panic. Other properties can check that rejected input leaves state unchanged or that formatting followed by parsing preserves meaning. The common requirement is an observable invariant and a way to generate inputs.
6. PBT and Go fuzzing
Go’s built-in coverage-guided fuzzing works well for decoders that accept untrusted bytes because it searches for inputs that reach new paths or trigger failures.
For property tests, I usually generate valid domain values instead. A generator can build a Record, a state transition or an operation sequence directly, and Rapid will shrink a failing value.
I generally use:
- example-based tests for named cases, expected errors and readable regression tests
- property-based tests for invariants over structured inputs
- fuzzing for coverage-guided exploration of parsers and other untrusted-input boundaries
7. Adding PBT to an existing Go package
When adding PBT to a Go package:
- Keep example tests for documentation and named cases.
- Write the property in plain language before writing a generator.
- Define equality instead of defaulting to
reflect.DeepEqual. - Generate the supported domain, including empty values and boundaries.
- Classify each counterexample as a product bug, generator bug or unclear contract, then keep important cases as regression tests.
- Run fast properties with unit tests and larger runs in CI or focused investigations.
PBT samples the values produced by its generator, so a poor generator can miss the same cases as a poor table test. I keep the hand-written examples and add useful counterexamples as regression tests. The generated cases then cover more of the domain than I would choose manually.