Hypothesis: Property-Based Testing for Python

Hypothesis: Property-Based Testing for Python

Hypothesis is Python's most powerful property-based testing library. It extends pytest with the @given decorator and a rich strategy API so you can describe the shape of your inputs rather than specific values. Hypothesis then generates hundreds of examples, finds failures, and shrinks them to the minimal reproducible case — all automatically.

Key Takeaways

@given turns a test function into a property. Decorate any pytest test with @given(st.integers(), st.text()) and Hypothesis will call it with hundreds of generated input combinations.

Strategies compose. st.integers, st.text, st.lists, st.dictionaries, st.builds, and st.from_type are the building blocks; combine them with |, map, filter, and flatmap to model any domain.

Hypothesis remembers failures. A .hypothesis/ database stores previously failing examples and replays them first on every subsequent run — so a flaky failure never gets silently ignored.

Stateful testing models workflows. RuleBasedStateMachine lets you define state transitions as rules and Hypothesis finds sequences of operations that break your invariants.

Hypothesis integrates cleanly with pytest. No extra test runner, no new file format — just decorators. Settings, profiles, and the @settings decorator let you tune test counts and timeouts per test or globally.

Why Hypothesis?

Traditional unit testing asks: "Does my function return the right value for this input?" Hypothesis asks a different question: "Is there any input in this space that breaks my invariant?"

The difference is enormous. A developer writing example-based tests for a URL parser might cover http://example.com, https://example.com/path, and maybe an empty string. Hypothesis will also try strings with null bytes, strings in Arabic script, strings that are exactly 2 GB long (if you let it), and strings with Unicode normalization edge cases — inputs a human would never think to write.

Install Hypothesis alongside pytest:

pip install hypothesis pytest

The @given Decorator

The entry point for every Hypothesis test is @given. It takes one or more strategies — objects that know how to generate values of a particular type.

from hypothesis import given
import hypothesis.strategies as st

@given(st.integers(), st.integers())
def test_addition_commutative(a, b):
    assert a + b == b + a

Run with pytest as normal. Hypothesis will call test_addition_commutative with 100 pairs of integers (by default), checking the assertion each time.

A slightly more useful example — testing a function that encodes and decodes JSON:

import json
from hypothesis import given
import hypothesis.strategies as st

@given(st.dictionaries(
    keys=st.text(min_size=1),
    values=st.one_of(st.integers(), st.text(), st.booleans(), st.none())
))
def test_json_roundtrip(data):
    encoded = json.dumps(data)
    decoded = json.loads(encoded)
    assert decoded == data

Core Strategies

Hypothesis ships with strategies for every Python built-in type and many standard library types.

Numeric strategies:

st.integers()                          # all integers
st.integers(min_value=0, max_value=100)
st.floats(allow_nan=False, allow_infinity=False)
st.decimals(min_value="0.01", max_value="9999.99", places=2)
st.fractions()

Text and binary:

st.text()                              # arbitrary Unicode strings
st.text(alphabet=st.characters(whitelist_categories=("Lu", "Ll")))
st.binary()
st.emails()
st.uuids()
st.ip_addresses()

Collections:

st.lists(st.integers(), min_size=1, max_size=20)
st.sets(st.text())
st.tuples(st.integers(), st.booleans(), st.text())
st.dictionaries(keys=st.text(), values=st.integers())
st.frozensets(st.floats(allow_nan=False))

Datetime:

from hypothesis.strategies import datetimes, dates, times, timedeltas
import datetime

@given(datetimes(min_value=datetime.datetime(2000, 1, 1),
                 max_value=datetime.datetime(2099, 12, 31)))
def test_iso_format_roundtrip(dt):
    assert datetime.datetime.fromisoformat(dt.isoformat()) == dt

Building Custom Strategies

st.builds — construct objects from a callable and strategies for its arguments:

from dataclasses import dataclass

@dataclass
class Order:
    order_id: str
    quantity: int
    unit_price: float

order_strategy = st.builds(
    Order,
    order_id=st.uuids().map(str),
    quantity=st.integers(min_value=1, max_value=1000),
    unit_price=st.floats(min_value=0.01, max_value=9999.99, allow_nan=False)
)

@given(order_strategy)
def test_order_total_non_negative(order):
    total = order.quantity * order.unit_price
    assert total >= 0

st.from_type — generate from a type annotation, including dataclasses and attrs classes:

from hypothesis.strategies import from_type

@given(from_type(Order))
def test_order_total_non_negative_v2(order):
    assert order.quantity * order.unit_price >= 0

map and filter:

non_empty_text = st.text().filter(lambda s: len(s.strip()) > 0)
slugified = st.text(alphabet="abcdefghijklmnopqrstuvwxyz-").map(lambda s: s.strip("-"))

Combining with | (union):

json_value = (
    st.none() |
    st.booleans() |
    st.integers() |
    st.floats(allow_nan=False) |
    st.text()
)

The Hypothesis Database

One of Hypothesis's killer features is its persistent example database. When Hypothesis finds a failing example, it stores it in .hypothesis/examples/. On subsequent runs, it replays those stored examples first, before generating new ones.

This means a failure found in CI is automatically reproduced locally — even without a fixed seed — and failures found locally will surface reliably in CI. Add .hypothesis/ to version control for shared failure memory across your team.

