AlgoMaster Logo

Test Fixtures

Medium Priority30 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Tests need setup. A cart needs items, an inventory needs stock, an order needs a customer. Doing that setup inline in every test produces duplication, hides what each test actually cares about, and breaks badly when the setup itself changes. Pytest fixtures replace that mess with named, reusable setup functions that pytest injects into tests on demand, with explicit teardown that runs even if the test fails.

The Problem Fixtures Solve

If you've come from unittest, you've used setUp and tearDown: methods that run before and after every test in a class. They work, but they have problems. The setup is implicit (you don't see it in the test signature, you have to remember the class runs it). It's all-or-nothing (every test in the class gets the same setup, even tests that don't need half of it). And the teardown runs in a different scope, which means sharing state with self is the only option, and self becomes a junk drawer.

Here's what the duplication looks like without any helpers:

The first three lines of each test do the same thing. If the cart's shape changes (say quantity becomes qty), every test breaks. Worse, the actual subject of each test (the total calculation, the discount) is buried under setup noise.

The unittest way to fix this looks like:

Better, but setUp is implicit (you can't tell from test_total_with_discount that it depends on cart setup unless you read the class) and it's an all-or-nothing affair. A test that doesn't need a cart still pays for one. And if you have two different starting carts, you end up with two test classes or conditional logic in setUp.

Pytest fixtures fix both problems. Setup becomes a named function. Tests declare what they need by listing fixture names as parameters. Pytest figures out which fixtures to call, in what order, and when to tear them down.

@pytest.fixture Basics

A fixture is a function decorated with @pytest.fixture. The function builds whatever the test needs and returns it. Tests request the fixture by adding a parameter with the matching name. Pytest sees the parameter name, calls the fixture, and passes the return value in.

Run that file with pytest -v:

Two tests, one fixture, zero duplication. Each test parameter named sample_cart triggers a call to the sample_cart fixture function, and the return value gets passed in. The test doesn't import anything; pytest finds the fixture by name.

Notice each test gets its own copy. The fixture function runs once per test by default, so if test_cart_total mutated the cart, test_cart_item_count would still see the original two items. Test isolation is the default, and that's almost always what you want.

You can request multiple fixtures by listing more parameters:

Pytest reads the parameter names, calls each fixture, and injects the results. Order doesn't matter; you can ask for them in any sequence.

Fixture Scopes

By default, a fixture runs once per test. That's fine for cheap setup (building a dict, instantiating a class), but expensive setup (spinning up a database, loading a large file, starting a server) becomes painful if it repeats for every test. Scopes let you control how often a fixture runs.

A scope says "reuse the same fixture value across this group of tests". Pytest gives you five scopes, listed from narrowest to widest:

ScopeFixture runs once perUse when
function (default)Test functionSetup is cheap, isolation matters
classTest classTests in a class share read-only data
moduleFileA whole file of tests shares an expensive resource
packageDirectoryAll tests in a subdirectory share something
sessionWhole test runThe setup is heavy and the result is truly immutable

Set the scope by passing it to the decorator: @pytest.fixture(scope="module").

Run with pytest -v -s (the -s lets the print output through):

inventory_db builds once. fresh_cart builds twice, once per test. The test_starts_empty test sees an empty cart, not the one test_add_item mutated, because the function-scoped fixture rebuilt it.

The relationship between scopes is strictly nested. A session-scoped fixture lives longer than a module-scoped fixture, which lives longer than a function-scoped one. The diagram below shows how they nest in time.

A higher-scoped fixture cannot depend on a lower-scoped one. A session fixture can use other session fixtures, but it can't request a function-scoped fixture; that would break the contract, since the function-scoped one is supposed to be rebuilt per test. Pytest enforces this and gives you a clear error if you mix scopes wrong:

Picking the right scope is mostly about answering "if test B sees what test A did to this fixture, is that a bug?" If yes, use function. If no, you can widen it.

Yield Fixtures and Teardown

Fixtures need to clean up after themselves. A fixture that opens a temporary database has to close it. A fixture that creates a file has to delete it. Pytest handles teardown with a yield statement: the code before yield is setup, the code after is teardown.

Run with pytest -v -s:

The flow is clear: pytest runs the setup, hands the yielded value to the test, runs the test, then runs the teardown. Note the structure: one yield per fixture. The value after yield becomes the fixture's value. Anything before yield is setup; anything after is teardown.

The big guarantee here is that the teardown runs even if the test fails. Mutate the dict to prove it:

The teardown still ran even though the assertion failed. That's the point: cleanup isn't tied to the test passing. This is what makes yield fixtures dramatically safer than unittest's setUp/tearDown pair, where errors in setUp or tearDown can mask each other.

A return fixture is fine when there's nothing to clean up:

A yield fixture is what you reach for the moment cleanup is needed:

If you find yourself wanting two yields in one fixture, split it into two fixtures. The yield is supposed to mark a single transition from setup to teardown.

Fixture Composition

Fixtures can depend on other fixtures. The composition rule is simple: list the dependency as a parameter, the same way a test does. Pytest builds a dependency graph, calls each fixture in the right order, and reuses values where scopes allow.

Reading top to bottom: sample_cart depends on empty_cart and inventory_db. empty_cart depends on customer. When test_sample_cart_belongs_to_customer runs, pytest figures out the order: build customer, then inventory_db, then empty_cart, then sample_cart. The test asks for sample_cart and customer, and pytest reuses the same customer value across both, because it's the same test and the default scope reuses values within one test.

The diagram below shows the resolution and teardown order pytest follows when a test requests sample_cart and customer:

Setup order, deepest first: customer and inventory_db, then empty_cart, then sample_cart, then the test runs. Teardown order is LIFO (last in, first out): if sample_cart had a yield, its teardown would run first; then empty_cart's teardown; then inventory_db's; then customer's. That's the same order you'd get from nested with blocks, and it's what you want, since later fixtures may depend on earlier ones still being alive.

Composition is the trick that makes fixtures scale. Instead of one giant mega_fixture that builds everything, you have small named fixtures that snap together. Tests pick the ones they need. New fixtures reuse existing ones. Refactoring a single fixture updates everywhere it's used.

conftest.py: Shared Fixtures

Fixtures defined in a test file are only visible to that file. When you want a fixture shared across multiple test files, put it in conftest.py. Pytest auto-discovers conftest.py files: it walks up from each test file's directory and loads any conftest.py it finds, making the fixtures in those files available to every test in the subtree.

A typical layout:

The top-level conftest.py might look like:

The cart-specific one adds cart fixtures:

And the test file just uses them:

No imports. Pytest walks tests/cart/ upward, finds conftest.py there, then walks to tests/, finds conftest.py there, and registers every fixture from both. customer is from the outer file, sample_cart is from the inner one, and the test gets both.

Why this matters: visibility is scoped by location. The cart-specific empty_cart doesn't leak into tests/inventory/. If tests/inventory/test_stock.py tries to use sample_cart, pytest reports the fixture as not found. The same fixture name can even be redefined in a deeper conftest.py, and the deeper one wins for tests in that subtree. That's how you specialize a fixture for one part of the suite without breaking everything else.

A few rules to remember:

  • conftest.py is never imported manually. Pytest finds and loads it. If you write from conftest import sample_cart, you've misunderstood the design.
  • Fixtures in conftest.py are pytest-visible only. You can't call them as regular functions from production code; they're test infrastructure.
  • The closest conftest.py wins for shadowed names. If tests/conftest.py defines customer and tests/cart/conftest.py redefines it, cart tests see the inner one and other tests see the outer one.

autouse=True (and Why to Be Careful)

A fixture marked autouse=True runs for every test in its scope, whether or not the test asked for it by parameter name. It's the closest pytest gets to unittest's implicit setUp.

Neither test mentioned reset_metrics in its signature, but the fixture still ran before and after each one. That's useful when you genuinely want every test in a file to get a piece of setup: clearing a singleton between tests, resetting a global counter, freezing a clock.

Be careful. Autouse is implicit. The reader of test_one can't see that reset_metrics runs without scanning the conftest or the surrounding file. Every autouse fixture is a hidden dependency, and hidden dependencies are how test suites become hard to reason about.

A reasonable rule: use autouse=True only when the fixture's effect is invisible (it resets state, doesn't return anything the test uses) and skipping it would cause silent test pollution. For anything the test consumes, name the fixture in the parameter list. That keeps the dependency visible and lets pytest still cache and reuse it correctly.

Autouse also takes a scope. An autouse=True, scope="module" fixture runs once per module, not once per test, which is the right tool when you need exactly-once setup for a whole file.

Built-in Fixtures

Pytest ships with fixtures you don't have to define. Knowing them saves a lot of boilerplate.

tmp_path and tmp_path_factory

tmp_path is a function-scoped fixture that gives each test its own unique pathlib.Path to a temporary directory. Pytest creates the directory before the test and cleans it up afterward (with some retention so you can inspect failures).

The tmp_path value is already a pathlib.Path, so / builds child paths and .write_text / .read_text work directly. No os.path.join, no manual cleanup. Each test gets a fresh directory, so two tests writing the same filename never collide.

For tests that share a directory across the module or session, use tmp_path_factory. It's session-scoped and lets you mint named directories at any scope.

mktemp("exports") gives you a fresh directory named with that prefix plus a counter, scoped to whichever fixture is calling it. The second test sees the file the first one wrote because the directory persists for the whole module.

monkeypatch (Preview)

monkeypatch is a built-in fixture for temporarily changing things during a test: environment variables, attributes, dictionary keys, sys.path entries. The changes get reverted automatically when the test ends, so tests don't bleed state into each other.

A common use is patching an environment variable. Suppose code reads DISCOUNT_PERCENT from the environment:

Each test changes the environment, runs its assertion, and pytest reverts the change before the next test. No leaked env vars, no need to remember to set them back.

That's enough for now.

capsys, capfd, and caplog

When a test needs to verify what code prints or logs, the capture fixtures grab the output.

capsys captures sys.stdout and sys.stderr writes (i.e., print calls). After the test, you read the captured text with capsys.readouterr(), which returns a tuple-like object with .out and .err strings.

capfd is the same idea, but it captures at the file-descriptor level. Use it when the code under test bypasses Python's sys.stdout (for example, a C extension or a subprocess that writes to FD 1 directly). For most pure-Python code, capsys is what you want.

caplog captures records emitted through the logging module. You can assert on log levels, messages, or attribute values.

caplog.set_level(logging.WARNING) makes sure the test captures at that threshold; without it, pytest's default capture level may hide messages. caplog.text is the formatted output, and caplog.records gives you the raw LogRecord objects for finer assertions.

request (Briefly)

Most fixtures don't need it, but request is a built-in fixture that gives you metadata about the test or fixture being set up. It's the way fixtures introspect themselves: which test is using me, what node id am I in, what parameters did I receive. Two common uses are accessing the current test's name (request.node.name) and finalizing teardown the older way with request.addfinalizer (mostly superseded by yield).

For now, treat request as "the thing fixtures use when they need to know about the test that called them". A typical sighting:

The fixture labels each cart with the test name. Useful for debugging output and not much else day-to-day.

Parametrizing a Fixture (Preview)

Sometimes you want the same test to run against multiple variations of a fixture: a cart with 1 item, 10 items, 100 items; an inventory with three different stock levels. Adding params=[...] to the fixture decorator turns the fixture into a multi-value source. Pytest runs each test that uses the fixture once per parameter value.

Running this produces three test entries from one function:

Inside the fixture, request.param gives the current value. The [1], [5], [25] in the test names are the parameter IDs pytest auto-generates.

That's the preview. @pytest.mark.parametrize covers test-level parameters, multi-parameter combinations, custom IDs, conditional skips, and how parametrized fixtures and parametrized tests interact.

Putting It Together: A Cart Test Suite

A small end-to-end example showing fixtures, scopes, yields, composition, and conftest sharing working together. Imagine the layout below, where the package code lives in shop/ and the tests are under tests/cart/:

shop/discount.py:

tests/conftest.py:

tests/cart/conftest.py:

tests/cart/test_cart_total.py:

Run pytest -v -s tests/cart/:

inventory_db ran once for the whole session (the print appears once, even though three tests use it). customer, empty_cart, sample_cart, and discount_calc all rebuilt per test. discount_calc had a yield teardown, which shows the after-test print. No test re-imported a fixture; pytest pulled them by name across both conftests.

This is the picture you should hold in your head: small fixtures, narrow scopes by default, widening scope only for things that are expensive and immutable, with conftest.py files mapping the shape of the test directory tree.

Quiz

Test Fixtures Quiz

10 quizzes