Pytest Fixtures
Replace repeated setup with fixtures, control how often they run with scope, and watch teardown happen after a test fails.
Every test needs something set up: a temp file, a client, a database row. A fixture is a function that provides it, requested by naming it as a test parameter.
The repetition to remove
# test_orders.py
import sqlite3
def test_insert_order():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
conn.execute("INSERT INTO orders (total) VALUES (25.50)")
assert conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1
conn.close()
def test_sum_orders():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
conn.execute("INSERT INTO orders (total) VALUES (25.50)")
conn.execute("INSERT INTO orders (total) VALUES (10.00)")
assert conn.execute("SELECT SUM(total) FROM orders").fetchone()[0] == 35.50
conn.close()
test_orders.py .. [100%]
2 passed in 0.01s
It works and it is unmaintainable. Change the schema and you edit every test.
The fixture
import sqlite3
import pytest
@pytest.fixture
def db():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
yield conn # the test runs here
conn.close() # teardown, after the test
def test_insert_order(db):
db.execute("INSERT INTO orders (total) VALUES (25.50)")
assert db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1
def test_sum_orders(db):
db.execute("INSERT INTO orders (total) VALUES (25.50)")
db.execute("INSERT INTO orders (total) VALUES (10.00)")
assert db.execute("SELECT SUM(total) FROM orders").fetchone()[0] == 35.50
test_orders.py .. [100%]
2 passed in 0.01s
Naming db as a parameter is the whole request mechanism. pytest matches the parameter name
to a fixture, calls it, and passes the yielded value.
Watching it run
Make the lifecycle visible:
@pytest.fixture
def db():
print("\n SETUP: creating database")
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
yield conn
print(" TEARDOWN: closing database")
conn.close()
pytest -s -v
test_orders.py::test_insert_order
SETUP: creating database
PASSED TEARDOWN: closing database
test_orders.py::test_sum_orders
SETUP: creating database
PASSED TEARDOWN: closing database
2 passed in 0.02s
Setup and teardown ran twice — once per test. That is the default function scope, and
it is what stops one test’s rows leaking into another’s assertions.
Teardown runs after a failure too
def test_deliberately_fails(db):
db.execute("INSERT INTO orders (total) VALUES (1.00)")
assert False, "forced failure"
test_orders.py::test_deliberately_fails
SETUP: creating database
FAILED TEARDOWN: closing database
E AssertionError: forced failure
TEARDOWN still printed. Everything after yield runs whether the test passed, failed or
raised — which is why yield is better than a return plus manual cleanup at the end of
each test.
Scope
@pytest.fixture(scope="session")
def expensive_resource():
print("\n SETUP: starting container (slow)")
yield {"host": "localhost", "port": 5432}
print("\n TEARDOWN: stopping container")
@pytest.fixture(scope="function")
def order_id():
return 42
test_scopes.py::test_one
SETUP: starting container (slow)
PASSED
test_scopes.py::test_two PASSED
test_scopes.py::test_three PASSED
TEARDOWN: stopping container
3 passed in 0.03s
Setup once, teardown once, three tests. The five scopes:
| Scope | Runs once per |
|---|---|
function (default) | test |
class | test class |
module | test file |
package | package directory |
session | whole pytest run |
The trade is isolation against speed. A session-scoped database is fast and lets one test
corrupt the next. Use wide scopes for read-only or self-resetting resources — a running
container, a loaded model, a parsed config — and keep mutable state at function.
Here is the failure mode, made concrete:
@pytest.fixture(scope="module")
def shared_db():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
yield conn
conn.close()
def test_a_inserts(shared_db):
shared_db.execute("INSERT INTO orders (total) VALUES (25.50)")
assert shared_db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1
def test_b_expects_empty(shared_db):
assert shared_db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 0
test_orders.py::test_a_inserts PASSED [ 50%]
test_orders.py::test_b_expects_empty FAILED [100%]
E assert 1 == 0
test_b fails because of what test_a did. Worse, run it alone and it passes:
pytest test_orders.py::test_b_expects_empty
1 passed in 0.01s
A test that passes alone and fails in the suite is the signature of shared mutable state.
Fixtures using fixtures
@pytest.fixture
def db():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL)")
yield conn
conn.close()
@pytest.fixture
def db_with_orders(db): # requests db, then adds to it
rows = [("alice", 25.50), ("bob", 10.00), ("alice", 99.99)]
db.executemany("INSERT INTO orders (customer, total) VALUES (?, ?)", rows)
return db
def test_alice_total(db_with_orders):
total = db_with_orders.execute(
"SELECT SUM(total) FROM orders WHERE customer = 'alice'"
).fetchone()[0]
assert total == pytest.approx(125.49)
test_orders.py::test_alice_total PASSED [100%]
Fixtures compose by requesting each other, so you can build a small ladder — empty database, seeded database, database with an authenticated client — without repetition.
Sharing with conftest.py
Move db into conftest.py and every test in that directory and below can request it,
with no import:
# conftest.py
import sqlite3
import pytest
@pytest.fixture
def db():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL)")
yield conn
conn.close()
tests/
├── conftest.py # db available to everything below
├── test_orders.py
└── api/
├── conftest.py # extra fixtures, just for api/
└── test_endpoints.py
The nearest conftest.py wins, so api/conftest.py can override db for those tests only.
See what is actually available:
pytest --fixtures -q
db
Fresh in-memory SQLite with the orders schema.
db_with_orders
db, seeded with three orders across two customers.
tmp_path
Return a temporary directory path object which is unique to each test function.
capsys
Enable text capturing of writes to sys.stdout and sys.stderr.
monkeypatch
A convenient fixture for monkey-patching.
Your fixtures and the built-in ones, together. When a fixture “is not found”, run this first — the name is usually not what you thought.
Built-in fixtures worth knowing
def test_writes_a_file(tmp_path):
target = tmp_path / "report.csv"
target.write_text("id,total\n1,25.50\n")
assert target.exists()
assert target.read_text().splitlines()[1] == "1,25.50"
print(f"\n used {tmp_path}")
used /tmp/pytest-of-you/pytest-14/test_writes_a_file0
PASSED
A fresh directory per test, cleaned up automatically. Never write test files into the repo.
def test_captures_output(capsys):
print("processing 3 orders")
captured = capsys.readouterr()
assert "3 orders" in captured.out
def test_patches_environment(monkeypatch):
monkeypatch.setenv("API_TOKEN", "test-token-123")
import os
assert os.environ["API_TOKEN"] == "test-token-123"
test_builtins.py::test_captures_output PASSED [ 50%]
test_builtins.py::test_patches_environment PASSED [100%]
monkeypatch undoes every change it made when the test ends, so an environment variable or
patched attribute cannot leak into the next test.
Autouse
@pytest.fixture(autouse=True)
def reset_registry():
print("\n clearing registry")
yield
test_autouse.py::test_one
clearing registry
PASSED
test_autouse.py::test_two
clearing registry
PASSED
Runs for every test without being requested. Convenient for global reset, and easy to abuse — an autouse fixture is invisible at the call site, so a reader cannot tell why a test behaves as it does. Reserve it for cleanup that genuinely applies to everything.
Practice
1. Write a fixture that yields a temp CSV and confirm the file is gone after the test.
@pytest.fixture
def csv_file(tmp_path):
p = tmp_path / "data.csv"
p.write_text("a,b\n1,2\n")
yield p
print(f"\n exists during teardown: {p.exists()}")
exists during teardown: True
PASSED
Still there at teardown — tmp_path is cleaned by pytest after all teardown finishes, and
it actually keeps the last few runs on disk for debugging. Look under
/tmp/pytest-of-<user>/.
2. Make a fixture module-scoped, mutate it in one test, and assert the original state in another.
test_a_inserts PASSED
test_b_expects_empty FAILED — assert 1 == 0
And test_b passes when run alone. Order-dependent failures are the standard cost of a wide
scope on mutable state — covered further in the flaky-tests lesson.
3. Request a fixture that does not exist.
E fixture 'databse' not found
> available fixtures: cache, capfd, capsys, db, db_with_orders, doctest_namespace,
monkeypatch, pytestconfig, record_property, recwarn, tmp_path, tmp_path_factory ...
pytest lists everything available, so a typo is obvious. Note it does not suggest the near match — read the list.
4. Add an autouse fixture that prints, then run a test that requests nothing.
It still runs. That is the point and the danger: nothing at the test’s call site indicates it happened. If a test’s behaviour depends on an autouse fixture, request it explicitly instead so the dependency is visible.
Next: running one test against many inputs without duplicating it.