# Design pytest Fixtures as Explicit Resource Contracts

> A test that passes alone and errors in the full suite is almost never a flaky test — it is a fixture whose lifetime is wider than the state it holds, requested by nothing the test names. This guide works from what pytest actually reads when it orders fixtures, why one autouse fixture pulls its whole dependency graph into every test in scope, and why teardown after yield covers only the fixtures that already succeeded.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-18T13:30:00.000Z

- Updated: 2026-09-07T11:52:05.989Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/pytest-fixtures-explicit-resource-contracts

- Tags: pytest, python, test-architecture, test-fixtures, flaky-tests, test-isolation

**Quick answer:** pytest decides what a fixture does using exactly three inputs — its scope, the fixtures it requests as arguments, and whether it is autouse. Everything else you might be relying on, including where the fixture is defined and the order arguments appear, is documented as coincidence. So the contract a fixture offers is only as explicit as those three fields make it, and a fixture whose ownership you cannot read off its signature is a fixture nobody can safely change.

A test passes on its own and errors in the full suite. Not fails — errors. The report line reads `1 error in 12.44s` with no assertion in the traceback, because the assertion never ran.

The cause is usually a fixture the test does not name. Something autouse three directories up requested a database session, the database session requested a connection, and the connection was already torn down by a module-scoped fixture that finished earlier. None of that appears in the test's argument list, so the person debugging it starts by reading the test — the one file that has nothing to do with the problem.

## An erroring test has told you nothing

pytest draws a distinction that most dashboards flatten. From the fixtures explanation page: if a fixture raises, pytest "will stop executing fixtures for that test and mark the test as having an error," and being marked as an error "doesn't mean the test failed" — it means "the test couldn't even be attempted."

That difference is operational, not cosmetic. A failed test is a result: the system under test did something you did not expect, and the assertion is evidence. An errored test is an absence of a result. If your flake tracker counts both as "red," you will spend triage time on tests that never exercised a single line of production code, and you will not notice that your suite's real coverage dropped the day a shared fixture started raising.

Separate them in your reporting before you do anything else:

```
# Errors and failures are already distinct in the summary line.
pytest -q
# 3 failed, 11 errors, 402 passed in 96.31s
#          ^^^^^^^^^ these 11 tests produced no evidence at all

# -rE prints the short summary for errors only, which is where
# the fixture name and its file appear.
pytest -rE
```

The eleven are one bug in one fixture, counted eleven times. Fixing them is one change; treating them as eleven flaky tests is a week.

## The three inputs pytest reads

The reference is explicit about what determines execution order: scope, dependencies, and autouse. It is equally explicit about what does not. "Names of fixtures or tests, where they're defined, the order they're defined in, and the order fixtures are requested in have no bearing on execution order beyond coincidence." pytest adds that it tries to keep such coincidences stable between runs, but that this "is not something that should be depended on."

Read that as a constraint on design rather than a piece of trivia. If a fixture must run after another one, the only supported way to say so is to request it as an argument — even when you do not need its return value. Ordering by defining one above the other in `conftest.py` works until someone reorders the file.

```
# Wrong: the ordering is real but invisible, and pytest never promised it.
@pytest.fixture
def seeded_catalog(db_session):
    db_session.execute(INSERT_CATALOG)

@pytest.fixture
def priced_catalog(db_session):          # must run after seeded_catalog
    db_session.execute(UPDATE_PRICES)     # ...but nothing says so


# Right: the dependency is the ordering. priced_catalog does not use
# the value, it uses the guarantee that the fixture already ran.
@pytest.fixture
def priced_catalog(db_session, seeded_catalog):
    db_session.execute(UPDATE_PRICES)
```

Scope is the second input, and it dominates dependencies: higher-scoped fixtures execute before lower-scoped ones within a single test's request. The documented order for a test requesting one fixture at each scope is `session`, `package`, `module`, `class`, `function`. Teardown is the mirror image, so anything session-scoped is destroyed last and anything function-scoped first.

## Autouse pulls its whole graph along

