Hypothesis & Fuzzing

Combining @given with pytest Fixtures Safely

@given and pytest fixtures look like they compose trivially — the fixture arguments and the generated arguments sit side by side in the signature — and in the common case they do. The trap is lifetime. pytest sets up a function-scoped fixture once per test function; Hypothesis runs the body once per generated example, which might be two hundred times. A fixture that returns a mutable object therefore shares one instance across every example, and state left by example 17 is visible to example 18.

Hypothesis detects this and raises HealthCheck.function_scoped_fixture. The check is not pedantry: a property test whose examples share mutable state can pass because of an accident of ordering and fail on a different seed, which is exactly the flakiness property testing is supposed to remove. The fix is to be deliberate about what fixtures provide and what the test body creates. In practice that means sorting every fixture a property test uses into one of three kinds: immutable configuration, which is safe to share; an expensive shared resource, which is safe to share if the body resets it per example; and mutable per-test state, which belongs in the body. Once the fixtures are sorted, the right code for each is obvious, and the health check becomes a useful confirmation rather than an obstacle.

Prerequisites

Solution

Python
import pytest
from hypothesis import HealthCheck, given, settings, strategies as st


@pytest.fixture
def pricing_rules():
    # Immutable configuration: safe to share across every example.
    return PricingRules.load("tests/fixtures/pricing.yaml")


@given(items=st.lists(st.integers(min_value=1, max_value=10_000), max_size=20))
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_cart_total_is_never_negative(pricing_rules, items):
    # Mutable state created INSIDE the body: fresh for every example.
    cart = Cart(rules=pricing_rules)
    for price in items:
        cart.add(price)
    assert cart.total() >= 0

The suppression is justified here, and the comment on the fixture says why: pricing_rules is immutable, so sharing it across examples cannot leak state. The Cart, which is mutated, is built in the body and is therefore new for every example.

Python
# The unsafe version the health check exists to catch:
@pytest.fixture
def cart(pricing_rules):
    return Cart(rules=pricing_rules)          # ONE cart for all examples


@given(items=st.lists(st.integers(min_value=1, max_value=10_000)))
def test_total_matches_items(cart, items):    # health check fires, rightly
    for price in items:
        cart.add(price)
    assert cart.total() == sum(items)         # fails from example 2 onward
Fixture lifetime versus example lifetime A function-scoped fixture is set up once, then the test body runs for example one, example two and example three. A mutable cart provided by the fixture accumulates items across all three examples. A cart created inside the body is new for each example, so every example starts clean. One fixture, many examples cart from a fixture example 1 adds [5] → example 2 sees [5] and adds [9] → example 3 sees [5, 9] … every example after the first runs against dirty state cart built in the body example 1: new cart · example 2: new cart · example 3: new cart every example is independent, so shrinking and replay work
The health check fires on the upper row. Moving construction into the body turns it into the lower row with no other change.

Why this works

pytest resolves fixtures before calling the test function, and Hypothesis's @given wraps that function so the call pytest makes becomes a loop over generated examples. Fixtures are outside the loop; the body is inside it. Anything constructed in the body is therefore per-example, and anything supplied by a function-scoped fixture is per-test-function — shared by every example in the loop.

Isolation matters for more than correctness. Hypothesis shrinks a failure by replaying variations of the failing example, and replays assume each example's outcome depends only on its own inputs. Shared mutable state breaks that assumption: a shrunk example may pass because the state it depended on was left by an example that is no longer being run, and the shrinker reports a confusing or non-reproducible counterexample.

Edge cases and failure modes

  • Suppressing the check globally. Adding it to a settings profile silences the one warning that catches leaking state. Suppress per test, with a reason.
  • Mutable defaults in immutable-looking fixtures. A configuration object with a mutable dict attribute is not immutable. Freeze it, or build it in the body.
  • Database sessions. A shared session accumulates rows across examples. Open a savepoint at the start of each example and roll it back at the end, inside the body.
  • Fixtures with teardown. Teardown runs once after all examples, not after each. Resources acquired per example must be released per example, in the body.
  • Dependent generation. Drawing a value that depends on fixture data needs st.data() and data.draw(...) in the body, since strategies in @given are built before fixtures exist.

Sharing an expensive resource correctly

Some resources are genuinely expensive and must be shared — a database connection, a compiled model, a started server — while each example still needs a clean slate. The pattern is a shared resource from a fixture plus a per-example reset inside the body.

Python
from hypothesis import HealthCheck, given, settings


@settings(suppress_health_check=[HealthCheck.function_scoped_fixture],
          max_examples=50, deadline=None)
