Skip to main content

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:

  1. Generate many random integers
  2. Run the test with each one
  3. 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 = 0 causes 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

  1. Parsers: parse(format(x)) == x
  2. Serialization: deserialize(serialize(x)) == x
  3. Data structures: Invariants always hold
  4. Mathematical functions: Known properties
  5. String manipulation: Reversibility, idempotence

Less suitable

  1. UI tests: Hard to define properties
  2. Integration tests: Too slow/complex
  3. 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

  1. Properties describe what should always be true
  2. Hypothesis generates many test cases automatically
  3. It finds edge cases you wouldn't think of
  4. Combine with example-based tests for best coverage
  5. 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.