This is the fact that turns a tidy `conftest.py` into a suite where unit tests need Postgres. From the reference: fixtures "requested by autouse fixtures effectively become autouse fixtures themselves for the tests that the real autouse fixture applies to."

One `autouse=True` does not add one fixture to every test in scope. It adds the fixture's entire transitive dependency graph.

```
# tests/conftest.py
#
# Wrong. This looks like one convenience fixture. It is four:
# db_session, feature_flags and clock all become effectively autouse
# for every test under tests/ — including the pure ones that parse
# strings and touch no I/O at all.
@pytest.fixture(autouse=True)
def seed_reference_data(db_session, feature_flags, clock):
    db_session.execute(INSERT_COUNTRIES)
    feature_flags.enable("checkout_v2")
    clock.freeze("2026-01-01T00:00:00Z")
```

The pytest docs note the limit of this propagation, and the limit is narrower than people assume: a non-autouse fixture pulled in this way "only effectively becomes an autouse fixture for the tests that the autouse fixture applied to." It does not become autouse globally. That is small comfort when the autouse fixture sits in the repository-root `conftest.py`, because then the tests it applies to are all of them.

The fix is to make the requirement explicit at the point that has it. `usefixtures` attaches a fixture to a class or module without threading it through argument lists:

```
# tests/checkout/test_pricing.py
#
# Right. The tests that need reference data say so. The tests in
# tests/parsing/ never touch a database again.
@pytest.mark.usefixtures("seed_reference_data")
class TestPricing:
    def test_applies_country_vat(self, priced_catalog): ...
    def test_rejects_unknown_country(self, priced_catalog): ...
```

The cost is real and worth naming: you now maintain a list of the places that need the fixture, and someone adding a new test module will forget it. The failure mode you get in exchange is a loud one — a missing fixture is an error at the test that needs it, not a hidden dependency at the 900 tests that do not. That trade is usually correct, but it is a trade, not a free win.

Autouse remains the right answer for cross-cutting concerns that genuinely apply to everything and create no business state: a fixture that fails the test if it logged an unhandled warning, or one that asserts no stray HTTP request escaped. The test for whether autouse is appropriate is not "is this convenient." It is "would a reader be surprised that this ran."

## Scope is a lifetime promise, not a speed dial

The documented values are `function`, `class`, `module`, `package` and `session`. Widening scope is normally reached for as a performance fix, which is why it gets applied to the wrong fixtures. Scope is a statement about how long a value may legally be shared, and sharing a mutable value is what makes tests order-dependent.

pytest enforces one direction of this. A fixture may only request fixtures of equal or wider scope; the reverse raises `ScopeMismatch`, because a session-scoped object cannot hold a reference to something destroyed after each test:

```
ScopeMismatch: You tried to access the 'function' scoped fixture
'monkeypatch' with a 'session' scoped request object
```

That specific message is common enough to be worth handling directly. Several pytest built-ins — `monkeypatch` and `caplog` among them — are function-scoped by design, so the first time a session-scoped fixture needs to set an environment variable, it hits the wall. The supported answer is documented on the `MonkeyPatch` class rather than in a workaround thread: since pytest 6.2 it "can now also be used directly as `pytest.MonkeyPatch()`, for when the fixture is not available," and in that case you must "use `with MonkeyPatch.context() as mp:` or remember to call `undo()` explicitly."

```
@pytest.fixture(scope="session")
def stub_payment_gateway():
    # The fixture is unavailable at session scope; the class is not.
    with pytest.MonkeyPatch.context() as mp:
        mp.setenv("PAYMENTS_BASE_URL", "http://127.0.0.1:9411")
        yield
    # Leaving the with block undoes the patch. Without the context
    # manager you own the undo(), and forgetting it leaks into every
    # later session-scoped fixture.
```

For expensive resources, the pattern that keeps both properties is to split the fixture along the mutable boundary rather than widening the whole thing:

```
# Session scope for the thing that is expensive and immutable.
@pytest.fixture(scope="session")
def engine():
    engine = create_engine(os.environ["TEST_DATABASE_URL"])
    yield engine
    engine.dispose()

# Function scope for the thing that is cheap and mutable. Each test
# gets a transaction that is rolled back, so no test can observe
# another test's rows regardless of execution order.
@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()
```

