Topic 11.3
Fixtures and Parametrised Tests
In one line
A fixture is a function that prepares what a test needs (data, a temporary folder, a fake client) and cleans up afterwards. @pytest.mark.parametrize runs the same test body with many inputs, so one test function becomes a whole table of cases.
Think of it like this
A restaurant kitchen. Before every dish, the cook gets a clean board and fresh ingredients (setup), cooks, and after the dish the board is cleaned (teardown). Nobody cooks on yesterday's dirty board. A pytest fixture is that helper: it prepares what each test needs, hands it over, and cleans up. And parametrize is the same recipe tested with many ingredients.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Fixture
- A function that prepares something a test needs and hands it over. Tests ask for it by parameter name.
- Setup and teardown
- Preparing before a test and cleaning up after it.
- Scope
- How long a fixture lives: per test, per file, or per whole run.
- `yield` fixture
- A fixture that does setup, yields the value to the test, then does teardown after it.
- `parametrize`
- A decorator that runs one test function once for each row of inputs.
- `conftest.py`
- A special file where fixtures are kept so every test in the folder can use them without importing.
- `tmp_path`
- A built-in fixture: a fresh temporary folder for one test.
- `monkeypatch`
- A built-in fixture that changes things for one test only and undoes the change afterwards.
- `capsys`
- A built-in fixture that captures printed output.
Step by step
01The problem: repeated setup
Here are three tests that each build their own cart. The setup line is copied three times. Change what a cart looks like and you must fix all three, and you will forget one.
def test_total():
cart = {"apple": 30, "banana": 12}
assert sum(cart.values()) == 42
def test_two_items():
cart = {"apple": 30, "banana": 12}
assert len(cart) == 2
def test_has_apple():
cart = {"apple": 30, "banana": 12}
assert "apple" in cart02A fixture: build it once, ask for it by name
Move the setup into a function with @pytest.fixture. The tests now list cart as a parameter. pytest matches the parameter name to the fixture name, calls the fixture and passes the result in.
The fixture runs again for every test, so each test gets its own fresh dictionary. Changing the cart in one test cannot leak into another.
import pytest
@pytest.fixture
def cart():
return {"apple": 30, "banana": 12}
def test_total(cart):
assert sum(cart.values()) == 42
def test_adding_does_not_leak(cart):
cart["mango"] = 90
assert len(cart) == 3
def test_still_two_items(cart):
assert len(cart) == 2 # a fresh cart: the mango is gone03Teardown with yield
When a fixture owns something that must be released, use yield. The example opens a small in-memory database with sqlite3 (a database built into Python, Topic 15.6); all that matters here is that it must be closed. Everything before yield is setup; the yielded value goes to the test; everything after yield runs after the test finishes, pass or fail. This is the same idea as a with block (Topic 7.4): acquire, use, release.
import sqlite3
import pytest
@pytest.fixture
def db():
connection = sqlite3.connect(":memory:") # setup
connection.execute("create table users (name text)")
yield connection # the test runs here
connection.close() # teardown04Scopes: how long a fixture lives
With scope="module" a fixture is built once for the whole file and shared by its tests. The run below prints the exact order: the database opens once, each test gets a new session, and everything closes in reverse order of opening.
Use a wider scope only for setup that is truly slow and that tests do not change. If a test modifies a shared fixture, later tests see the modification, and you get failures that depend on test order.
05parametrize: one test, many rows
List the argument names in one string, then one tuple per case. pytest creates a separate test for each row and puts the values in its id, so a failure says exactly which row broke.
Prefer parametrize over a for loop inside the test. In a loop, the first failing row stops the test and hides the rest. With parametrize, every row runs and every failure is reported.
import pytest
@pytest.mark.parametrize("price, quantity, expected", [
(10, 3, 30),
(0, 5, 0),
(7, 0, 0),
(2.5, 4, 10.0),
])
def test_line_total(price, quantity, expected):
assert price * quantity == expected06tmp_path, monkeypatch and capsys
Three built-in fixtures you will use constantly. tmp_path is a real folder that belongs to the test, so you can write and read files without touching your project. monkeypatch.setenv sets an environment variable that is removed again after the test. capsys.readouterr() returns what was printed so far, as .out and .err.
These are exactly the things that make code hard to test (files, environment, printing), and the fixtures tame them.
import os
def test_writes_a_file(tmp_path):
path = tmp_path / "notes.txt"
path.write_text("hello")
assert path.read_text() == "hello"
def test_env(monkeypatch):
monkeypatch.setenv("MODE", "test")
assert os.environ["MODE"] == "test"
def test_prints(capsys):
print("hi there")
captured = capsys.readouterr()
assert captured.out.strip() == "hi there"07conftest.py: share fixtures without importing
Put a fixture in conftest.py and every test file next to it (and below it) can use it by name. Importing a fixture from another test file works by accident but is discouraged (linters flag the unused import and the shadowed parameter name); conftest.py is the supported way.
Try it yourself
- 1
Prove the cart is fresh
In the first example, add
cart["kiwi"] = 5to the line before the assert intest_cart_total, and predict which tests will fail. Onlytest_cart_totalfails (the sum is now 47), because every other test builds its own fresh cart. Check your prediction. - 2
Add a failing row
Add the row
(3, 3, 10)to the parametrize list. Run it and find the failing id in the summary: it istest_line_total[3-3-10]. The other rows still pass. - 3
Change a scope
In the second example change
scope="module"to the default (delete the argument). Predict how many times "open database" appears now, then run it.
Code & diagrams
pytest runs here in your browser. The run_pytest helper is the same plumbing as before. The `conftest.py` fixture is used by `test_fixtures.py` without any import. Add a row to the parametrize list and run again.
Expected output
test_fixtures.py::test_cart_total
test_fixtures.py::test_cart_is_fresh_each_time
test_fixtures.py::test_cart_again_is_fresh
test_fixtures.py::test_line_total[10-3-30]
test_fixtures.py::test_line_total[0-5-0]
test_fixtures.py::test_line_total[7-0-0]
test_fixtures.py::test_line_total[2.5-4-10.0]
test_fixtures.py::test_writes_a_file
test_fixtures.py::test_prints
test_fixtures.py::test_env
10 tests collected
.......... [100%]
10 passedA module-scope fixture opens once; a function-scope fixture wraps each test. The log shows the real order, including teardown.
Expected output
open database (once per file)
begin session
test_one runs
end session
begin session
test_two runs
end session
close databaseBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Parametrize names that the test does not accept
List three argument names in parametrize, but give the test function only two parameters.
import pytest
@pytest.mark.parametrize("price, quantity, expected", [(10, 3, 30), (0, 5, 0)])
def test_line_total(price, quantity):
assert price * quantity == expectedBreak #2
Ask for a fixture that does not exist
Request cart in a test file where no fixture of that name exists (a typo, or conftest.py is missing).
def test_uses_missing_fixture(cart):
assert len(cart) == 2Myth vs fact
Myth
Fixtures are called like normal functions.
Fact
Never call a fixture directly. Name it as a test parameter and pytest calls it for you (calling it directly is an error in current pytest).
Myth
A fixture's value is shared by all tests, so changes carry over.
Fact
With the default function scope each test gets a new value. Only wider scopes share, and then you must treat the value as read-only.
Myth
A loop inside a test is the same as parametrize.
Fact
In a loop the first failure stops the test and hides later rows, and the report does not say which row failed. Parametrize makes each row its own test.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A fixture can be
autouse=True, which runs it for every test without being requested. Use this sparingly (for example, to block real network access), because hidden setup makes tests harder to read. - ▸
Fixtures can be parametrized too:
@pytest.fixture(params=["sqlite", "postgres"])runs every test that uses it once per parameter, which is a clean way to run the same suite against several backends. - ▸
tmp_path_factoryis the session-scoped companion oftmp_pathfor building shared folders.monkeypatchalso hassetattr,delattr,setitemandsyspath_prepend; it undoes all of them when the test ends, which a bare assignment would not. - ▸
Teardown with
yieldruns in reverse order of setup, like nestedwithblocks. If a fixture raises during setup, the code after itsyieldnever runs, but fixtures that were already set up are still torn down. When a fixture acquires several things, wrap them intry/finally(orcontextlib.ExitStack) so a failure half-way does not leak the first resource. - ▸
Within one test, a fixture is built once per scope instance and shared: if
cartandorderboth depend ondb, they receive the samedbobject. A fixture can be overridden by defining one with the same name closer to the test (in the module, or in aconftest.pyin a deeper folder), which is how one test file swaps in a differentdb. - ▸
Parametrize ids come from the values only when they are strings, numbers, booleans or
None; other objects (a dict, a class) get generated ids such ascart0,cart1, which tell you nothing in a report. Give them names withids=[...], anids=function, orpytest.param(value, id="empty-cart"), andpytest.param(..., marks=pytest.mark.xfail)marks a single row. Stacking twoparametrizedecorators builds the cross product (2 rows by 3 rows = 6 tests).
Remember this
- 1
Without fixtures, tests repeat setup. Ten tests each build the same shopping cart, and when the cart changes you edit ten places. A fixture is a function marked
@pytest.fixture(a decorator: a line starting with@above a function that adds behaviour to it, Topic 9.6) that builds the thing once, in one place. A test asks for it by writing the fixture's name as a parameter:def test_cart_total(cart):. pytest sees the name, calls the fixture, and passes in the result. You never call a fixture yourself. - 2
By default every test gets a fresh copy: the fixture runs again for each test (this is
scope="function"). That is what keeps tests independent: a test that adds a mango to the cart cannot affect the next test. - 3
If a fixture must clean up (close a connection, delete a file), write it with
yield. Recall from Topic 9.2 thatyieldhands out a value and pauses. In a fixture, the code beforeyieldis setup, the yielded value is handed to the test, and the code afteryieldis teardown. Teardown runs even when the test fails. - 4
A scope says how long a fixture lives:
"function"(default, one per test),"class","module"(once per file),"package", or"session"(once per whole run). A bigger scope saves time for expensive setup (starting a database) but shares state between tests, which can leak. - 5
A fixture can use other fixtures by naming them as parameters, and pytest builds them in the right order. A fixture can only use fixtures whose scope is the same or wider than its own (pytest reports a ScopeMismatch error otherwise).
- 6
pytest ships useful built-in fixtures.
tmp_pathgives each test its own empty temporary folder as apathlib.Path(Topic 7.6). pytest keeps the folders of the last few runs (three by default) so you can look inside after a failure, and deletes older ones.capsyscaptures what the code prints so you can assert on it withcapsys.readouterr(). - 7
monkeypatchchanges an environment variable, an attribute or a dictionary item for the length of one test and puts everything back afterwards (monkeypatch.setenv("MODE", "test")). An environment variable is a named setting that lives outside your code (Topic 15.3 teaches them). An attribute is a variable stored on an object (Topic 8.1). - 8
@pytest.mark.parametrize("price, quantity, expected", [(10, 3, 30), (0, 5, 0), ...])turns one test function into one test per row. Each row is reported separately, with an id built from its values, such astest_line_total[10-3-30], so a failing row is easy to spot, and the other rows still run. Addids=[...]for readable names. Stack twoparametrizedecorators and pytest tests every combination of the rows. - 9
A file called
conftest.pyholds fixtures that many test files share. pytest finds it automatically, so tests use its fixtures without importing them. Aconftest.pyapplies to the folder it sits in and everything below, so you can keep general fixtures at the top oftests/and specific ones deeper. - 10
In AI projects this is the pattern for model code: a fixture that returns a fake model client (Topic 11.4), and a parametrised test that feeds a dozen messy sample replies (valid JSON, JSON in a markdown fence, empty string, extra text) to the one function that parses them.
Explain it without notes
What is a pytest fixture, and how does a test use one?
What do fixture scopes mean, and what is the risk of a wide scope?
How does yield work in a fixture, and when does teardown run?
Why is @pytest.mark.parametrize better than a loop inside a test?
What are tmp_path, monkeypatch and capsys for?
Practice
Write a fixture numbers that returns [3, 1, 2] and two tests that use it: one for sorted(numbers) and one showing that sorting a copy leaves the fixture's list unchanged. Run them with the pytest helper.
Parametrize a test for is_even(n) with at least five rows, including 0 and a negative number.
Write a test with tmp_path that saves a function's result into a text file and reads it back.
Trade-offs
- ↔
Fixtures versus plain helper functions. A helper function is explicit and easy to follow with a debugger. A fixture is injected by name, which is shorter but less obvious to a newcomer. Use fixtures for setup/teardown and shared resources; use plain helpers for simple data builders.
- ↔
Wide scopes are fast, narrow scopes are safe. Session-scoped databases make a suite quick but let tests influence each other. Default to function scope and widen only when profiling shows setup dominating the run time.
- ↔
Parametrize many rows versus a few clear tests. A table is great for the same behaviour with different data. When rows need different explanations, separate, well-named tests read better.
Done when you can
Done when you can write a fixture and use it from a test by parameter name.
Done when you can write a
yieldfixture with teardown and say when teardown runs.Done when you can choose a fixture scope and explain the risk of a wide one.
Done when you can parametrize a test and read the ids in the report.
Done when you can use
tmp_path,monkeypatchandcapsys.Done when you know that shared fixtures belong in
conftest.py.