Skip to content

← Tests That Earn Their Keep step 4 of 19

Medium Framework

Fixture setup and teardown order

When a fixture’s teardown does not run, the next test in the file inherits a half-built world and fails somewhere completely unrelated. The pytest docs are blunt about the cause:

if any of those steps in the setup raise an exception, none of the teardown code will run

That single sentence is the whole argument for the rule most suites break: one state-changing action per fixture, bundled with its own cleanup. A fixture that creates a database, seeds it, opens a connection pool and starts a background worker has four ways to die halfway through — and if it dies on step three, steps one and two are never undone. Split it into four fixtures that depend on each other and pytest tears down exactly the ones that succeeded, in reverse order.

Which means reading a conftest.py correctly is reading a dependency graph. This problem makes you build the reader.

What to write

def resolve_fixture_order(
    deps: Mapping[str, Sequence[str]], requested: Sequence[str]
) -> list[str]

def teardown_order(setup: Sequence[str]) -> list[str]

deps is the whole conftest: fixture name to the fixtures it requests. requested is what one test asked for in its signature.

resolve_fixture_order returns the setup order for exactly the fixtures that test needs — the transitive closure of requested, and nothing else. A fixture that exists in the conftest but nobody reached is never set up, which is why an expensive session fixture nobody uses costs nothing.

Ordering rules:

  • A fixture is set up only after every fixture it depends on.
  • Among the fixtures whose dependencies are all satisfied, take the alphabetically smallest. Real pytest orders by scope and then by the order parameters are declared; we use name order here so that one input has exactly one right answer.
  • A name that is not a key in deps is a leaf — that is how builtins like tmp_path and capsys appear in a graph you did not write.
  • Duplicates in requested are the same fixture. It appears once, because pytest caches one instance per fixture per scope.

teardown_order is exactly the reverse of the setup list. That is not a convenience — it is the only order in which each fixture still sees the world its dependencies built. request.addfinalizer follows the same first-in, last-out discipline within a single fixture.

If no order exists, raise FixtureCycleError with the message

fixture cycle: <names, sorted, comma-separated>

listing every fixture still unresolved when progress stops. Naming them all matters: a cycle is never one fixture’s fault, and the reader needs the whole set to see which edge to cut. A fixture that requests itself is the degenerate case and must produce the same error.

solve is provided and turns the exception into the report’s error field.

Why Mapping and Sequence

The parameter types say what the function does with them: deps is looked up and never written, requested is iterated and never sorted in place. Annotating either as dict/list would let a future edit mutate a caller’s conftest model, and the type checker would say nothing.