The connection handshake happens once. The isolation happens every test. Widening `db_session` itself to session scope would have bought the same startup saving and paid for it with a suite whose result depends on collection order.

## Teardown only covers what already succeeded

A `yield` fixture's teardown runs "regardless of the test outcome," which is why it is the recommended form. It does not run regardless of the *fixture's* outcome, and that is where resources leak. The docs state it plainly: "If a yield fixture raises an exception before yielding, pytest won't try to run the teardown code after that yield fixture's yield statement. But, for every fixture that has already run successfully for that test, pytest will still attempt to tear them down as it normally would."

So a fixture that performs several state-changing actions before its yield is a leak waiting for the second one to fail:

```
# Wrong. If create_subscription raises, the customer created on the
# line above it is never deleted — the teardown below the yield was
# never reached. Run this in CI for a month and the test tenant has
# thousands of orphaned customers.
@pytest.fixture
def subscribed_customer(api_client):
    customer = api_client.create_customer(unique_email())
    subscription = api_client.create_subscription(customer.id, plan="pro")
    yield customer, subscription
    api_client.delete_subscription(subscription.id)
    api_client.delete_customer(customer.id)
```

pytest's own recommendation for this is structural: limit "fixtures to only making one state-changing action each, and then bundling them together with their teardown code." One create per fixture means every create that succeeded has its own teardown already registered.

```
# Right. If create_subscription raises, pytest still tears down
# customer, because that fixture completed. The test is reported as
# an error and the tenant is left clean.
@pytest.fixture
def customer(api_client):
    created = api_client.create_customer(unique_email())
    yield created
    api_client.delete_customer_if_present(created.id)

@pytest.fixture
def subscription(api_client, customer):
    created = api_client.create_subscription(customer.id, plan="pro")
    yield created
    api_client.delete_subscription_if_present(created.id)
```

Note the `_if_present` naming. Teardown runs after a test that may have deleted the resource itself, so a delete that raises 404 turns a green test into an error report about cleanup. Idempotent teardown is not defensive programming here; it is the only shape that survives a test suite that exercises deletion.

When one fixture genuinely cannot be split — an SDK call that creates two coupled objects in a single request — `request.addfinalizer` gives you finer control, and its guarantee is the one you want. pytest "will run that finalizer once it's been added, even if that fixture raises an exception after adding the finalizer." Register it on the line after the create, before anything else that can fail:

```
@pytest.fixture
def tenant(api_client, request):
    created = api_client.provision_tenant()
    # Registered immediately: from here on, failure still cleans up.
    request.addfinalizer(lambda: api_client.deprovision_if_present(created.id))
    api_client.wait_until_ready(created.id, timeout=120)  # may raise
    return created
```

Finalizers run "in a first-in-last-out order," and yield fixtures are implemented on top of the same mechanism — "when the fixture executes, addfinalizer registers a function that resumes the generator." Mixing the two styles in one graph is therefore safe: they share one ordering, not two.

## Make the graph readable without opening conftest

Everything above is unenforceable if nobody can see the graph. pytest ships the introspection for this and almost nobody runs it.

```
# What would run, in what order, without executing anything.
# This is the fixture dependency graph, per test.
pytest --setup-plan tests/checkout/test_pricing.py

# The same, but during a real run, so you see it interleaved with results.
pytest --setup-show tests/checkout/test_pricing.py

# Which fixtures each test actually pulls in — the fastest way to find
# an autouse fixture that is dragging a database into a unit test.
pytest --fixtures-per-test tests/parsing/

# Every fixture visible from here, with its docstring and defining file.
pytest --fixtures tests/
```

Two details make the output trustworthy. Fixtures whose names begin with an underscore are hidden from `--setup-plan` and `--fixtures` unless you add `-v`, so a private fixture is invisible in exactly the report you are using to audit privacy. And the docstring on a fixture is what `--fixtures` prints — which makes it the right place to record the resource's owner and its cleanup guarantee, because that is the one piece of documentation a colleague will actually encounter.

