Property-Based Testing Design
Purpose
Guide the design and implementation of property-based tests for a given module, identifying invariants, selecting generators, and producing executable test code in Go (rapid), Python (Hypothesis), or TypeScript (fast-check).
When to Use
- •Building or reviewing code with domain invariants (financial, mathematical, serialization)
- •Adding tests to parsers, validators, encoders, or state machines
- •Strengthening test suites that rely solely on example-based tests
- •Verifying round-trip properties (encode/decode, serialize/deserialize)
Steps
Step 1: Identify the Target Module
Read the source file(s) under test. Identify:
- •Public functions and their signatures
- •Input domains (types, ranges, constraints)
- •Output guarantees (postconditions)
- •Side effects (mutations, I/O)
Step 2: Enumerate Properties
For each function, identify which property patterns apply:
| Pattern | Question to Ask | Example |
|---|---|---|
| Invariant | What must always be true after this operation? | Balance >= 0 after any withdrawal |
| Idempotency | Does applying this twice give the same result? | deduplicate(deduplicate(list)) == deduplicate(list) |
| Commutativity | Does order matter? | merge(a, b) == merge(b, a) |
| Round-trip | Can I reverse this operation? | decode(encode(x)) == x |
| Oracle | Is there a simpler reference implementation? | Optimized sort vs. naive sort |
| Metamorphic | How does output change when input changes predictably? | Adding an element to a sorted list keeps it sorted |
| Zero-sum | Do related operations cancel out? | Transfer: source_loss == dest_gain |
Document each identified property with:
- •Property name: e.g.,
balance_never_negative - •Precondition: e.g.,
amount > 0 AND amount <= balance - •Postcondition: e.g.,
new_balance == old_balance - amount AND new_balance >= 0 - •Edge cases: e.g.,
amount == balance (zero balance after),amount == 0.01 (minimum)
Step 3: Design Generators (Strategies)
For each input type, design a generator that produces valid and interesting values:
Go (rapid):
amount := rapid.Float64Range(0.01, 1_000_000).Draw(t, "amount")
accountType := rapid.SampledFrom([]string{"checking", "savings"}).Draw(t, "type")
Python (Hypothesis):
amount = st.decimals(min_value=Decimal("0.01"), max_value=Decimal("1000000"), places=2)
account_type = st.sampled_from(["checking", "savings"])
TypeScript (fast-check):
const amount = fc.double({ min: 0.01, max: 1_000_000, noNaN: true });
const accountType = fc.constantFrom('checking', 'savings');
For composite types, build composite generators that produce valid domain objects.
Step 4: Implement Property Tests
Write one test per property. Follow the framework conventions:
- •Go:
func TestPropertyName(t *testing.T) { rapid.Check(t, func(t *rapid.T) { ... }) } - •Python:
@given(...) def test_property_name(...): ... - •TypeScript:
fc.assert(fc.property(..., (...) => { ... }))
Step 5: Add Stateful Tests (If Applicable)
If the module has state transitions (e.g., account lifecycle, order processing):
- •Model the state machine with a simple reference implementation
- •Define operations as rules/commands
- •Define invariants that must hold after every operation
- •Let the framework generate random sequences of operations
Step 6: Configure Settings
- •Development: 100 examples (fast feedback)
- •CI: 500-1000 examples (thorough coverage)
- •Nightly: 5000+ examples (deep exploration)
- •Set
deadline=Nonefor slow operations (database, network)
Step 7: Review and Refine
After running tests:
- •Check for flaky failures (tighten generators if ranges are too broad)
- •Examine shrunk failing examples (the framework minimizes them)
- •Add the shrunk example as an explicit example-based test for regression
- •Verify edge cases are covered (empty inputs, max values, boundary values)
Output Format
Produce:
- •A test file with clearly named property tests
- •Generator/strategy definitions (reusable across tests)
- •Comments explaining each property and why it matters
- •Settings configuration for dev vs. CI profiles