@given(order=orders())
def test_saved_order_round_trips(db_session, order):
    savepoint = db_session.begin_nested()       # per-example isolation
    try:
        repo = SqlOrderRepository(db_session)
        repo.add(order)
        assert repo.get(order.id) == order
    finally:
        savepoint.rollback()                    # nothing survives to the next example

The fixture provides the connection once; the savepoint gives each example a clean database; the finally guarantees the rollback even when the assertion fails and Hypothesis moves on to shrinking. The suppression is justified by the savepoint, and a short comment saying so keeps a future reader from removing either.

deadline=None is worth noting too. Database round trips vary in latency, and the default per-example deadline would flag a slow example as a failure even though the property holds. For properties involving I/O, disabling the deadline and bounding max_examples instead keeps the test both reliable and affordable.

A shared connection with per-example savepoints A database session fixture is created once for the test function. Inside the body, each generated example opens a savepoint, runs its operations and rolls back in a finally block, so examples share the expensive connection but none sees another's rows. Share the expensive part, reset the rest db_session fixture — one connection for the test function example 1 SAVEPOINT · add · get ROLLBACK TO example 2 SAVEPOINT · add · get ROLLBACK TO example 3 … clean database again shrinking replays safely
The connection is paid for once; every example still starts from the same state, which is what makes shrinking and replay trustworthy.

Drawing values that depend on fixtures

Strategies passed to @given are built before pytest resolves fixtures, so they cannot refer to fixture values. When generation needs to depend on a fixture — pick an existing customer id from a seeded database, choose a product from a loaded catalogue — st.data() moves the draw into the test body, where the fixture is available.

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


@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(data=st.data())
def test_discount_applies_to_any_catalogue_item(catalogue, data):
    # catalogue is an immutable fixture; the draw uses its contents.
    sku = data.draw(st.sampled_from(sorted(catalogue.skus)), label="sku")
    quantity = data.draw(st.integers(min_value=1, max_value=50), label="quantity")

    price = catalogue.price(sku, quantity, discount_code="TEN")
    assert price <= catalogue.price(sku, quantity)

The label arguments matter when a failure is reported: Hypothesis prints each interactive draw with its label, so the counterexample reads sku='SKU-7', quantity=1 rather than two anonymous values. Draws made through st.data() shrink just like ordinary arguments, so the reported case is still minimal.

The pattern also solves the opposite problem — generated data that later draws depend on. Draw an order first, then draw a line index from range(len(order.lines)); the second draw is constrained by the first, which a static @given signature cannot express without a composite strategy.

Static strategies versus interactive draws Strategies in the given decorator are built at import time, before fixtures exist, so they cannot use fixture data. The data strategy defers drawing into the test body, where fixtures are available, and each labelled draw appears in the counterexample and shrinks normally. Draw inside the body when the draw needs the fixture @given(sku=…) built at import time no access to fixtures cannot sample the catalogue data.draw(…, label=) drawn in the body fixture values available labelled, shrinks normally
Interactive draws cost nothing in shrinking quality; they only move the point at which generation happens.

Widening fixture scope instead

A third option, sometimes the cleanest, is to make the shared resource explicitly session- or module-scoped. Hypothesis's health check targets function-scoped fixtures specifically, because those are the ones that look per-test but are actually per-function; a session-scoped fixture is honestly shared, and nobody reading the code expects it to be fresh for each example.

That makes wider scope a good fit for resources that are both expensive and naturally immutable — a loaded machine-learning model, a parsed schema, a compiled regular-expression set, a read-only reference dataset. Declaring them at session scope removes the health-check warning without any suppression, makes the sharing obvious to readers, and saves the setup cost across every test in the session rather than only across one function's examples.

It is the wrong fit for anything mutable. A session-scoped database session or cart would leak state not just between examples but between tests, which is a larger version of the same problem. The rule that ties the three options together is simple: share only what cannot change, reset anything that can, and make the choice visible in the code — through scope, through a per-example reset in the body, or through a suppression with a comment explaining why the state cannot leak.

Frequently Asked Questions

Why does Hypothesis raise HealthCheck.function_scoped_fixture? Because a function-scoped fixture is created once for the whole test function, while the test body runs once per generated example. Any mutable state in the fixture is shared across all examples, so later examples see changes made by earlier ones. The health check flags that the examples are not isolated.

Is it ever safe to suppress that health check? Yes, when the fixture provides something immutable or stateless — a configuration object, a pure function, a read-only client — or when the test body explicitly resets the state at the start of every example. Suppress it on that test only, with a comment saying why it is safe.

Can I use @given on a fixture? No. Hypothesis drives test functions, not fixtures. If a fixture needs generated input, move the generation into the test with st.data() and draw inside the body, or build the object from drawn values in the test itself.

← Back to Hypothesis Integration with pytest & Frameworks