Property-Based Testing
Find edge cases your unit tests miss with property-based testing. Covers generators, shrinking, stateful testing, and integration with conventional test suites.
Property-based testing inverts the testing model. Instead of writing specific inputs and expected outputs, you describe properties that should always hold and let the framework generate hundreds of random inputs to find violations. It discovers edge cases humans never think of — empty strings, integer overflows, Unicode sequences, negative numbers, lists with duplicate elements.
Example-Based vs Property-Based
Example-based:
test("sort returns sorted list"):
expect(sort([3,1,2])).toEqual([1,2,3])
expect(sort([5,4])).toEqual([4,5])
expect(sort([])).toEqual([])
// Did you test negative numbers? Duplicates? Very long lists?
Property-based:
property("sort returns list of same length"):
for any list of integers:
expect(sort(list).length).toBe(list.length)
property("sort returns elements in ascending order"):
for any list of integers:
sorted = sort(list)
for each consecutive pair (a, b) in sorted:
expect(a <= b).toBe(true)
property("sort preserves all elements"):
for any list of integers:
expect(sort(list)).toContainExactly(list)
Properties to Test
| Property Type | Example | Applicable To |
|---|---|---|
| Roundtrip | decode(encode(x)) === x | Serialization, encryption, compression |
| Idempotence | f(f(x)) === f(x) | Cache operations, normalization, formatting |
| Invariant | sorted list length === input list length | Any transformation |
| Commutativity | f(a, b) === f(b, a) | Set operations, mathematical functions |
| Associativity | f(f(a, b), c) === f(a, f(b, c)) | String concat, set union |
| Oracle | fast_impl(x) === slow_reference(x) | Optimized algorithms vs known-correct |
| No-crash | f(random_input) does not throw | Input validation, API handlers |
Tool Comparison
| Tool | Language | Generators | Shrinking | Best For |
|---|---|---|---|---|
| fast-check | JavaScript/TypeScript | Rich built-in | Automatic | JS/TS projects |
| Hypothesis | Python | Extensive | Automatic | Python (industry standard) |
| QuickCheck | Haskell | Original PBT library | Automatic | Haskell, inspired all others |
| jqwik | Java/JVM | JUnit 5 integration | Automatic | Java projects |
| proptest | Rust | Derive macros | Automatic | Rust projects |
| ScalaCheck | Scala | Comprehensive | Automatic | Scala/JVM |
| Rapid | Go | Simple API | Automatic | Go projects |
Generators
| Generator | Produces | Use Case |
|---|---|---|
| integer() | Random integers in range | Numerical operations |
| string() | Random strings (ASCII, Unicode, empty) | Text processing |
| array() | Random-length arrays of values | Collection operations |
| record() | Objects with typed fields | Domain entity testing |
| oneof() | One of several generators | Union types, enums |
| constant() | Fixed value | Control values, sentinels |
| tuple() | Fixed-length arrays of generators | Multi-argument functions |
Shrinking
When a property violation is found, the framework shrinks the failing input to the minimal reproduction case:
Property violation found with input: [847, -23, 0, 445, 12, -1, 99, 0, 7]
Shrinking...
[847, -23, 0, 445, 12, -1, 99, 0, 7] → fails
[847, -23, 0, 445] → fails
[-23, 0, 445] → fails
[-23, 0] → fails
[-1, 0] → fails
[0, -1] → passes
[-1, 0] → MINIMAL FAILING CASE
Result: Property fails for input [-1, 0]
Stateful Testing
For testing systems with state (databases, caches, state machines), property-based testing generates sequences of operations and verifies invariants after each step:
Model: SimpleMap (in-memory reference implementation)
System Under Test: CacheService
Generated command sequence:
1. put("key1", "value1")
2. get("key1") → should return "value1"
3. put("key2", "value2")
4. delete("key1")
5. get("key1") → should return null
6. get("key2") → should return "value2"
After each command:
Verify SUT state matches Model state
Integration with Conventional Tests
| Strategy | Approach |
|---|---|
| Alongside unit tests | Property tests in the same test file, tagged |
| Separate directory | tests/property/ for heavy generators |
| CI configuration | Run with fewer iterations on PR, more on nightly |
| Seed management | Store failing seeds for deterministic regression tests |
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Properties that are too weak | Only testing “doesn’t crash” — misses logic bugs | Write properties that verify specific behaviors |
| Too many iterations on CI | Slow test runs (100K iterations per property) | Use 100-1,000 on PR, 10,000+ on nightly |
| Not saving failing seeds | Can’t reproduce failures deterministically | Save seed in test, commit to version control |
| Custom generators too complex | Generator itself has bugs | Test generators separately, keep them simple |
| Ignoring shrunk output | Using the original large input to debug | Always debug with the shrunk minimal case |
Checklist
- Property-based testing framework added to project
- Core algorithms have property tests (sort, search, transform, encode/decode)
- Roundtrip properties tested for all serialization (JSON, protobuf, etc.)
- Stateful tests for cache, database, and state machine components
- Generators cover edge cases (empty, null, Unicode, negative, MAX_INT)
- CI runs PBT with moderate iterations (1,000 per property on PR)
- Nightly runs PBT with high iterations (10,000+ per property)
- Failing seeds committed as regression tests
- Team trained on identifying useful properties
:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For quality engineering consulting, visit garnetgrid.com. :::