Python · Testing with pytest · Advanced

Property-Based Testing with Hypothesis

5 tasks

Generate thousands of test inputs automatically to find edge cases you would miss.

From example-based to property-based testing

#
## The limits of example-based testing Every test you have written so far is *example-based*: you pick specific inputs, call the function, and assert specific outputs. ```python def test_reverse(): assert reverse('hello') == 'olleh' assert reverse('') == '' assert reverse('a') == 'a' ``` You chose these three examples. You tested what you thought to test. If you did not think of `'racecar'` (a palindrome), or `'café'` (unicode), or `' '` (whitespace), those inputs go untested. The fundamental weakness: **example-based tests only cover the examples you thought to write.** ## Property-based testing: describe the invariant, let the library find the counterexample Property-based testing takes a different approach. Instead of choosing examples, you describe a *property* — an invariant that must hold for any valid input — and let a library generate hundreds of random inputs to try to falsify it. ```python from hypothesis import given from hypothesis import strategies as st @given(st.text()) def test_reverse_involution(s): # Property: reversing a string twice gives back the original string. assert reverse(reverse(s)) == s ``` Hypothesis generates random strings — short, long, empty, unicode, control characters, strings with spaces — and runs the test body for each one. If any input causes the assertion to fail, hypothesis reports it as a failing example and tries to *shrink* it to the smallest possible failing case. ## Installing and importing ```bash pip install hypothesis ``` ```python from hypothesis import given, assume, settings from hypothesis import strategies as st ``` ## The @given decorator `@given(strategy)` is the core of hypothesis. It turns a regular test function into a property test by injecting generated values as arguments: ```python @given(st.integers()) def test_abs_is_non_negative(n): assert abs(n) >= 0 ``` Hypothesis generates integers across the full range — positives, negatives, zero, `sys.maxsize`, `-sys.maxsize - 1` — and runs the test body for each. This test will pass for all of them because `abs` is correct. If you had a buggy implementation: ```python def buggy_abs(n): return n # bug: negative numbers returned unchanged ``` Hypothesis quickly finds the minimal counterexample: ``` Falsifying example: test_abs_is_non_negative(n=-1) ``` The original failing input might have been `-273`. Hypothesis shrinks it to `-1` — the smallest integer that falsifies `abs(n) >= 0` for a function that returns `n` unchanged. ## Strategies: how hypothesis generates data A *strategy* describes a set of values hypothesis can generate. The `hypothesis.strategies` module (`st`) provides a comprehensive library: | Strategy | Generates | |---|---| | `st.integers()` | Any integer | | `st.integers(min_value=0, max_value=100)` | Integer in [0, 100] | | `st.floats()` | Any float (including NaN, inf) | | `st.floats(allow_nan=False, allow_infinity=False)` | Finite float | | `st.text()` | Any unicode string | | `st.text(alphabet=string.ascii_lowercase, min_size=1)` | Non-empty lowercase ASCII | | `st.booleans()` | True or False | | `st.lists(st.integers())` | List of integers | | `st.lists(st.integers(), min_size=1, max_size=10)` | Non-empty list, up to 10 items | | `st.dictionaries(st.text(), st.integers())` | Dict with text keys, integer values | | `st.one_of(st.integers(), st.text())` | Either an integer or a text | ## Shrinking: finding the minimal failing example When hypothesis finds a failing input, it does not stop and report that input immediately. It tries to *shrink* it — find a smaller, simpler input that also fails. This is one of hypothesis's most valuable features. For example, if hypothesis generates a 500-character string that fails your test, it will try progressively shorter strings until it finds the shortest string that still fails. The reported failure is the minimal example, not the original random one — making it easy to understand the bug. ``` Falsifying example: test_slugify(s='A B') # Not: s='Random Long String With Spaces And Punctuation!!!' ``` ## assume(): filtering invalid inputs Sometimes a property only makes sense for a subset of inputs. `assume()` tells hypothesis to skip examples that do not satisfy a precondition: ```python from hypothesis import given, assume from hypothesis import strategies as st @given(st.integers(), st.integers()) def test_division(a, b): assume(b != 0) # skip examples where b is zero result = a / b assert isinstance(result, float) ``` When hypothesis generates `b=0`, `assume(b != 0)` raises an internal exception and hypothesis discards that example, trying a different one. The test is only run on valid inputs. **Caution:** `assume()` should be used sparingly. If most generated inputs are discarded, hypothesis generates many more examples to find valid ones, and may give up with `Unsatisfied`. Use constrained strategies instead when possible: ```python # Better than assume(b != 0): @given(st.integers(), st.integers().filter(lambda x: x != 0)) def test_division(a, b): result = a / b assert isinstance(result, float) ``` ## settings: controlling hypothesis behaviour ```python from hypothesis import settings @settings(max_examples=500) # run 500 examples instead of the default 100 @given(st.text()) def test_something(s): ... ``` | Setting | Default | Description | |---|---|---| | `max_examples` | 100 | How many valid examples to generate | | `deadline` | 200 ms | Maximum time per example (set `None` to disable) | | `suppress_health_check` | `[]` | Disable specific health checks | For tests that call a real API, set `deadline=None` to avoid false failures from network latency: ```python @settings(max_examples=10, deadline=None) @given(st.text(min_size=1, max_size=20, alphabet=string.ascii_lowercase)) def test_search(query): resp = requests.get(f'{BASE_URL}/api/search/?q={query}') assert resp.status_code in (200, 404) ``` ## When to use hypothesis **Good fit:** - Pure functions with mathematical properties (sorting, encoding, parsing) - Functions with invertible operations (`encode`/`decode`, `compress`/`decompress`) - Validation functions that should reject or accept any input of a type - Data structure invariants (tree height, list ordering, set membership) **Poor fit:** - Tests that require specific setup state in a database - Tests where the expected output depends on the input in a non-trivial way (then use parametrize) - Tests of side-effectful functions (calls to APIs, writing files) — too slow for 100 examples The clearest sign that hypothesis is right for a test: the property is a universal statement like "for all X, property(X) is True."

