Fixtures, Parametrize, and Coverage

Updated

September 8, 2026

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) == 2400

Run:

uv run --with pytest pytest test_desk.py -q

Output (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 -q

Output (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]) == want

Run:

uv run --with pytest pytest test_status.py -q

Output (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 -q

Output (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 -q

You 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_path for 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-missing and read Missing, not only the percent.
  • 100% coverage is not a personality. Untested branches are.

Try this

  1. Add a fixture paid_only that filters Case 1’s tickets. Assert open_ids is empty.
  2. In test_write_receipt, assert the file has exactly three lines of content (plus the final newline).
  3. Add a void path test for the trap’s close and re-run coverage until Missing is empty.
  4. Run pytest on test_desk.py and test_status.py together: uv run --with pytest pytest test_desk.py test_status.py -q.