Developer Tools · Test Tooling

Fixture Scoping and the Lifetime of Shared Test State

How setup and teardown become a dependency graph with scoped lifetimes, why a session-scoped object couples tests that never mention each other, and how teardown failures are attributed.

Every test needs something to run against: a temporary directory, a parsed configuration, a database handle, a server listening on a port. The naive answer is a setup function called before each test and a teardown function after, which is what the classic xUnit pattern of setup and teardown describes. The moment the same resource is needed by two tests that differ only in what they do with it, the setup cost is paid twice and the obvious optimisation — build it once — introduces a coupling problem that no amount of care in the setup function removes.

Setup and teardown are a lifetime problem

The formal framing is that a test has four phases: setup, exercise, verify, teardown. In the simplest implementation all four live in one function body, and setup is cheap because it is local. As soon as setup is factored out, an ordering constraint appears: teardown must run in the reverse of setup order, and non-local resources must be released even when the test fails. Exceptions propagate through the exercise phase, so teardown either runs in a finally-style finally block or it does not run at all, and the version that does not run leaves a port bound or a temporary directory behind for the next test to trip over.

The second constraint is that setup can fail. A fixture that tries to bind a port already in use raises before the test body starts, and the runner has to decide how to report that. The distinction frameworks make is between failure, meaning the test ran and its assertions lost, and error, meaning the test never got a fair chance. That distinction is load-bearing for automated triage: an error in a shared fixture is one root cause reported against many tests, and tooling that counts both kinds as “red” produces an inflated list of symptoms. Grouping those symptoms requires stable failure text, the sort that How Assertion Libraries Recover Operands From a Failed Comparison discusses, since the same fixture failure appears once per dependent test.

The fixture graph and its instantiation order

Modern runners do not have a setup function. They have fixtures declared as ordinary functions, requested by name in the parameters of a test or of another fixture, which makes the set of fixtures required by a test into a directed graph. A fixture that asks for a connection and a configuration is a node with two dependencies.

Resolution walks that graph. A fixture is instantiated when a test that requires it is about to run, its dependencies first, and the resulting object is cached under a key that combines the fixture’s identity with its scope. Finalisers run in reverse order of instantiation at the boundary of the scope. The consequence worth internalising is that a fixture is not a function call per test; it is an object with a lifetime, and the lifetime is what tests share.

Scope is the knob that sets the lifetime. Python’s pytest offers function, class, module, package, and session scopes, and the scope must be at least as wide as the widest scope among dependencies — a session-scoped fixture cannot depend on a function-scoped one, and the framework rejects the declaration rather than silently instantiating it many times. Go’s testing package has no fixture declarations at all; a helper that builds a resource is an ordinary function, and its release is registered with the test through a cleanup registry that runs in reverse order when the test finishes. Java’s JUnit has @BeforeEach and @BeforeAll with corresponding teardown annotations, plus injection of constructed objects into parameters, which is the same dependency-graph idea expressed through the type system.

What a shared object costs

Widening a scope converts a per-test cost into a per-scope cost. If setup takes time T and a suite contains N tests that each need the resource, per-test scoping pays N·T and per-session scoping pays T. For a resource whose setup is dominated by process spawn or network handshake, that difference is the main reason suites are not already all-per-test. For a resource whose setup is a dictionary construction, T is small enough that the wider scope buys nothing and costs something.

What it costs is state. A shared fixture is a mutable object visible to every test in the scope, and mutation by one test is visible to the next. If the suite always runs in the same order, the code that depends on that mutation passes, and the dependency is invisible because it never fails. The failures appear when something changes the order — a shuffled run, a partial selection, a different worker distribution, or simply a new test inserted earlier alphabetically. This is the same class of bug that Distributing Tests Across Workers Without Losing Determinism exposes when it splits a suite across processes, and the shape of the report is characteristic: tests that pass in a full serial run and fail in isolation, or the reverse.

The standard mitigation is to reset rather than recreate. A database-backed fixture opens one connection and wraps each test in a transaction that is rolled back at the end, so the table definitions are paid once and the row state is not shared. The pattern generalises: any resource whose expensive part is structural and whose cheap part is content can be shared at the structural level and reset at the content level. It requires the reset to be complete, and the failure mode when it is not is a leak that shows up as a test failing only when another test ran before it.

import pytest

@pytest.fixture(scope='session')
def engine():
    conn = open_connection()          # expensive: paid once per session
    yield conn
    conn.close()                      # runs at the end of the session

@pytest.fixture
def tx(engine):
    cur = engine.cursor()
    cur.execute('BEGIN')
    yield cur
    cur.execute('ROLLBACK')           # cheap: content state discarded per test

The structure above is the reason the two scopes coexist. engine holds structure; tx holds content, and because it is function-scoped, each test sees the schema without seeing the previous test’s rows. The trade is that a test which commits cannot be isolated this way, and a fixture that needs its own transaction cannot nest inside one.

When teardown fails

Teardown failures are reported against the test that was running when the scope ended, which is not always the test that caused them. A function-scoped finaliser that fails is attributed to the correct test. A session-scoped finaliser that fails runs after the last test, and frameworks attribute it to a synthetic final node or to the last test that used the fixture; either way, the association is a report artefact, not a cause.

Two further consequences follow. First, an exception inside teardown can suppress the test’s own result: if the assertion failed and the teardown then raised, some runners report both and some report only the teardown error, so a real regression can be replaced in the report by a spurious cleanup problem. Second, skipped tests can still hold fixture resources. A skip evaluated inside the test body occurs after setup has run, so teardown is still required; a skip evaluated before setup means no fixture was instantiated and no finaliser is registered. The difference is observable in the number of connections opened, which is why skipping in a fixture versus skipping in the body is not equivalent.

A final constraint is that finalisers are LIFO within a scope and scopes end at their own boundaries, not at the end of the suite. A package-scoped fixture used by exactly one test in one package is torn down when that package’s last test finishes, even though tests in later packages might have benefited. Widening a scope to fix that converts an ordering artefact into a global dependency, which is usually the wrong trade unless the test set is small.

Choosing a scope

The decision rule that survives contact with real suites is to make the scope as narrow as the resource permits and to make the resource as narrow as the assertions require. In-process objects that exist to be mutated should be function-scoped; processes, servers, and parsed schemas can be session-scoped if they are treated as immutable after construction or reset per test. The awkward middle case is a resource that is expensive to construct and intrinsically mutable — a service with in-memory state. There the options are to construct it once and reset it through an interface designed for the purpose, or to construct it once and accept the coupling, documenting the ordering requirement explicitly rather than discovering it during a shuffle. Fixture scope therefore determines not just performance but the size of the blast radius of any test that misbehaves, which is the same reasoning that Isolation Boundaries: In-Process, Subprocess, and Sandboxed Tests applies one level down, at the process boundary.

References

  1. xUnit Test Patterns: Refactoring Test Code — Gerard Meszaros
  2. pytest documentation — How to use fixtures — pytest project
  3. Go testing package documentation — The Go project
  4. JUnit 5 User Guide — JUnit team