Hypothesis in practice: invariants, shrinking, and composite strategies

#
## Setup ```bash pip install hypothesis ``` ## Example 1: The simplest property — abs is non-negative ```python # test_abs.py from hypothesis import given from hypothesis import strategies as st @given(st.integers()) def test_abs_non_negative(n): assert abs(n) >= 0 ``` ``` $ pytest test_abs.py -v test_abs.py::test_abs_non_negative PASSED (ran 100 examples) ``` Hypothesis generated 100 integers — including large positives, large negatives, zero, and edge cases like `sys.maxsize` — and verified the property for each. This is more thorough than any manual parametrize list. ## Example 2: Invertible operation — double reverse ```python # test_reverse.py from hypothesis import given from hypothesis import strategies as st def reverse(s: str) -> str: return s[::-1] @given(st.text()) def test_reverse_involution(s): assert reverse(reverse(s)) == s ``` "Reverse twice equals identity" is the classic property for string reversal. Hypothesis will try empty strings, single characters, unicode characters (`'é'`, `'🐍'`), strings with null bytes, and more. The property holds for all of them. ## Example 3: Sorted list invariants ```python # test_sorting.py from hypothesis import given from hypothesis import strategies as st @given(st.lists(st.integers())) def test_sorted_list_is_ordered(lst): result = sorted(lst) for i in range(len(result) - 1): assert result[i] <= result[i + 1] @given(st.lists(st.integers())) def test_sort_preserves_length(lst): assert len(sorted(lst)) == len(lst) @given(st.lists(st.integers())) def test_sort_preserves_elements(lst): assert sorted(lst, reverse=True) == list(reversed(sorted(lst))) assert set(sorted(lst)) == set(lst) ``` Notice three separate properties for the same function: ordering, length, element set. Each property is simple; together they give strong confidence in `sorted`. ## Example 4: assume() to constrain inputs ```python # test_division.py from hypothesis import given, assume from hypothesis import strategies as st def safe_divide(a: int, b: int) -> float: return a / b @given(st.integers(), st.integers()) def test_safe_divide_result_type(a, b): assume(b != 0) result = safe_divide(a, b) assert isinstance(result, float) @given(st.integers(min_value=1), st.integers(min_value=1)) def test_divide_positive_by_positive(a, b): result = safe_divide(a, b) assert result > 0 ``` The second test uses a constrained strategy (`min_value=1`) instead of `assume()` — this is more efficient because hypothesis generates only valid inputs from the start, rather than generating and discarding many invalid ones. ## Example 5: Finding a bug with hypothesis — shrinking in action ```python # test_buggy_max.py from hypothesis import given from hypothesis import strategies as st def my_max(lst): result = lst[0] for x in lst[1:]: if x > result: result = x return result @given(st.lists(st.integers(), min_size=1)) def test_my_max_equals_builtin(lst): assert my_max(lst) == max(lst) ``` If you introduced a bug — say, `>` instead of `>=` might cause issues in some cases, or if the loop had an off-by-one — hypothesis would find the minimal failing list. For example, if the bug only manifests with repeated maximum values, hypothesis would shrink to `[1, 1]` rather than reporting `[5, 3, 1, 5, 8, 5]`. ## Example 6: @st.composite — custom strategy `@st.composite` lets you build a strategy that combines multiple draws to produce a structured value: ```python # test_composite.py from hypothesis import given from hypothesis import strategies as st @st.composite def valid_user(draw): username = draw(st.text( alphabet='abcdefghijklmnopqrstuvwxyz0123456789_', min_size=3, max_size=20, )) age = draw(st.integers(min_value=13, max_value=120)) return {'username': username, 'age': age} def validate_user(user): if len(user['username']) < 3: raise ValueError('Username too short') if user['age'] < 13: raise ValueError('Too young') return True @given(valid_user()) def test_valid_user_always_validates(user): assert validate_user(user) is True ``` `draw(strategy)` inside a `@st.composite` function samples a value from `strategy`. The decorated function becomes a strategy that can be passed to `@given`. This is the right tool when you need to generate structured data where one field constrains another. ## Running hypothesis with verbosity ```bash pytest test_abs.py -v --hypothesis-show-statistics ``` ``` test_abs_non_negative: - 100 passing examples - Typical runtimes: 0-1ms ``` To see every generated example: ```bash pytest test_abs.py -v -s --hypothesis-verbosity=verbose ```

