Introduction
Traditional example-based tests verify specific input-output pairs: assert add(2, 2) == 4. Property-based testing flips this approach — instead of choosing examples, you describe properties that must hold for all valid inputs, and a framework generates hundreds or thousands of random inputs to verify them.
Key Concepts
- Property: An invariant that should hold true regardless of input (e.g., reversing a list twice yields the original list).
- Strategy: A description of how to generate random test data (integers, strings, lists, custom objects).
- Shrinking: When a failing case is found, the framework automatically simplifies it to the minimal reproducing example.
- Stateful testing: Testing sequences of operations against a model to find state-dependent bugs.
Real World Context
Property-based testing excels at finding edge cases humans overlook: empty collections, negative numbers, Unicode surrogates, integer overflow boundaries. Companies like Volvo and Ericsson use it extensively for safety-critical systems. The Hypothesis library for Python has found bugs in major projects including NumPy, Pandas, and the CPython standard library itself.
Deep Dive
The Hypothesis Library
Hypothesis is the standard property-based testing library for Python. It integrates with pytest and unittest.
pythonfrom hypothesis import given, settings, assume from hypothesis import strategies as st @given(st.lists(st.integers())) def test_sorted_is_idempotent(xs): """Sorting a sorted list should not change it.""" assert sorted(sorted(xs)) == sorted(xs) @given(st.integers(), st.integers()) def test_addition_is_commutative(a, b): """Addition order should not matter.""" assert a + b == b + a @given(st.text()) def test_encode_decode_roundtrip(s): """Encoding then decoding UTF-8 should return the original.""" assert s.encode('utf-8').decode('utf-8') == s
Custom Strategies
pythonfrom hypothesis import strategies as st from dataclasses import dataclass @dataclass class User: name: str age: int # Build a strategy that generates User objects users = st.builds( User, name=st.text(min_size=1, max_size=50), age=st.integers(min_value=0, max_value=150) ) @given(users) def test_user_display_name(user): display = f"{user.name} (age {user.age})" assert user.name in display assert str(user.age) in display
Shrinking in Action
When Hypothesis finds a failing input like [1073741824, -3, 0, 99, 42], it automatically shrinks it to the simplest failing case, such as [1073741824]. This makes debugging far easier than staring at a random 50-element list.
Settings and Profiles
pythonfrom hypothesis import settings, Phase # Run more examples in CI @settings(max_examples=1000) @given(st.integers()) def test_thorough(n): assert abs(n) >= 0 # Skip shrinking for faster feedback @settings(phases=[Phase.explicit, Phase.generate]) @given(st.floats(allow_nan=False)) def test_quick(x): assert x * 0 == 0
Common Pitfalls
- Testing implementation, not properties:
assert my_sort(xs) == sorted(xs)just re-tests Python's sort. Instead, test properties like "output is sorted" and "output is a permutation of input". - Overly strict
assume()calls: Usingassume()to filter too many inputs makes tests slow and reduces coverage. Use targeted strategies instead. - Non-deterministic tests: If your code uses
randomordatetime.now(), inject those as dependencies so Hypothesis controls them. - Ignoring the Hypothesis database: Hypothesis remembers previously failing examples in
.hypothesis/. Commit this directory or your CI will lose regression coverage.
Best Practices
- Start by identifying universal properties: idempotency, commutativity, round-trip encoding, invariants.
- Use
st.from_type()to auto-generate data from type hints. - Combine property-based tests with a few explicit example-based tests for documentation.
- Set
@settings(max_examples=500)in CI for thorough testing. - Use
@example(...)to pin known edge cases alongside generated ones.
Summary
Property-based testing complements example-based testing by exploring the input space automatically. Define properties, let Hypothesis generate inputs, and benefit from automatic shrinking when failures occur. It is particularly valuable for algorithmic code, serialization, and any function with clear mathematical properties.
Code Examples
from hypothesis import given, example
from hypothesis import strategies as st
def encode(text: str) -> list[int]:
"""Run-length encode a string."""
if not text:
return []
result = []
count = 1
for i in range(1, len(text)):
if text[i] == text[i - 1]:
count += 1
else:
result.extend([ord(text[i - 1]), count])
count = 1
result.extend([ord(text[-1]), count])
return result
def decode(data: list[int]) -> str:
"""Decode run-length encoded data."""
return ''.join(chr(data[i]) * data[i + 1] for i in range(0, len(data), 2))
# Property: encode then decode is a round-trip
@given(st.text(alphabet=st.characters(whitelist_categories=('L', 'N')), min_size=0, max_size=100))
@example('')
@example('aaa')
def test_roundtrip(text):
assert decode(encode(text)) == textfrom hypothesis import given
from hypothesis import strategies as st
def my_sort(xs: list[int]) -> list[int]:
return sorted(xs)
@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
assert len(my_sort(xs)) == len(xs)
@given(st.lists(st.integers()))
def test_sort_is_ordered(xs):
result = my_sort(xs)
for i in range(len(result) - 1):
assert result[i] <= result[i + 1]
@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs):
assert my_sort(my_sort(xs)) == my_sort(xs)
@given(st.lists(st.integers()))
def test_sort_preserves_elements(xs):
from collections import Counter
assert Counter(my_sort(xs)) == Counter(xs)