Fixtures, Parametrize, and Coverage
Fixtures, Parametrize, and Coverage
A fixture is a function pytest calls to build a value a test needs: a list of tickets, a temp directory, a clock. tmp_path is a fixture pytest already provides — a fresh pathlib.Path per test. Coverage is a report of which lines ran. The boring default is: small fixtures, parametrize for tables, and a coverage run before you call a change done.
Mental model
Put shared setup in @pytest.fixture. Tests name the fixture as an argument. pytest injects the return value. Do not call the fixture yourself.
tmp_path lives on disk for one test, then goes away. Use it for receipts and JSON, not for “the real desk folder.”
pytest-cov measures lines. A number is not a goal. The missing-line report is: you wrote a branch and no test walked it.
Worked examples
Case 1: A fixture for the rail
Save both files in the same directory.
# desk.py
def open_ids(tickets):
return [t["id"] for t in tickets if t["status"] == "open"]
def total_pence(tickets):
return sum(t["pence"] for t in tickets)
def write_receipt(path, tickets):
lines = [f"{t['id']} {t['pence']}" for t in tickets]
path.write_text("\n".join(lines) + "\n")# test_desk.py
import pytest
from desk import open_ids, total_pence, write_receipt
@pytest.fixture
def tickets():
return [
{"id": "T-11", "status": "open", "pence": 1200},
{"id": "T-12", "status": "paid", "pence": 800},
{"id": "T-13", "status": "open", "pence": 400},
]
def test_open_ids(tickets):
assert open_ids(tickets) == ["T-11", "T-13"]
def test_total_pence(tickets):
assert total_pence(tickets) == 2400Run:
uv run --with pytest pytest test_desk.py -qOutput (duration varies):
.. [100%]
2 passed in 0.01s
Each test gets its own list. Mutating tickets in one test does not leak into the other, because the fixture function runs again.
Case 2: tmp_path for a receipt file
Add this test to test_desk.py.
# test_desk.py
import pytest
from desk import open_ids, total_pence, write_receipt
@pytest.fixture
def tickets():
return [
{"id": "T-11", "status": "open", "pence": 1200},
{"id": "T-12", "status": "paid", "pence": 800},
{"id": "T-13", "status": "open", "pence": 400},
]
def test_open_ids(tickets):
assert open_ids(tickets) == ["T-11", "T-13"]
def test_total_pence(tickets):
assert total_pence(tickets) == 2400
def test_write_receipt(tmp_path, tickets):
path = tmp_path / "receipt.txt"
write_receipt(path, tickets)
text = path.read_text()
assert "T-11 1200" in text
assert text.endswith("\n")Run:
uv run --with pytest pytest test_desk.py -qOutput (duration varies):
... [100%]
3 passed in 0.01s
Do not write receipts next to the test file. tmp_path keeps the repo clean and the test isolated.
Case 3: Parametrize plus a fixture
Save as test_status.py next to the same desk.py.
# test_status.py
import pytest
from desk import open_ids
@pytest.fixture
def ticket():
return {"id": "T-21", "status": "open", "pence": 100}
@pytest.mark.parametrize(
("status", "want"),
[
("open", ["T-21"]),
("paid", []),
("void", []),
],
)
def test_open_ids_status(ticket, status, want):
ticket["status"] = status
assert open_ids([ticket]) == wantRun:
uv run --with pytest pytest test_status.py -qOutput (duration varies):
... [100%]
3 passed in 0.01s
The fixture builds a ticket. Parametrize changes one field. Keep the table small enough to read on one screen.
Case 4: Coverage
From the directory that holds desk.py and test_desk.py (Case 2):
uv run --with pytest --with pytest-cov pytest test_desk.py --cov=desk --cov-report=term-missing -qOutput (duration and column spacing vary):
... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.7-final-0 ________________
Name Stmts Miss Cover Missing
---------------------------------------
desk.py 7 0 100%
---------------------------------------
TOTAL 7 0 100%
3 passed in 0.03s
--cov=desk is the module name, not the test file. Missing lists line numbers no test ran. Add a test for that branch; do not delete the branch to make the number pretty.
A pyproject.toml can pin both tools:
# pyproject.toml
[project]
name = "desk"
version = "0.1.0"
requires-python = ">=3.14"
[dependency-groups]
dev = ["pytest>=8", "pytest-cov>=6"]Then uv sync --group dev and uv run pytest test_desk.py --cov=desk --cov-report=term-missing.
The trap
Save this desk.py and a test that never voids a ticket.
# desk.py
def close(ticket, status):
if status not in {"paid", "void"}:
raise ValueError(f"bad status {status!r}")
ticket = dict(ticket)
ticket["status"] = status
return ticket# test_close.py
from desk import close
def test_close_paid():
ticket = close({"id": "T-1", "status": "open"}, "paid")
assert ticket["status"] == "paid"Run coverage:
uv run --with pytest --with pytest-cov pytest test_close.py --cov=desk --cov-report=term-missing -qYou will see the ValueError line in Missing. The paid path is green. The bad-status path is not. Shipping at “most of the function ran” is how voided tickets skip the check.
The boring rule
- Fixtures build data. Tests name them as arguments.
tmp_pathfor files. Never write into the source tree from a test.- Parametrize the inputs. Fixture the setup that is the same every time.
- Run
--cov=desk --cov-report=term-missingand readMissing, not only the percent. - 100% coverage is not a personality. Untested branches are.
Try this
- Add a fixture
paid_onlythat filters Case 1’s tickets. Assertopen_idsis empty. - In
test_write_receipt, assert the file has exactly three lines of content (plus the final newline). - Add a
voidpath test for the trap’scloseand re-run coverage untilMissingis empty. - Run pytest on
test_desk.pyandtest_status.pytogether:uv run --with pytest pytest test_desk.py test_status.py -q.