Property-Based Testing: Hypothesis and fast-check in Practice
Advanced3 minInnovialabs editorial team
In short
Instead of a few examples you describe a property that holds for any input, and Hypothesis or fast-check generate hundreds of cases and shrink any failure down to a minimal example.
Properties instead of examples
A regular test checks an example: "for 2 and 3 the sum is 5". A property-based test checks a statement that must hold for any input: "the result of sorting is ordered and contains the same elements".
The library generates many inputs itself — empty lists, negative numbers, Unicode, very long strings — and looks for one that breaks the property. This complements regular tests well: examples pin down known behaviour, properties search for what you did not think of.
- Python — Hypothesis.
- JavaScript and TypeScript — fast-check.
Generators and shrinking
A generator (a strategy in Hypothesis, an arbitrary in fast-check) describes what data to produce: st.integers(), st.lists(st.text()), fc.array(fc.integer()), fc.record({...}). Generators compose, so you can build an order or a user object.
Shrinking is the key convenience. When a failure is found on a huge random input, the library simplifies it step by step until it gets a minimal example that still fails. Instead of a list of 80 random numbers, you see [0, -1].
Which properties to check
| Property | Example |
|---|---|
| Round-trip | decode(encode(x)) == x, parse(format(x)) == x |
| Invariant | after sorting, the length is the same and items are ordered |
| Idempotence | normalize(normalize(x)) == normalize(x) |
| Oracle | the fast function matches a simple slow version |
| Does not crash | the function never throws on any valid input |
Start with round-trip and "does not crash": they are the easiest to formulate.
Hypothesis: money that does not split evenly
A function splits an amount in cents into n parts:
from hypothesis import given, strategies as st
def split_amount(total, n):
part = round(total / n)
return [part] * n
@given(st.integers(min_value=0, max_value=10**9),
st.integers(min_value=1, max_value=100))
def test_parts_sum_to_total(total, n):
assert sum(split_amount(total, n)) == total
Examples like (100, 4) pass, but Hypothesis quickly finds a violation and shrinks it to something like total=1, n=2: the parts do not add up to the original amount. The fix is to distribute the remainder:
def split_amount(total, n):
q, r = divmod(total, n)
return [q + 1] * r + [q] * (n - r)
fast-check: hand-rolled serialization
A simple function turns an object into a key=value&... string:
import fc from "fast-check";
const stringify = (o: Record<string, string>) =>
Object.entries(o).map(([k, v]) => `${k}=${v}`).join("&");
fc.assert(
fc.property(fc.dictionary(fc.string(), fc.string()), (obj) => {
expect(parse(stringify(obj))).toEqual(obj);
})
);
fast-check finds a key or value containing & or =, and the round-trip breaks. A manual test with { name: "Ali" } would never have shown this. The fix is to escape values, for example with encodeURIComponent.
How to adopt it without pain
- Pick pure functions: parsers, formatters, calculations, data transformations.
- Write one property and constrain the generator to the real domain (ranges, lengths).
- Save each counterexample as a regular example test: that is your regression test.
- Record the seed when CI fails: both libraries tell you how to reproduce the run.
- Do not duplicate the implementation inside the property, or the test will repeat the same bug.
Common mistakes
- Generators that are too broad, so filters discard most cases.
- A property that is trivially true and checks nothing.
- Slow tests: heavy database work inside every iteration.