Fixtures and parametrize: dependency injection, scopes, and teardown order
Fixtures resolve by name and cache per scope: session scope amortizes expensive setup but shares mutable state — the order-dependent failure class. yield puts teardown beside setup and unwinds in reverse. parametrize stacks cartesian; indirect=True routes params through fixtures.
The billing suite had been green for months — until the Tuesday it failed 40 tests in CI while every one of them passed locally. pytest tests/billing/test_invoices.py: 22 passed. Full suite: 40 failed, all of them downstream of test_upgrades.py in collection order. The cause sat in a session-scoped tenant fixture: one provisioned tenant dict for the whole run — 8 seconds of setup paid once instead of 300 times, a deliberate and sensible optimization. A new test exercised the upgrade path with tenant["plan"] = "enterprise" and never put it back. Every test that resolved tenant afterwards inherited an enterprise tenant: quota assertions off by a tier, invoice totals wrong, 40 red tests whose tracebacks pointed nowhere near the cause. Two engineers lost a day bisecting test order — the failures vanished under -k, reshuffled under -p no:randomly, and --lf re-ran only the victims, which passed. The fix was three lines of teardown. The lesson was the scope contract: session scope hands every test the same object, and pytest does not police what they do to it.
Fixtures are dependency injection, resolved by name
Before diving into the mechanics: if you have ever asked “why does this test build a database it never uses”, or found teardown scattered across setUp, tearDown, and five helper methods, fixtures are the answer — they wire exactly what a test needs, nothing more.
A test declares what it needs as parameter names; pytest resolves each name to a fixture — looked up in the test module first, then up the conftest.py chain (nearest wins), then plugins — calls it, and caches the result for the duration of its scope. Fixtures request other fixtures the same way, so what executes is a dependency graph, built per test, with each node instantiated at most once per scope unit:
import pytest, sqlite3
@pytest.fixture
def db(tmp_path): # fixtures compose: db depends on tmp_path
conn = sqlite3.connect(tmp_path / "t.db")
yield conn # setup above the yield, teardown below
conn.close() # runs even if the test failed
def test_insert(db): # injected by NAME — no imports, no registry
db.execute("create table t (x int)")This is why fixtures beat setUp methods structurally: a test names exactly what it uses, unused machinery is never built, and the same fixture serves one test or four hundred without inheritance gymnastics. The request fixture exposes the resolution context — the requesting test, its markers, request.param — which parametrization will need below.
Scopes: amortization versus isolation
Scope is the cache key. function (the default) rebuilds per test — full isolation, full price. module and session build once per module or per run; class and package exist between. The arithmetic that pushes teams to broad scopes is real: a Postgres testcontainer at ~8 s of startup, 300 integration tests — function scope spends 40 minutes on setup alone, session scope spends 8 seconds. The price is the Hook: the cache returns the same object to every consumer, and one mutation becomes ambient state for every test that follows. That is the order-dependent failure class — green alone, red in suite, unreproducible by --lf because the mutating test is not in the re-run set. The standard resolution is layered: session-scoped engine or container (expensive, effectively immutable), function-scoped connection that opens a transaction and rolls back in teardown (cheap, perfectly isolated). Never hand tests a mutable session-scoped object directly; wrap it in a function-scoped view, a fresh copy, or a transaction.
A session-scoped fixture returns a dict of tenant config. One test mutates it. What is the observable failure pattern?
yield fixtures: teardown that always runs, in reverse
Everything above the yield is setup; everything below is teardown, and it runs even when the test fails — fixture finalization is driven by pytest’s finalizer stack, not by the test outcome. Two precise edges: if the fixture’s own setup raises, its teardown never started, but fixtures that completed setup earlier still unwind; and teardown order is the reverse of resolution order — if the test resolved db → schema → user, teardown runs user → schema → db, like nested context managers. Dependents release before their dependencies, which is the only order that does not close a connection while something still holds it. Broader scopes do not break the rule; they defer it — a session-scoped db finalizes at session end, still after everything that depended on it.
Factory fixtures: per-test variation without scope games
When tests need different objects, not the same one, return a builder instead of a value — and keep cleanup in the fixture:
@pytest.fixture
def make_user(db):
created = []
def _make(plan="free", **kw):
u = create_user(db, plan=plan, **kw)
created.append(u)
return u
yield _make
for u in reversed(created): # LIFO, mirroring fixture teardown
delete_user(db, u)
def test_upgrade(make_user):
u = make_user(plan="free") # this test decides the shapeThis is the clean answer to the Hook incident: the upgrade test calls make_user(plan="free") and mutates its own private user; the shared tenant stays untouched. autouse=True is the opposite move — a fixture injected into every test in its scope without being named. It has legitimate uses (freezing time, seeding RNG, isolating env) and one systemic cost: invisible coupling — a reader of the test cannot see why behavior differs, and a future autouse fixture taxes every test in the subtree whether or not it is relevant. Default to explicit names. conftest.py layering follows the same locality principle: the nearest conftest wins, so tests/integration/conftest.py can redefine settings and override the root fixture for its subtree only.
parametrize: the cartesian machine
@pytest.mark.parametrize("pct", [0, 15, 100])
@pytest.mark.parametrize("cents", [1, 1999], ids=["min", "typical"])
def test_discount(cents, pct): # stacked decorators multiply: 2 x 3 = 6 tests
...Stacked parametrize decorators produce the cartesian product — 6 generated tests here, each an independent item that fails alone, shows in --lf, and reports as test_discount[typical-15] thanks to ids=. Without ids, large parametrizations fail as test_discount[1999-15] and someone greps for which case that was. indirect=True routes a parameter through a fixture: the value arrives as request.param inside the fixture, which transforms it before the test sees it — the idiom for “run this suite against postgres and sqlite”, where the param is a backend name and the fixture builds the actual engine. The same effect from the other side: @pytest.fixture(params=["postgres", "sqlite"]) parametrizes every test that depends on the fixture, no decorator at the call site.
One more injected tool earns its place here: monkeypatch is a function-scoped fixture with setattr, setitem, setenv, delenv, chdir, syspath_prepend — every change automatically undone at teardown, which is exactly the guarantee hand-rolled os.environ pokes lack. Reach for unittest.mock instead when you need call records, side_effect, or spec-checked stand-ins; monkeypatch replaces values, mock observes interactions.
A test uses `user`, which depends on `schema`, which depends on `db` — three function-scoped yield fixtures. The test FAILS. Which teardowns run, in what order?
- 01Explain the fixture mechanism end to end: resolution, caching, and why a session-scoped fixture produced failures that only appeared in full-suite order.
- 02State the yield-fixture teardown rules, the factory-fixture pattern, and what indirect=True does in parametrize.
The fixture system is dependency injection with a cache, and every behavior in this lesson falls out of that sentence. Resolution is by name through the module, the conftest chain (nearest wins), and plugins; the cache key is scope. Function scope buys isolation at full price; session scope buys an 8-second container once instead of 300 times and pays in shared mutable state — the billing incident, where one tenant["plan"] = "enterprise" turned 40 downstream tests red in an order that --lf could not even reproduce. The disciplined shape layers an immutable broad-scoped core under function-scoped transactional views, or replaces sharing with factory fixtures that build per-test objects and clean them up in reverse after the yield — the same LIFO rule the fixture stack itself follows, dependents before dependencies, failure or not. autouse trades visibility for convenience and should be rare; conftest layering keeps overrides local. parametrize multiplies stacked decorators into cartesian products with ids= for readable failures, routes values through fixtures with indirect=True or params=, and monkeypatch covers env and attribute swaps with guaranteed undo, leaving unittest.mock for when you need to observe calls rather than replace values. Now when you see failures that vanish under -k but recur in the full suite, your first question should be: which session-scoped fixture hands tests a mutable object without a function-scoped wrapper?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.