```
@pytest.fixture
def customer(api_client):
    """One customer in the test tenant, deleted on teardown.

    Owner: checkout team. Created via the admin API, not the database,
    so the row carries the same defaults production rows do.
    Teardown is idempotent: safe if the test deleted it already.
    """
    created = api_client.create_customer(unique_email())
    LOG.info("fixture customer created id=%s", created.id)
    yield created
    api_client.delete_customer_if_present(created.id)
```

That log line is the other half of the evidence. When a nightly run leaves 40 orphaned records, the identifiers in the log are what map them back to the run that created them; without it you have a cleanup problem and no way to attribute it.

One naming trap is worth knowing because its symptom is confusing. If a fixture is used in the same module where it is defined, the function name is shadowed by the argument that requests it. The documented resolution is to name the function `fixture_<name>` and pass `@pytest.fixture(name='<name>')`, which keeps the fixture's public name short while leaving the function callable.

## When a wide fixture is the honest choice

The rules above optimise for legibility and isolation, and both have a price. A suite built entirely from single-action function-scoped fixtures will be slower and will have a deeper graph — `--setup-plan` output for one test can run to thirty lines, which is its own kind of unreadable.

Some resources belong at session scope and splitting them is theatre: a container started once per run, a compiled binary, a downloaded model file, a browser process. What makes those safe is not their scope but their mutability. If nothing a test does can change what the next test sees, session scope costs you nothing. The moment a session-scoped resource accumulates state — a queue, a cache, a schema — you have converted execution order into a hidden input, and the failure will surface as a test that only breaks when run after a specific other test.

The question to ask about any widening, then, is not "how much time does this save." It is "what would a second test have to do to make the first one wrong." If you cannot answer that, the fixture is not ready to be shared.

## Apply this now

Run `pytest --fixtures-per-test` against the fastest, most isolated directory in your suite — the one full of tests that should touch nothing. Every fixture listed there that the tests do not name in their arguments is arriving via autouse, and each one is a dependency your unit tests have without declaring it.

Then grep your `conftest.py` files for `autouse=True` and, for each hit, read its argument list. That list is the real blast radius. Convert any of them that create business state to `@pytest.mark.usefixtures` on the modules that need them.

Two artefacts tell you it worked: a `--setup-plan` output for one representative test that a new joiner can read top to bottom, and a run log in which every created resource identifier appears exactly twice — once at creation, once at teardown.

## Questions about fixture ownership

### Why does a test pass alone and error in the full suite?

Almost always a scope that is wider than the state it holds. Running one file means the module- or session-scoped fixture is built fresh and used once; running the suite means an earlier test mutated it first. Reproduce it with `pytest --setup-show` on the two-test combination rather than the whole suite — the setup lines show you which fixture was reused rather than rebuilt, which is usually enough to identify it without a bisect.

### Is autouse ever the right call?

Yes, for fixtures that observe rather than create. Failing a test that emitted an unexpected warning, asserting no outbound HTTP request escaped the sandbox, or resetting a global registry that some import populated are all reasonable autouse fixtures, because a reader is not surprised they ran and they add nothing to the dependency graph. The rule that catches the bad cases is the argument list: an autouse fixture that requests other fixtures is making those fixtures autouse too, and that is rarely what was intended.

### Can request.getfixturevalue get around the scope rules?

It exists for cases where "you can only decide whether to use another fixture at test setup time," and pytest states that "declaring fixtures via function argument is recommended where possible." Using it to dodge `ScopeMismatch` replaces an error at collection with a bug at runtime. It is also narrowing: as of pytest 9.1, calling `getfixturevalue()` during teardown to request a fixture that was not already requested is deprecated. If you reach for it in a finalizer, request the fixture properly instead.

### Should a fixture verify its own cleanup?

Verify, but do not assert loudly for things the test may legitimately have done. A teardown that raises because the record was already gone converts a correct test into an error, and the error points at the fixture rather than at anything real. Make the delete idempotent, and put the strict check in a separate session-scoped fixture that runs at the end of the run and reports leftover resources for the whole suite. One report about ten orphans is actionable; ten teardown errors are noise.

