Python Testing #2 Fixtures: Injecting Setup and Teardown
In part 1 we installed pytest and ran our first test. But add just a few more tests and a new annoyance appears: every test repeats the same setup code at the top. In this post we’ll cover pytest’s core tool for eliminating that repetition — the fixture.
- #1 Getting started with pytest
- #2 Fixtures ← this post
- #3 parametrize and markers
- #4 mock and monkeypatch
- #5 Testing the outside world
- #6 Test design and coverage
- #7 CI integration
Repeated setup code #
Add a few more tests to the shopping cart example from part 1 and you end up with something like this.
def test_remove_item():
cart = Cart()
cart.add("apple", 1000)
cart.remove("apple")
assert cart.total() == 0
def test_clear():
cart = Cart()
cart.add("apple", 1000)
cart.clear()
assert cart.total() == 0
def test_total():
cart = Cart()
cart.add("apple", 1000)
cart.add("pear", 2000)
assert cart.total() == 3000All three tests start by hand-building “a cart with an apple in it.” If the way Cart gets constructed changes, every test needs fixing — and the longer the setup grows, the more it buries the one line you actually wanted to verify.
@pytest.fixture: injection through function arguments #
A fixture is “a function that builds a prepared object.” You define it with @pytest.fixture, and a test only has to declare an argument with the same name.
import pytest
@pytest.fixture
def cart():
c = Cart()
c.add("apple", 1000)
return c
def test_remove_item(cart):
cart.remove("apple")
assert cart.total() == 0
def test_clear(cart):
cart.clear()
assert cart.total() == 0When a test function declares an argument named cart, pytest finds the fixture function with the same name, runs it, and passes the return value in as the argument. The fastest way to understand this structure is through the lens of dependency injection: the test merely declares “I need a cart with an apple in it,” and the fixture owns the knowledge of how to build one. By default a fixture runs fresh for every test, so even if one test empties the cart, the next test receives a clean one.
yield fixtures: setup and cleanup in one place #
For resources that need cleaning up after use — files, DB connections — use yield instead of return.
import sqlite3
import pytest
@pytest.fixture
def db():
conn = sqlite3.connect(":memory:") # setup
conn.execute("CREATE TABLE users (name TEXT)")
yield conn # the test runs here
conn.close() # teardown
def test_insert(db):
db.execute("INSERT INTO users VALUES ('Curtis')")
rows = db.execute("SELECT count(*) FROM users").fetchone()
assert rows[0] == 1Everything before yield is setup; everything after is cleanup. The key point: the cleanup code runs even if the test fails. Instead of writing try/finally in every test, you keep the resource’s entire lifecycle in one place — the fixture.
scope: the isolation-versus-cost trade-off #
A fixture running fresh for every test is great for isolation, but painful for resources that are expensive to set up. What if spinning up a DB engine takes 2 seconds and you have 100 tests? The scope option lets you stretch the creation cycle.
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine() # the expensive setup, just once
yield engine
engine.dispose()| scope | Created | Suited for |
|---|---|---|
function | per test (default) | anything with mutable state |
module | once per file | resources safe to share within a file |
session | once per run | expensive resources like DB engines or containers |
The wider the scope, the faster the run — but the object is shared between tests. The moment changes left by one test become visible to the next, isolation is broken, so wide scopes are only safe for resources that are effectively read-only.
conftest.py: sharing fixtures #
You don’t need to copy fixtures into every test file. Define them in a file named conftest.py and every test in the same directory and below can use them — no import required.
tests/
├── conftest.py # cart fixture: available to all tests
├── test_cart.py
└── api/
├── conftest.py # client fixture: available only under api/
└── test_orders.pyWhen pytest runs a test, it reads every conftest.py from the file’s location up through the parent directories and gathers the fixtures. That naturally produces a hierarchy: shared fixtures live near the top, and fixtures specific to one area live in that directory’s own conftest.py.
Composition: fixtures using fixtures #
Fixtures can take other fixtures as arguments too.
@pytest.fixture
def user():
return User(name="Curtis")
@pytest.fixture
def cart(user):
return Cart(owner=user)
def test_owner(cart):
assert cart.owner.name == "Curtis"Declare that cart depends on user and pytest runs them in dependency order on its own. Assembling small fixtures into complex situations like this is far easier to maintain than the do-everything fixture we’ll look at below.
A taste of built-in fixtures: tmp_path and capsys #
pytest ships with several fixtures you can use without defining anything. Here are the two you’ll reach for most.
def test_save_report(tmp_path):
file = tmp_path / "report.txt" # a temp directory just for this test
save_report(file, "sales 1000")
assert file.read_text() == "sales 1000"
def test_greet(capsys):
greet("Curtis")
captured = capsys.readouterr() # capture print output
assert "Curtis" in captured.outtmp_path gives each test its own temporary directory as a pathlib.Path and handles cleanup for you. Tests that touch files no longer mess up your real directories. capsys captures standard output so you can verify what print produced. You can see the full list with pytest --fixtures.
Two anti-patterns #
Watch out for the do-everything fixture first. It’s the structure where one giant fixture prepares the user, the cart, the DB, and the config, and every test receives it. You can no longer see what a test actually depends on, and every change to the fixture breaks unrelated tests. Split it small using the composition pattern above, and let each test declare only what it needs.
scope abuse is common too. Promote a mutable object to session scope to speed things up, and you get tests whose results depend on execution order, thanks to state left behind by earlier tests. If you ever see a test that passes in isolation but fails in the full run, this is usually why. Stick to the rule: wide scopes only for resources that are both expensive and read-only.
Recap #
What we covered in this post:
- Extract setup code with
@pytest.fixture; tests receive it by declaring the argument name yieldfixtures keep setup and teardown together, and guarantee cleanup even when a test failsscopeis an isolation-versus-cost trade-off; wide scopes are only safe for read-only resources- Fixtures in
conftest.pyare shared across the directory hierarchy without imports - Compose small fixtures by having fixtures receive other fixtures
- Built-in fixtures like
tmp_pathandcapsyswork with zero definition
In the next post (#3 parametrize and markers), we’ll cover @pytest.mark.parametrize for running the same test logic against many inputs, and markers for classifying tests and running just the ones you choose.