blog

0x0019 - Property-Based Testing in Go: From Round Trips to System Invariants

Using generated inputs and shrinking to test serialization, deterministic event IDs, and invariants across state transitions.

I’ve encountered types that serialized and deserialized without error, yet came back different. Nothing crashed and the serialized data looked valid. The round trip had changed the value.

The serializer passed the examples I had written but violated an invariant for a value I had not chosen. Example-based tests are still useful because they document specific cases well. Property-based testing (PBT) states what must hold across a domain, then generates inputs and shrinks failures to smaller counterexamples.

A property can say that a round trip preserves meaning, a retry retains the same event ID, a sort preserves every input element or a state machine never reaches an invalid state. Serialization provides a compact place to start:

deserialize(serialize(value)) == value

The == hides an important question: what does equality mean here?

1. A passing example can hide the edge cases

Consider a small Go type that we want to encode as JSON:

package record

import "time"

type Record struct {
	ID        string    `json:"id"`
	Labels    []string  `json:"labels,omitempty"`
	CreatedAt time.Time `json:"created_at"`
}

A conventional round-trip test might look like this:

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)
	}
}

The test passes with non-empty labels and a UTC timestamp. More table entries help, but I still have to anticipate each interesting case.

2. Generate values instead of choosing them

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 ./...

We can generate records instead of choosing them by hand:

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 property is close to the example test:

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 and shrinks a case whose Labels field 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, so deserialization leaves Labels as nil. reflect.DeepEqual distinguishes nil from non-nil empty slices. The serializer did what I had asked it to do; the property exposed an ambiguity in the contract.

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. Define equality before testing it

Serialization tests commonly need three kinds of equality:

  1. Representation equality: every field and representation detail must be preserved.
  2. Semantic equality: the fields that define application meaning must match.
  3. Canonical encoding: equivalent values must produce one stable byte representation.

Suppose the application treats nil and empty labels as equivalent and cares about the instant represented by CreatedAt, not its 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; serialization can discard a monotonic clock reading or preserve an offset without its location.

The property can now test the behavior the application actually needs:

if !original.Equal(decoded) {
	t.Fatalf(
		"round trip changed the record:\noriginal: %#v\ndecoded:  %#v\njson: %s",
		original,
		decoded,
		encoded,
	)
}

If nil and empty labels have different meanings, this equality check would hide a bug. The encoding contract should instead remove omitempty, use a custom representation or reject lossy values. I need to make that decision in the contract, not in the test.

4. Choose the right round-trip properties

Once equality is clear, useful properties include:

semantic round trip: 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.

Properties can also target the parts of a type most likely to lose information:

  • 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. Apply properties beyond serialization

The same approach works wherever behavior can be expressed 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

An event delivery retry should keep the original event ID. A new random UUID would make it look like a different event.

UUID version 5 derives an ID from a namespace and a name; equal inputs produce equal IDs. 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)
}

Generate the namespace once and store it as configuration. Keep the name encoding stable and unambiguous. Attempt and SentAt describe delivery, so they are excluded from 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

We can express that decision as a property:

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")
		}
	})
}

This fails if a refactor hashes the whole Event, including delivery metadata. UUIDv5 is hash-based, so distinct identities are not guaranteed to produce distinct UUIDs. Use it for stable identification, not authentication or secrecy.

A sort must produce ordered output containing the same elements as its input. An optimized algorithm, query planner or compatibility layer can be checked against a slower reference implementation that is easier to trust.

For stateful systems, generate command sequences and 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 parsers and validators, arbitrary input must not panic, rejected input must not mutate state, and formatting followed by parsing should preserve meaning. PBT needs an observable invariant and a generator, not necessarily a pure function or round trip.

6. Use property-based testing and fuzzing together

Go’s built-in coverage-guided fuzzing finds inputs that reach unusual branches, trigger panics or expose security problems. I use it for decoders that accept untrusted bytes.

PBT starts with an invariant over valid domain values. Its generators can create a Record, state transition or operation sequence directly, then shrink failures.

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. Put property-based testing into practice

When adding PBT to a Go package:

  1. Keep example tests for documentation and named cases.
  2. Write the property in plain language before writing a generator.
  3. Define equality instead of defaulting to reflect.DeepEqual.
  4. Generate the supported domain, including empty values and boundaries.
  5. Classify each counterexample as a product bug, generator bug or unclear contract, then keep important cases as regression tests.
  6. Run fast properties with unit tests and larger runs in CI or focused investigations.

PBT does not prove correctness. It samples the domain described by its generator, and a weak generator can miss the same cases as a weak table test. I’ve found it most useful when a precise invariant, representative inputs and a small counterexample reveal an assumption that the example tests never challenged.