### Does a wider scope actually make suites faster?

Only when the setup cost dominates, and you should measure before assuming it does. `--setup-show` prints setup and teardown lines as they happen, and `--durations=20` shows where the time actually went. The common surprise is that the expensive part was never the fixture — it was a fixed sleep or a retry loop inside it, which widening the scope hides instead of fixing.

## Primary references

- [pytest — About fixtures: Fixture errors](https://docs.pytest.org/en/stable/explanation/fixtures.html#fixture-errors). Establishes that a raising fixture stops fixture execution and marks the test as an error, and that an error is not a failure because the test could not be attempted.

- [pytest — Fixtures reference: Fixture instantiation order](https://docs.pytest.org/en/stable/reference/fixtures.html#fixture-instantiation-order). The three factors pytest considers, and the explicit statement that definition location and argument order have no bearing on execution order beyond coincidence.

- [pytest — Autouse fixtures are executed first within their scope](https://docs.pytest.org/en/stable/reference/fixtures.html#autouse-fixtures-are-executed-first-within-their-scope). That fixtures requested by an autouse fixture effectively become autouse for the tests it applies to, and the limit of that propagation.

- [pytest — How to use fixtures: Handling errors for yield fixture](https://docs.pytest.org/en/stable/how-to/fixtures.html#handling-errors-for-yield-fixture). That teardown after `yield` is skipped when the fixture raises before yielding, while already-successful fixtures are still torn down.

- [pytest — Safe fixture structure](https://docs.pytest.org/en/stable/how-to/fixtures.html#safe-fixture-structure). The one-state-changing-action-per-fixture recommendation, and why bundling several into one fixture leaves state behind.

- [pytest — Note on finalizer order](https://docs.pytest.org/en/stable/how-to/fixtures.html#note-on-finalizer-order). First-in-last-out finalizer ordering, and that `yield` fixtures are implemented with `addfinalizer` behind the scenes.

- [pytest — API reference: `pytest.fixture`](https://docs.pytest.org/en/stable/reference/reference.html#pytest.fixture). The five scope values, the guarantee that teardown runs regardless of test outcome, and the `name=` parameter for same-module shadowing.

- [pytest — API reference: monkeypatch and `MonkeyPatch`](https://docs.pytest.org/en/stable/reference/reference.html#monkeypatch). That `pytest.MonkeyPatch()` can be used directly since 6.2 when the function-scoped fixture is unavailable, with `context()` or an explicit `undo()`.

- [pytest — Command-line flags](https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags). What `--setup-plan`, `--setup-show`, `--fixtures` and `--fixtures-per-test` each report, and that leading-underscore fixtures need `--verbose`.

- [pytest — `FixtureRequest.getfixturevalue`](https://docs.pytest.org/en/stable/reference/reference.html#pytest.FixtureRequest.getfixturevalue). That argument declaration is recommended where possible, and that requesting a not-already-requested fixture during teardown is deprecated as of 9.1.

## Continue reading on AutomationTester.in

- [Detect Breaking API Changes Before Consumers Do](https://automationtester.in/blog/automation-tutorials/detect-breaking-api-changes-before-consumers)
- [Verify Pagination, Filtering, and Sorting Contracts](https://automationtester.in/blog/automation-tutorials/api-pagination-filtering-sorting-contract-tests)
- [Test API Authorization Beyond Happy-Path Tokens](https://automationtester.in/blog/automation-tutorials/api-authorization-testing-object-function-property)
- [Test API Idempotency Under Retries and Timeouts](https://automationtester.in/blog/automation-tutorials/test-api-idempotency-retries-timeouts)
- [Design Negative API Tests from the Contract](https://automationtester.in/blog/automation-tutorials/design-negative-api-tests-from-contract)

Source: [Design pytest Fixtures as Explicit Resource Contracts](https://automationtester.in/blog/automation-tutorials/pytest-fixtures-explicit-resource-contracts) by Shashank Rawlani.
