Skip to content
Codeloom
Testing

Property-Based Testing with Hypothesis in Python

Learn property-based testing with Hypothesis to find edge cases your example-based tests miss and write more robust Python code.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What property-based testing is and why it matters
  • How to write Hypothesis strategies and properties
  • Shrinking: how Hypothesis finds minimal failing examples
  • Combining property-based tests with example-based tests

Prerequisites

  • Basic Python knowledge
  • Familiarity with pytest

The Problem with Example-Based Tests

Traditional unit tests follow a pattern: you pick specific inputs, run the function, and assert the output matches your expectation. This works, but it depends entirely on your ability to imagine every important case. If you forget to test a negative number, an empty string, or a boundary value, the bug sits quietly until production traffic finds it for you.

Property-based testing flips the approach. Instead of choosing examples, you describe properties that should always hold, and the framework generates hundreds of random inputs to try to break them. If it finds a failing case, it shrinks it to the smallest possible reproducing input so you can debug quickly.

Installing Hypothesis

Hypothesis is the leading property-based testing library for Python. Install it alongside pytest:

pip install hypothesis pytest

Hypothesis integrates directly with pytest. You write tests as normal functions, decorated with @given.

Your First Property Test

Suppose you have a function that reverses a list:

def reverse_list(xs):
    return xs[::-1]

What properties should always hold for reverse?

  1. Reversing twice gives back the original list.
  2. The length does not change.
  3. Every element in the original appears in the reversed version.

Here is how you express those with Hypothesis:

from hypothesis import given
from hypothesis import strategies as st

@given(st.lists(st.integers()))
def test_reverse_twice_is_identity(xs):
    assert reverse_list(reverse_list(xs)) == xs

@given(st.lists(st.integers()))
def test_reverse_preserves_length(xs):
    assert len(reverse_list(xs)) == len(xs)

@given(st.lists(st.integers()))
def test_reverse_preserves_elements(xs):
    assert sorted(reverse_list(xs)) == sorted(xs)

Run these with pytest as usual. Hypothesis will generate 100 random lists by default and check each property. If any fail, it will shrink the input to the simplest failing case.

Understanding Strategies

Strategies are the building blocks of input generation. Hypothesis ships with strategies for most Python types:

from hypothesis import strategies as st

# Primitive types
st.integers()                    # any int
st.integers(min_value=0, max_value=100)  # bounded int
st.floats(allow_nan=False)       # floats without NaN
st.text(min_size=1, max_size=50) # non-empty strings up to 50 chars
st.booleans()                    # True or False

# Collections
st.lists(st.integers(), min_size=1, max_size=20)
st.dictionaries(st.text(), st.integers())
st.tuples(st.integers(), st.text())

# Composite strategies
st.one_of(st.integers(), st.none())  # int or None
st.integers().filter(lambda x: x != 0)  # non-zero ints
st.integers().map(lambda x: x * 2)      # even ints

You can also build custom strategies for your domain objects using @st.composite:

@st.composite
def user_strategy(draw):
    name = draw(st.text(min_size=1, max_size=30, alphabet=st.characters(whitelist_categories=("L",))))
    age = draw(st.integers(min_value=0, max_value=150))
    email = draw(st.emails())
    return {"name": name, "age": age, "email": email}

@given(user_strategy())
def test_user_display_name_is_not_empty(user):
    display = format_display_name(user)
    assert len(display) > 0

Shrinking: Finding Minimal Failures

One of Hypothesis’s strongest features is shrinking. When it finds a failing input, it does not just report it. It systematically simplifies the input while keeping the test failing.

For example, if a list [437, -92, 0, 18, 55] triggers a failure, Hypothesis might shrink it down to [0] or [-1] depending on the actual bug. This turns a confusing five-element failure into an obvious edge case.

You do not need to configure shrinking. It happens automatically. But you can observe it by running pytest with -s to see Hypothesis’s output:

pytest test_example.py -s --hypothesis-show-statistics

A Realistic Example: Sorting