Hypothesis reference card

#
## Setup ```bash pip install hypothesis ``` ```python from hypothesis import given, assume, settings from hypothesis import strategies as st ``` ## Basic pattern ```python @given(strategy) def test_property(value): # assert invariant holds for any `value` assert some_property(value) ``` ## Strategy cheatsheet | Strategy | What it generates | |---|---| | `st.integers()` | Any integer | | `st.integers(min_value=0, max_value=100)` | Integer in [0, 100] | | `st.floats(allow_nan=False, allow_infinity=False)` | Finite float | | `st.text()` | Any unicode string | | `st.text(alphabet='abc', min_size=1)` | Non-empty string from `'abc'` | | `st.binary()` | Bytes object | | `st.booleans()` | `True` or `False` | | `st.none()` | `None` | | `st.lists(st.integers())` | List of integers | | `st.lists(st.integers(), min_size=1, max_size=10)` | 1–10 integers | | `st.tuples(st.integers(), st.text())` | Tuple `(int, str)` | | `st.dictionaries(st.text(), st.integers())` | `{str: int}` dict | | `st.one_of(st.integers(), st.text())` | Integer or text | | `st.sampled_from([1, 2, 3])` | One of the listed values | | `st.builds(MyClass, field=strategy)` | `MyClass` instance | ## Filtering strategies ```python # Prefer constrained strategies over assume() when possible: st.integers().filter(lambda x: x != 0) st.text().filter(lambda s: s.strip()) ``` ## assume() ```python @given(st.integers(), st.integers()) def test_division(a, b): assume(b != 0) # skip examples where b == 0 assert a / b == a / b ``` ## settings ```python @settings(max_examples=500, deadline=None) @given(st.text()) def test_something(s): ... ``` | Setting | Default | Use | |---|---|---| | `max_examples` | 100 | Number of valid examples to run | | `deadline` | 200 ms | Max per-example time; set `None` for slow operations | | `suppress_health_check` | `[]` | Suppress specific warnings | ## @st.composite — custom strategies ```python @st.composite def my_strategy(draw): x = draw(st.integers(min_value=0)) y = draw(st.integers(min_value=x)) # y >= x return (x, y) @given(my_strategy()) def test_pair(pair): x, y = pair assert y >= x ``` ## Common property patterns ```python # Invertible operation assert decode(encode(x)) == x # Idempotence assert f(f(x)) == f(x) # Monotonicity assert f(a) <= f(b) when a <= b # Length preservation assert len(transform(x)) == len(x) # Element preservation assert set(transform(x)) == set(x) # Non-negativity assert result >= 0 ```
01

