Property-based testing checks a general rule against many automatically generated inputs. Instead of testing only sort([3, 1]) == [1, 3], you state that sorting any valid list preserves its elements, produces an ordered result, and is idempotent. Hypothesis explores the input space and reduces a failure to a small counterexample.

This approach does not replace conventional tests. It adds systematic exploration to a pytest test suite, particularly for parsers, serialization, calculations, validation, and collection transformations where hand-picked examples cover little of the domain.

Install Hypothesis and write the first property

Create a Python virtual environment, then install both packages:

python -m pip install hypothesis pytest

Suppose the application normalizes user names:

def normalize_name(name: str) -> str:
    return " ".join(name.split()).casefold()

Normalization should be idempotent: applying it twice must have the same result as applying it once.

from hypothesis import given, strategies as st

from app.text import normalize_name


@given(st.text())
def test_normalization_is_idempotent(text: str) -> None:
    once = normalize_name(text)
    twice = normalize_name(once)

    assert twice == once

Run pytest -q. The @given decorator obtains values from st.text(), including empty strings, Unicode, unusual whitespace, and combinations that are easy to overlook. Refer to the official Hypothesis documentation for current settings and behavior.

Strategies describe valid input

A strategy is more than a random generator. It describes how to produce and simplify values. Built-in strategies cover integers, decimals, dates, text, binary data, lists, sets, dictionaries, and combinations of those types.

from hypothesis import given, strategies as st


@given(st.lists(st.integers(), max_size=200))
def test_sort_preserves_content(values: list[int]) -> None:
    result = sorted(values)

    assert len(result) == len(values)
    assert result == sorted(result)
    assert sorted(result, reverse=True) == list(reversed(result))

Constraints should model the real domain. If a function accepts percentages from zero through one hundred, use st.integers(min_value=0, max_value=100). Do not filter inputs merely to make a test pass. Heavy .filter() use discards examples and may trigger health checks; express constraints in strategy arguments or construct objects with st.builds().

Use @st.composite when fields depend on each other:

@st.composite
def intervals(draw):
    start = draw(st.integers(-10_000, 10_000))
    end = draw(st.integers(min_value=start, max_value=start + 1_000))
    return start, end


@given(intervals())
def test_interval_length_is_non_negative(interval):
    start, end = interval
    assert end - start >= 0

This preserves end >= start without rejecting pairs. For domain models, st.builds(Product, name=..., price=...) can reuse the constructor while keeping generation close to the type.

Choose meaningful invariants

An invariant remains true throughout the tested domain. Strong properties describe behavior without copying the implementation. Useful patterns include:

  • Idempotence: applying an operation again changes nothing, as with normalization or deduplication.
  • Round trip: decoding an encoded value recovers the original.
  • Conservation: sorting preserves length and elements; a transfer preserves total funds.
  • Bounds: a result always falls inside its documented range.
  • Oracle comparison: an optimized implementation agrees with a simple, obviously correct model.

For example, a JSON round trip must use values representable by the chosen format and equality rule:

import json
from hypothesis import given, strategies as st

json_scalar = st.none() | st.booleans() | st.integers() | st.text()
json_value = st.recursive(
    json_scalar,
    lambda children: st.lists(children, max_size=10)
    | st.dictionaries(st.text(), children, max_size=10),
    max_leaves=30,
)


@given(json_value)
def test_json_round_trip(value) -> None:
    assert json.loads(json.dumps(value)) == value

Leaving floats out is an explicit domain decision: non-finite values and representation details need their own property. The annotations covered in the Python type hints guide clarify contracts, but do not enforce them at runtime.

Shrinking reveals the real defect

After detecting a failure, Hypothesis searches for simpler values that still fail. This process is called shrinking. A large list may become [0]; a complicated string may become one character. The minimal counterexample often exposes a missing condition better than the first generated value would.

Do not assume the displayed value was generated first, and never assert generation order. Avoid catching broad exceptions inside a property because doing so can hide a failure from the runner. If an exception is part of the contract, state that explicitly with pytest.raises.

Shrinking also explains why custom strategies should use Hypothesis primitives rather than Python's random module. Hypothesis understands its own choices and can simplify them; an opaque random value cannot participate effectively in that process.

Reproducibility and regression cases

Hypothesis stores interesting and failing examples in .hypothesis/ and tries those cases early in later runs. This gives practical reproducibility while allowing future runs to explore. A permanently fixed seed usually weakens that exploration and should not be the default.

When a failure deserves visible documentation, add an explicit example:

from hypothesis import example, given, strategies as st


@given(st.text())
@example("\u00a0")  # known regression: non-breaking space
def test_normalized_name_has_no_trailing_space(text: str) -> None:
    assert not normalize_name(text).endswith(" ")

A failure report may also provide @reproduce_failure(...), which reproduces an encoded example with a compatible Hypothesis version. It is valuable for immediate diagnosis; after the fix, a readable @example generally communicates the regression better. Run both in the pipeline described in the Python GitHub Actions CI guide.

Composite strategies and test cost

Not every domain fits a direct combination of lists() and dictionaries(). When one generated value depends on another, use @st.composite to construct a coherent object. An interval strategy, for example, can draw the start first and constrain the end to an equal or greater value. This is preferable to drawing two unrelated integers and discarding most examples with assume().

@st.composite
def intervals(draw):
    start = draw(st.integers(min_value=-1000, max_value=1000))
    end = draw(st.integers(min_value=start, max_value=1000))
    return start, end

Filters and assume() remain useful for occasional conditions, but rejecting too much data weakens exploration and can trigger a health check. Whenever practical, generate valid values by construction. Avoid strategies that are much broader than the tested contract as well. Generating enormous Unicode strings for a field limited to 20 characters wastes time and does not represent the public interface.

Performance is part of test design. Begin with the defaults, measure suite duration, and change max_examples only for a documented reason. Separate development and CI profiles can be helpful, but globally suppressing health checks merely hides inefficient generation. An unstable deadline failure may reflect a noisy machine; before disabling it, verify that the system under test has not introduced an unexpectedly slow path.

When Hypothesis is the right tool

Use it where inputs are broad and strong properties exist: codecs, parsers, algorithms, transformation APIs, state machines, and financial rules with carefully constrained generators. Stateful testing can generate operation sequences, but teams usually get faster results by starting with pure functions and small invariants.

Prefer examples for one exact requirement, an exact error message, or an expensive integration. Browser interfaces and unstable external services are rarely the best first target for intensive generation. Merely checking that generated input does not crash can be a legitimate robustness property, but it is weaker than checking a meaningful result.

Common mistakes include recreating production logic in the assertion, calling random inside a test, filtering most generated data, allowing unbounded recursive structures, and raising max_examples to compensate for a poor strategy. Running slow I/O for every example is another trap; separate pure logic and cover the integration with a few representative tests.

Final checklist

  • Install hypothesis and pytest as development dependencies.
  • Model the real domain with precise strategies and bounded sizes.
  • Choose invariants independent of implementation details.
  • Keep each property fast, isolated, and free from global state.
  • Study the shrunk counterexample before restricting the strategy.
  • Preserve important regressions with readable @example cases.
  • Run properties locally and in CI without a fixed seed by default.

Start with one function whose inputs vary widely and whose output is easy to verify. One strong invariant backed by an accurate strategy is more useful than many generic generators. Hypothesis works best alongside readable examples, not as a blanket replacement for them.