Advanced · Lesson 65 of 94

Fixtures and parametrize

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Tests repeat setup: build the same object, write the same temporary file, prepare the same list. A fixture is a function that produces one of those things, and any test that names it as a parameter receives a fresh one. Decorate with @pytest.fixture, and pytest calls it before each test that asks for it. The test body then contains only the behavior being checked. If the fixture uses yield instead of return, the code after the yield runs after the test, which is how you clean up files, close connections, or reset state.

Fixtures and parametrize: @pytest.fixture; def cart(): return Cart([... → def test_total(cart): ... → def test_remove(cart): ... → def test_empty(cart): ...
Fixtures and parametrize · Figure 1 · Open for full size

A fixture can request other fixtures, so small ones compose into larger ones. Its scope decides how often it is built: the default function scope builds it per test, which is what you want for anything mutable; module or session scope builds once for something expensive and read-only, like a large parsed file. Fixtures defined in a file called conftest.py are available to every test in that folder and below, without imports, which is where project-wide ones belong.

Fixture feature Effect
@pytest.fixture a function tests can request by name
yield inside the fixture code after it is teardown, run after the test
scope="module" / "session" build once per module / once per run instead of per test
a fixture requesting another composition; pytest resolves the order
conftest.py fixtures shared by every test in the folder tree
tmp_path built in: a fresh temporary folder as a Path
capsys built in: captures print output for assertions
monkeypatch built in: temporarily replace attributes, env vars, dictionary entries

parametrize turns one test function into many test cases. Give it the parameter names and a list of value tuples, and pytest runs the test once per tuple, reporting each separately with its values in the name. That is the right tool for a function that should behave a certain way across a table of inputs: boundary values, formats, edge cases. Each case is its own pass or fail, so one bad input does not hide the others, and adding a case is adding a tuple.

Fixtures and parametrize: @pytest.mark.parametrize('text,expected', [... → test_clean[  Hi  -hi → test_clean[HELLO-hello → test_clean[-
Fixtures and parametrize · Figure 2 · Open for full size

Two built-in fixtures cover the most common awkward tests. tmp_path gives each test a fresh empty folder, so code that reads and writes files can be tested without touching the real filesystem or cleaning up. capsys captures what the code printed, so functions that report to the console can be checked with captured.out. The next lesson covers monkeypatch, the third one, for replacing things the code depends on.

Code

The code under test: a small shopping cart. The tests below build it through a fixture instead of repeating the setup.

# cart/__init__.py
# cart/model.py
class Cart:
    def __init__(self):
        self.items = {}

    def add(self, name, price, qty=1):
        if price < 0 or qty < 1:
            raise ValueError("price must be non-negative and qty at least 1")
        self.items[name] = self.items.get(name, 0) + qty * price

    def remove(self, name):
        del self.items[name]

    def total(self):
        return round(sum(self.items.values()), 2)

    def receipt(self):
        for name, amount in sorted(self.items.items()):
            print(f"{name:<10}{amount:>8.2f}")
        print(f"{'total':<10}{self.total():>8.2f}")
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

A fixture with yield teardown, a shared fixture in conftest.py, and tests that use them. The teardown prints so you can see when it runs.

# tests/conftest.py
import pytest

from cart.model import Cart


@pytest.fixture
def cart():
    c = Cart()
    c.add("pen", 1.5, qty=2)
    c.add("book", 12.0)
    yield c
    print("(teardown: cart discarded)")
# tests/test_cart.py
import pytest


def test_total(cart):
    assert cart.total() == 15.0


def test_remove(cart):
    cart.remove("book")
    assert cart.total() == 3.0
    assert "book" not in cart.items


def test_each_test_gets_a_fresh_cart(cart):
    assert cart.items == {"pen": 3.0, "book": 12.0}


def test_bad_add(cart):
    with pytest.raises(ValueError):
        cart.add("x", -1)
# run_fixtures.sh
pytest -q -s tests/test_cart.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
.(teardown: cart discarded)
.(teardown: cart discarded)
.(teardown: cart discarded)
.(teardown: cart discarded)

4 passed in Ns

Parametrize: one test, six cases, each reported by name. The last case is written to fail so the report shows how a single bad row appears.

# tests/test_param.py
import pytest

from cart.model import Cart


@pytest.mark.parametrize(
    "price,qty,expected",
    [
        (1.0, 1, 1.0),
        (1.5, 2, 3.0),
        (0.0, 3, 0.0),
        (19.99, 1, 19.99),
        (0.1, 3, 0.3),
        (2.0, 2, 5.0),
    ],
)
def test_add_amounts(price, qty, expected):
    cart = Cart()
    cart.add("item", price, qty)
    assert cart.total() == expected
# run_param.sh
pytest -q tests/test_param.py 2>&1 | grep -E "passed|FAILED" | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
FAILED tests/test_param.py::test_add_amounts[2.0-2-5.0] - assert 4.0 == 5.0
1 failed, 5 passed in Ns

tmp_path and capsys: testing file output without touching the real disk, and asserting on printed text.

# tests/test_io.py
from cart.model import Cart


def save_receipt(cart, path):
    path.write_text(f"total={cart.total()}\n", encoding="utf-8")


def test_receipt_file(cart, tmp_path):
    target = tmp_path / "receipt.txt"
    save_receipt(cart, target)
    assert target.read_text(encoding="utf-8") == "total=15.0\n"
    assert len(list(tmp_path.iterdir())) == 1


def test_receipt_output(cart, capsys):
    cart.receipt()
    out = capsys.readouterr().out
    assert out.splitlines()[0].startswith("book")
    assert "total        15.00" in out
# run_io.sh
pytest -q tests/test_io.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
Output
..                                                                       [100%]
2 passed in Ns

Try it yourself

Extend the textkit tests from the previous lesson. Add a conftest.py fixture that returns a list of messy names, a parametrized test for clean with at least six input/expected rows including an empty string, a test that writes cleaned names to a file in tmp_path and reads them back, and a test that uses capsys to check a report(names) function's printed output. Run with -v and read the parametrized case names.

Common mistakes

What's next

Your tests are concise and thorough. Next you will test code that talks to the clock, the filesystem, and the network without actually doing so: mocking and test doubles.

Course overview