echo ".hypothesis/examples/" >> .gitignore  # or track it:
git add .hypothesis/examples/

Controlling Test Behavior with @settings

The @settings decorator lets you tune Hypothesis per test:

from hypothesis import given, settings, HealthCheck
import hypothesis.strategies as st

@given(st.lists(st.integers(), min_size=1))
@settings(max_examples=500, deadline=2000)  # 500 examples, 2s deadline
def test_sort_is_idempotent(lst):
    assert sorted(sorted(lst)) == sorted(lst)

@given(st.text())
@settings(suppress_health_check=[HealthCheck.too_slow])
def test_slow_operation(text):
    # some slow processing
    pass

Global settings via profiles:

# conftest.py
from hypothesis import settings, HealthCheck

settings.register_profile("ci", max_examples=1000)
settings.register_profile("dev", max_examples=50)
settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose)

settings.load_profile("ci")  # or from env: HYPOTHESIS_PROFILE=ci pytest

Stateful Testing with RuleBasedStateMachine

Hypothesis can test stateful systems — not just pure functions. A RuleBasedStateMachine lets you define:

  • Initialize steps — how to set up the system under test
  • Rules — transitions (like "add an item", "remove an item", "query the system")
  • Invariants — properties that must hold after every transition
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant, initialize
import hypothesis.strategies as st

class ShoppingCart:
    def __init__(self):
        self.items = {}

    def add(self, sku: str, qty: int):
        self.items[sku] = self.items.get(sku, 0) + qty

    def remove(self, sku: str):
        self.items.pop(sku, None)

    def total_items(self) -> int:
        return sum(self.items.values())


class CartStateMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.cart = ShoppingCart()
        self.model_total = 0

    @rule(sku=st.text(min_size=1, max_size=10),
          qty=st.integers(min_value=1, max_value=50))
    def add_item(self, sku, qty):
        before = self.cart.total_items()
        self.cart.add(sku, qty)
        # total must increase
        assert self.cart.total_items() >= before

    @rule(sku=st.text(min_size=1, max_size=10))
    def remove_item(self, sku):
        self.cart.remove(sku)

    @invariant()
    def total_non_negative(self):
        assert self.cart.total_items() >= 0


CartTest = CartStateMachine.TestCase

Hypothesis will generate random sequences of add_item and remove_item calls and verify the invariant after every step. When it finds a sequence that violates the invariant, it shrinks it to the shortest sequence that still fails.

Database Integration Testing

Hypothesis works naturally with database-backed code. Use @settings(deriving=True) with SQLAlchemy or Django ORM:

# Django example
from hypothesis import given, settings
from hypothesis.extra.django import from_model
import hypothesis.strategies as st
from myapp.models import Product

@given(from_model(Product,
                  name=st.text(min_size=1, max_size=200),
                  price=st.decimals(min_value="0.01", max_value="99999.99", places=2)))
def test_product_display_price(product):
    # test that display logic never raises
    display = product.format_price()
    assert display.startswith("$")

For pure SQLAlchemy, use transactions to roll back after each test:

@pytest.fixture
def db_session(engine):
    conn = engine.connect()
    trans = conn.begin()
    session = Session(bind=conn)
    yield session
    session.close()
    trans.rollback()
    conn.close()

@given(st.text(min_size=1), st.integers(min_value=1))
def test_insert_and_retrieve(db_session, name, value):
    db_session.add(MyModel(name=name, value=value))
    db_session.flush()
    result = db_session.query(MyModel).filter_by(name=name).first()
    assert result.value == value

Pytest Integration Tips

Hypothesis is a pytest plugin — pip install hypothesis is all you need. A few patterns make it integrate even better:

Mark slow Hypothesis tests for selective running:

@pytest.mark.slow
@given(st.lists(st.integers(), max_size=1000))
@settings(max_examples=500)
def test_heavy_property(lst):
    ...

Use assume() to skip invalid combinations (better than filter for complex preconditions):

from hypothesis import given, assume
import hypothesis.strategies as st

@given(st.integers(), st.integers())
def test_division(a, b):
    assume(b != 0)
    assert a / b == a / b  # trivial, but assume() is the pattern

Reproduce a specific failure by pinning the example:

from hypothesis import given, example
import hypothesis.strategies as st

@given(st.integers())
@example(0)          # always test 0
@example(-1)         # always test -1
def test_abs_non_negative(n):
    assert abs(n) >= 0

Complementing Hypothesis with End-to-End Testing

Hypothesis is excellent for testing units and algorithms in isolation — data transformations, parsers, serializers, state machines, and pure business logic. It does not test your live web application, API authentication flows, browser interactions, or multi-service integrations.

HelpMeTest covers that layer. It runs AI-powered end-to-end tests against your deployed application, verifying that the invariants your Hypothesis tests prove at the unit level survive the full stack. A typical setup: Hypothesis tests run in CI as fast unit-level property checks; HelpMeTest runs against the staging or production environment and catches regressions at the integration layer — form validation, API contract changes, rendering bugs — that no unit-level strategy can find.

Summary

Hypothesis raises the bar for what Python testing can accomplish. The combination of @given, composable strategies, a persistent failure database, and stateful testing machines gives you a tool that finds bugs no example-based test suite would catch. Start by adding @given to your most complex pure functions; within a few runs you will likely find edge cases you had not considered.

Read more

Start now free