Test that abs(n) >= 0 for all integers

#

Install `hypothesis` and write a property-based test `test_abs_non_negative` that: - Uses `@given(st.integers())` to generate any integer - Asserts that `abs(n) >= 0` Also write `test_abs_of_abs_equals_abs` that asserts `abs(abs(n)) == abs(n)` for any integer. Both tests should pass — they are testing a correct Python built-in.

# test_abs_property.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.integers())
def test_abs_non_negative(n):
    ...


@given(st.integers())
def test_abs_of_abs_equals_abs(n):
    ...
Solution
# test_abs_property.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.integers())
def test_abs_non_negative(n):
    assert abs(n) >= 0


@given(st.integers())
def test_abs_of_abs_equals_abs(n):
    assert abs(abs(n)) == abs(n)
02

Test that reversing a string twice returns the original

#

Define a `reverse(s: str) -> str` function that reverses a string using slicing (`s[::-1]`). Write a property test `test_reverse_involution` using `@given(st.text())` that asserts: ``` reverse(reverse(s)) == s ``` Also write `test_reverse_empty` using `@given(st.text(max_size=0))` — the reverse of an empty string is an empty string.

# test_reverse.py
from hypothesis import given
from hypothesis import strategies as st


def reverse(s: str) -> str:
    ...


@given(st.text())
def test_reverse_involution(s):
    ...


@given(st.text(max_size=0))
def test_reverse_empty(s):
    ...
Solution
# test_reverse.py
from hypothesis import given
from hypothesis import strategies as st


def reverse(s: str) -> str:
    return s[::-1]


@given(st.text())
def test_reverse_involution(s):
    assert reverse(reverse(s)) == s


@given(st.text(max_size=0))
def test_reverse_empty(s):
    assert reverse(s) == ''
03

Use assume() to test division with a non-zero denominator

#

Write a function `safe_divide(a: float, b: float) -> float` that returns `a / b`. Write two property tests: 1. `test_divide_with_assume` — use `@given(st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False), st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False))` and `assume(b != 0)`. Assert `safe_divide(a, b) * b == pytest.approx(a)` (multiplying result by denominator gives back numerator). 2. `test_divide_positive_constrained` — use `@given(st.floats(min_value=0.1, max_value=100), st.floats(min_value=0.1, max_value=100))`. Assert the result is positive.

# test_division_property.py
import pytest
from hypothesis import given, assume
from hypothesis import strategies as st


def safe_divide(a: float, b: float) -> float:
    return a / b


