Property Based Testing
In this chapter, we'll explore property-based testing - a powerful technique that generates test cases automatically. We'll cover:
- What is property-based testing?
- Introduction to Hypothesis
- Defining strategies for test data
- Finding edge cases automatically
- Combining with example-based tests
- When property-based testing shines
What is property-based testing?
Traditional (example-based) testing:
def test_reverse_string():
assert reverse("hello") == "olleh"
assert reverse("") == ""
assert reverse("a") == "a"
Property-based testing:
from hypothesis import given
from hypothesis import strategies as st
@given(st.text())
def test_reverse_twice_is_original(s):
assert reverse(reverse(s)) == s
Instead of specific examples, we define properties that should hold for any input.
Introduction to Hypothesis
Hypothesis is Python's premier property-based testing library:
pip install hypothesis
Basic usage
from hypothesis import given
from hypothesis import strategies as st
@given(st.integers())
def test_integer_addition_is_commutative(x):
# Hypothesis generates many integers and tests this property
assert x + 0 == x
Hypothesis will:
- Generate many random integers
- Run the test with each one
- Report any failures with a minimal example
Strategies
Strategies define how to generate test data:
Built-in strategies
from hypothesis import strategies as st
st.integers() # Any integer
st.integers(min_value=0) # Non-negative integers
st.integers(min_value=1, max_value=100) # Range
st.floats() # Any float
st.floats(allow_nan=False) # No NaN values
st.text() # Any Unicode string
st.text(min_size=1, max_size=10) # Length limits
st.text(alphabet="abc") # Limited characters
st.booleans() # True or False
st.none() # Always None
st.lists(st.integers()) # List of integers
st.lists(st.text(), min_size=1) # Non-empty list of strings
st.tuples(st.integers(), st.text()) # (int, str) tuples
st.dictionaries(st.text(), st.integers()) # Dict[str, int]
Combining strategies
# Either an integer or None
st.integers() | st.none()
# One of specific values
st.sampled_from(["red", "green", "blue"])
# Complex structure
st.fixed_dictionaries({
"name": st.text(min_size=1),
"age": st.integers(min_value=0, max_value=150),
"email": st.emails(),
})
Testing properties
Reversibility
@given(st.text())
def test_encode_decode_roundtrip(s):
encoded = base64_encode(s)
decoded = base64_decode(encoded)
assert decoded == s
Invariants
@given(st.lists(st.integers()))
def test_sorted_list_is_ordered(lst):
result = sorted(lst)
# Property: sorted list has same elements
assert sorted(result) == sorted(lst)
# Property: each element <= next element
for i in range(len(result) - 1):
assert result[i] <= result[i + 1]
Idempotence
@given(st.text())
def test_normalize_is_idempotent(s):
once = normalize(s)
twice = normalize(normalize(s))
assert once == twice
Commutativity
@given(st.integers(), st.integers())
def test_addition_is_commutative(a, b):
assert a + b == b + a
Finding edge cases
Hypothesis excels at finding edge cases you might miss:
from hypothesis import given
from hypothesis import strategies as st
def divide(a, b):
return a / b
@given(st.integers(), st.integers())
def test_divide(a, b):
result = divide(a, b)
assert result * b == a
Hypothesis will find:
b = 0causes ZeroDivisionError- Very large numbers may cause overflow
- Edge cases with negative numbers
Fixing with assume
from hypothesis import given, assume
from hypothesis import strategies as st
@given(st.integers(), st.integers())
def test_divide(a, b):
assume(b != 0) # Skip when b is 0
result = divide(a, b)
# Note: floating point may not be exact
assert abs(result * b - a) < 0.0001
Custom strategies
Using @composite
from hypothesis import given
from hypothesis import strategies as st
from hypothesis.strategies import composite
@composite
def valid_user(draw):
name = draw(st.text(min_size=1, max_size=50))
age = draw(st.integers(min_value=0, max_value=150))
email = draw(st.emails())
return {"name": name, "age": age, "email": email}
@given(valid_user())
def test_user_creation(user_data):
user = User(**user_data)
assert user.name == user_data["name"]
assert user.age == user_data["age"]
Filtering strategies
# Even integers only
even_integers = st.integers().filter(lambda x: x % 2 == 0)
# Or use assume in the test
@given(st.integers())
def test_even_numbers(n):
assume(n % 2 == 0)
assert n // 2 * 2 == n
Mapping strategies
# Generate positive integers as strings
positive_int_strings = st.integers(min_value=1).map(str)
@given(positive_int_strings)
def test_parse_positive(s):
assert int(s) > 0
Hypothesis settings
Configure Hypothesis behavior:
from hypothesis import given, settings
from hypothesis import strategies as st
@settings(max_examples=500) # Run more examples
@given(st.integers())
def test_with_more_examples(n):
assert n == n
@settings(deadline=None) # No time limit per example
@given(st.text())
def test_slow_operation(s):
# Slow operation that might timeout
process(s)
Profile settings
# conftest.py
from hypothesis import settings
# Faster for CI
settings.register_profile("ci", max_examples=100)
# Thorough for local
settings.register_profile("dev", max_examples=1000)
# Use with: pytest --hypothesis-profile=ci
Combining with example-based tests
Use @example for specific cases you always want tested:
from hypothesis import given, example
from hypothesis import strategies as st
@given(st.text())
@example("") # Always test empty string
@example("hello") # Always test this specific case
@example("🎉🎊") # Always test emoji
def test_process_text(s):
result = process(s)
assert isinstance(result, str)
Stateful testing
Test sequences of operations:
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
from hypothesis import strategies as st
class SetMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.model = set() # What we expect
self.actual = MySet() # What we're testing
@rule(value=st.integers())
def add(self, value):
self.model.add(value)
self.actual.add(value)
@rule(value=st.integers())
def remove(self, value):
self.model.discard(value)
self.actual.discard(value)
@invariant()
def sets_match(self):
assert set(self.actual) == self.model
TestMySet = SetMachine.TestCase
Real-world example: JSON serialization
from hypothesis import given
from hypothesis import strategies as st
import json
# Strategy for JSON-serializable data
json_data = st.recursive(
st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(),
lambda children: st.lists(children) | st.dictionaries(st.text(), children),
max_leaves=20
)
@given(json_data)
def test_json_roundtrip(data):
serialized = json.dumps(data)
deserialized = json.loads(serialized)
assert deserialized == data
When property-based testing shines
Good use cases
- Parsers:
parse(format(x)) == x - Serialization:
deserialize(serialize(x)) == x - Data structures: Invariants always hold
- Mathematical functions: Known properties
- String manipulation: Reversibility, idempotence
Less suitable
- UI tests: Hard to define properties
- Integration tests: Too slow/complex
- Tests that need specific values: Use example-based
Best practices
1. Start simple
# Start with basic properties
@given(st.lists(st.integers()))
def test_length_preserved(lst):
result = my_sort(lst)
assert len(result) == len(lst)
2. Combine with example-based tests
# Example-based for specific scenarios
def test_sort_empty():
assert my_sort([]) == []
# Property-based for general correctness
@given(st.lists(st.integers()))
def test_sort_is_sorted(lst):
result = my_sort(lst)
assert all(result[i] <= result[i+1] for i in range(len(result)-1))
3. Document the property
@given(st.text())
def test_strip_removes_whitespace(s):
"""
Property: strip() removes leading/trailing whitespace
and the result contains no leading/trailing whitespace.
"""
result = s.strip()
assert not result.startswith((" ", "\t", "\n"))
assert not result.endswith((" ", "\t", "\n"))
Wrapping up
We've covered:
- Property-based testing - Define properties, not examples
- Hypothesis - Python's property-based testing library
- Strategies - Generate test data
- Finding edge cases - Hypothesis finds bugs you'd miss
- Custom strategies - Build complex test data
- Stateful testing - Test sequences of operations
Key takeaways
- Properties describe what should always be true
- Hypothesis generates many test cases automatically
- It finds edge cases you wouldn't think of
- Combine with example-based tests for best coverage
- Great for serialization, parsing, and data structures
Property-based testing is a powerful addition to your testing toolkit. It complements example-based testing by exploring the input space more thoroughly than you ever could manually.