Let us test a sorting function more thoroughly. Suppose you have written a custom sort:

def my_sort(xs):
    # imagine a hand-rolled merge sort here
    return sorted(xs)  # placeholder

The properties of any correct sort are well-defined:

@given(st.lists(st.integers()))
def test_sort_produces_ordered_output(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_a_permutation(xs):
    result = my_sort(xs)
    assert sorted(result) == sorted(xs)

@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs):
    once = my_sort(xs)
    twice = my_sort(once)
    assert once == twice

@given(st.lists(st.integers()))
def test_sort_length(xs):
    assert len(my_sort(xs)) == len(xs)

These four properties completely specify what a correct sort must do. If your implementation passes all four across thousands of random inputs, you have high confidence it is correct.

Testing Encode/Decode Round-Trips

Property-based testing excels at round-trip tests. Any pair of functions where one is the inverse of the other is a natural fit:

import json

@given(st.dictionaries(st.text(), st.integers()))
def test_json_round_trip(data):
    encoded = json.dumps(data)
    decoded = json.loads(encoded)
    assert decoded == data

This pattern applies to serialization, compression, encryption/decryption, URL encoding, and database read/write cycles.

Stateful Testing

Hypothesis can also test stateful systems using RuleBasedStateMachine. This generates sequences of operations rather than single inputs:

from hypothesis.stateful import RuleBasedStateMachine, rule, precondition, invariant

class SetMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.model = set()
        self.real = MyCustomSet()  # your implementation

    @rule(value=st.integers())
    def add(self, value):
        self.model.add(value)
        self.real.add(value)

    @rule(value=st.integers())
    def remove(self, value):
        self.model.discard(value)
        self.real.discard(value)

    @invariant()
    def sets_match(self):
        assert set(self.real) == self.model

TestSet = SetMachine.TestCase

Hypothesis generates random sequences of add and remove calls and checks the invariant after each step. This is extremely effective at finding ordering-dependent bugs.

Settings and Profiles

You can tune how many examples Hypothesis generates:

from hypothesis import settings, HealthCheck

@settings(max_examples=500, deadline=None)
@given(st.lists(st.integers()))
def test_heavy_property(xs):
    # runs 500 examples instead of the default 100
    assert my_sort(xs) == sorted(xs)

For CI environments, you might use profiles:

# conftest.py
from hypothesis import settings

settings.register_profile("ci", max_examples=1000)
settings.register_profile("dev", max_examples=50)
settings.load_profile("dev")  # default for local development

Then in CI: pytest --hypothesis-profile=ci.

When to Use Property-Based Testing

Property-based testing shines when:

  • Pure functions with clear invariants — sorting, encoding, math operations.
  • Round-trip operations — serialize/deserialize, compress/decompress.
  • Data structure implementations — stacks, queues, trees, caches.
  • Parsers and validators — anything that accepts structured input.

It is less useful when:

  • The function’s correctness depends on specific business rules that are hard to express as general properties.
  • Side effects make tests slow or non-deterministic.
  • You are testing UI behavior that requires visual inspection.

Combining with Example-Based Tests

Property-based tests do not replace example-based tests. They complement each other. Use example-based tests for specific business scenarios and regression tests. Use property-based tests to explore the input space broadly.

# Example-based: specific business rule
def test_premium_users_get_20_percent_discount():
    assert calculate_discount(100, is_premium=True) == 80

# Property-based: general invariant
@given(st.floats(min_value=0, max_value=10000, allow_nan=False))
def test_discount_never_increases_price(price):
    assert calculate_discount(price, is_premium=True) <= price

Key Takeaways

Property-based testing with Hypothesis lets you specify what should always be true rather than what should happen for this specific input. The framework generates hundreds of inputs, finds failures, and shrinks them to minimal cases. Combined with traditional example-based tests, it dramatically increases your confidence that code handles edge cases correctly. Start with round-trip tests and simple invariants, then expand to stateful testing as you get comfortable with the approach.