SOFTWARE / SYSTEMS / AIEngineering news. Technical depth.
Architecture / 4 MIN READ

Turn domain invariants into property-based tests

Use conservation, round-trip, ordering, idempotence, and simple state models to write property-based tests that find small, reproducible counterexamples.

Property-based tests work best when an application has rules that should hold across many inputs and operation sequences. Write the invariant first, build generators that produce valid and edge-case domain values, and let the framework shrink a failure into a small counterexample. The goal is a better specification, not a larger random input count.

Choose invariants with independent meaning

Conservation is strong: splitting an invoice total across line items must preserve the total in the smallest currency unit. Round-trip properties suit parsers and serializers: decode(encode(value)) should equal the canonical value. Ordering properties check that output is sorted, contains the same multiset, and is idempotent under a second sort. Idempotence fits retry handlers: applying the same command with the same key twice has the same durable effect as once.

Avoid restating the implementation. A property that compares a function with a copy of its algorithm can reproduce the same bug. Prefer an algebraic law, a simpler oracle, a domain rule, or two independent paths. For money allocation, assert conservation, nonnegative shares, bounded rounding difference, and permutation behavior rather than rebuilding the allocator inside the test.

The Hypothesis documentation describes property-based testing for Python as writing tests that should pass for all inputs in a described range, while the library chooses examples to check, including edge cases you might not have thought about. The fast-check getting-started guide shows the same pattern in JavaScript: define properties from arbitraries, run them multiple times, and let the runner reduce failures to smaller, more readable cases. When a failure happens, keep the reported counterexample and any replay information your tool prints in CI output so the case is easy to rerun.

Generate the domain, not malformed noise

Build small composable generators: non-empty account IDs, currency amounts within supported bounds, date intervals with start before end, and records whose internal fields agree. Generate invalid inputs separately when testing validation. Too many filtered values slow exploration and distort the distribution; construct valid values directly from smaller components.

Bias deliberately toward boundaries: zero, one, maximum, duplicate identifiers, equal timestamps, empty collections, and transitions around expiry. Mix ordinary values with rare shapes rather than assuming uniform randomness will discover meaningful cases. If shrinking produces a case your domain considers invalid, first inspect whether the generator encodes the domain correctly before drawing conclusions.

For a workflow state machine, generate commands such as create, approve, cancel, and retry. Keep a simple model of allowed states, execute commands against the system, and compare visible state after every step. In practice, you can use preconditions to avoid impossible commands while still trying conflicting and repeated sequences. This finds bugs that no single input property can reach.

Treat shrinking as diagnostic evidence

Aim for shrink behavior that keeps generated values meaningful to the domain while simplifying failures. Domain-specific types may need custom shrink behavior or generator structure that naturally shrinks well. Read the minimal counterexample as a missing rule: two equal timestamps break stable ordering; a one-cent remainder disappears; retry after cancellation resurrects a job. Turn the discovered case into a named regression test when it documents an important boundary.

Control nondeterminism. Record the failing example and any replay details the tool reports. Do not simply raise run counts until CI becomes slow. A practical rollout is to run fast properties on every change and schedule deeper stateful runs separately.

Start with one function whose specification contains “for every,” “always,” or “never.” Write two independent invariants and a generator that includes its boundary values. Run until each deliberately planted defect produces a small counterexample; that validates the test design before trusting it against real code. Then pick one parser, allocator, or retry handler in your codebase this week and add those two invariants plus the boundary-aware generator.

Review what your properties are actually exploring

A property that passes can still explore a narrow corner. Inspect failing cases, sample generated values during development, and check whether your boundary cases really appear often enough to matter. Add lightweight categories such as empty, singleton, duplicate, boundary, and typical in your own test review notes so generator drift becomes visible when domain types evolve. Seed replay belongs in the failure report, but the property should also pass under fresh runs after the defect is fixed.

Reviewed September 2026.

SOURCES & REVIEW

Sources are linked throughout this guide. Product capabilities can change; consult the linked documentation for your deployment.

Read our editorial approach ↗