@given(
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
)
def test_divide_with_assume(a, b):
    assume(b != 0)
    result = safe_divide(a, b)
    assert result * b == pytest.approx(a, rel=1e-6)


@given(
    st.floats(min_value=0.1, max_value=100),
    st.floats(min_value=0.1, max_value=100),
)
def test_divide_positive_constrained(a, b):
    result = safe_divide(a, b)
    assert result > 0
Solution
# test_division_property.py
import pytest
from hypothesis import given, assume
from hypothesis import strategies as st


def safe_divide(a: float, b: float) -> float:
    return a / b


@given(
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
    st.floats(min_value=-1e15, max_value=1e15, allow_nan=False, allow_infinity=False),
)
def test_divide_with_assume(a, b):
    assume(b != 0)
    result = safe_divide(a, b)
    assert result * b == pytest.approx(a, rel=1e-6)


@given(
    st.floats(min_value=0.1, max_value=100),
    st.floats(min_value=0.1, max_value=100),
)
def test_divide_positive_constrained(a, b):
    result = safe_divide(a, b)
    assert result > 0
04

Test that max(lst) >= min(lst) for any non-empty list

#

Write a property test using `@given(st.lists(st.integers(), min_size=1))` that asserts: - `max(lst) >= min(lst)` - `max(lst)` is an element of `lst` (i.e., `max(lst) in lst`) - `min(lst)` is an element of `lst` (i.e., `min(lst) in lst`) All three properties should be separate `assert` statements in a single test function `test_max_min_properties`.

# test_max_min.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.lists(st.integers(), min_size=1))
def test_max_min_properties(lst):
    assert ...
    assert ...
    assert ...
Solution
# test_max_min.py
from hypothesis import given
from hypothesis import strategies as st


@given(st.lists(st.integers(), min_size=1))
def test_max_min_properties(lst):
    assert max(lst) >= min(lst)
    assert max(lst) in lst
    assert min(lst) in lst
05

Write a @st.composite strategy for a user dict

#

Write a `@st.composite` strategy `valid_user(draw)` that generates a user dict with: - `username` — 3–20 characters from `string.ascii_lowercase + string.digits + '_'` - `age` — integer between 13 and 120 - `email` — a simple fake email constructed as `f'{username}@example.com'` Note: derive `email` from the same `username` value you drew (so they are consistent). Write a `validate_user(user)` function that: - Raises `ValueError('Username too short')` if `len(username) < 3` - Raises `ValueError('Too young')` if `age < 13` - Returns `True` otherwise Write `test_valid_user_always_validates` using `@given(valid_user())` that asserts `validate_user(user) is True`.

# test_composite.py
import string
from hypothesis import given
from hypothesis import strategies as st


@st.composite
def valid_user(draw):
    username = draw(st.text(
        alphabet=...,
        min_size=3,
        max_size=20,
    ))
    age = draw(st.integers(min_value=13, max_value=120))
    return {
        'username': username,
        'age': age,
        'email': f'{username}@example.com',
    }


def validate_user(user):
    if len(user['username']) < 3:
        raise ValueError('Username too short')
    if user['age'] < 13:
        raise ValueError('Too young')
    return True


@given(valid_user())
def test_valid_user_always_validates(user):
    assert validate_user(user) is True
Solution
# test_composite.py
import string
from hypothesis import given
from hypothesis import strategies as st


@st.composite
def valid_user(draw):
    username = draw(st.text(
        alphabet=string.ascii_lowercase + string.digits + '_',
        min_size=3,
        max_size=20,
    ))
    age = draw(st.integers(min_value=13, max_value=120))
    return {
        'username': username,
        'age': age,
        'email': f'{username}@example.com',
    }


def validate_user(user):
    if len(user['username']) < 3:
        raise ValueError('Username too short')
    if user['age'] < 13:
        raise ValueError('Too young')
    return True


@given(valid_user())
def test_valid_user_always_validates(user):
    assert validate_user(user) is True