@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
hypothesis >= 6.100andpytest >= 8.0.- The execution model from Hypothesis integration with pytest and frameworks.
Solution
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.
# 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
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()anddata.draw(...)in the body, since strategies in@givenare 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.
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.
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.
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.
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.
Related
- Hypothesis Integration with pytest & Frameworks — the one-item-many-executions model.
- Property-Testing Django Models with Hypothesis — per-example transactions handled by the framework.
- Rolling Back Every Test with Nested Transactions — the savepoint mechanism used above.
- Fixing Hypothesis Flaky Health Check Failures — the other health checks and what they mean.