Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~31 min· topic 3 of 8

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.

test_cart.pywhole filepython
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 cart

02A 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.

test_cart.pywhole filepython
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 gone

03Teardown 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.

conftest.pywhole filepython
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()                         # teardown

04Scopes: 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.

Scopes: how long a fixture livesdiagram
Rendering diagram…

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.

test_prices.pywhole filepython
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 == expected

06tmp_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.

test_builtin.pywhole filepython
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.

terminal
$ tree
── expected output ──
myproject/
├── src/
│ └── shop.py
└── tests/
├── conftest.py # shared fixtures: cart, db, fake_llm
├── test_cart.py
└── test_prices.py

Try it yourself

  1. 1

    Prove the cart is fresh

    In the first example, add cart["kiwi"] = 5 to the line before the assert in test_cart_total, and predict which tests will fail. Only test_cart_total fails (the sum is now 47), because every other test builds its own fresh cart. Check your prediction.

  2. 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 is test_line_total[3-3-10]. The other rows still pass.

  3. 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

Fixtures, parametrize, tmp_path, capsys and monkeypatch New tab

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.

Sign in to run this example in your browser.

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 passed
Fixture scopes and teardown order New tab

A module-scope fixture opens once; a function-scope fixture wraps each test. The log shows the real order, including teardown.

Sign in to run this example in your browser.

Expected output

open database (once per file)
  begin session
    test_one runs
  end session
  begin session
    test_two runs
  end session
close database
Where fixtures come fromdiagram
Rendering diagram…

Break 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.

test_bad.pywhole filepython
import pytest


@pytest.mark.parametrize("price, quantity, expected", [(10, 3, 30), (0, 5, 0)])
def test_line_total(price, quantity):
    assert price * quantity == expected
terminal
$ python -m pytest -q
── what you'll see ──
==================================== ERRORS ====================================
_________________________ ERROR collecting test_bad.py _________________________
In test_line_total: function uses no argument 'expected'
=========================== short test summary info ============================
ERROR test_bad.py - Failed: In test_line_total: function uses no argument 'ex...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error

Break #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).

test_bad2.pywhole filepython
def test_uses_missing_fixture(cart):
    assert len(cart) == 2
terminal
$ python -m pytest -q
── what you'll see ──
E fixture 'cart' not found
> available fixtures: capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.

Myth 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_factory is the session-scoped companion of tmp_path for building shared folders. monkeypatch also has setattr, delattr, setitem and syspath_prepend; it undoes all of them when the test ends, which a bare assignment would not.

  • ▸

    Teardown with yield runs in reverse order of setup, like nested with blocks. If a fixture raises during setup, the code after its yield never runs, but fixtures that were already set up are still torn down. When a fixture acquires several things, wrap them in try/finally (or contextlib.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 cart and order both depend on db, they receive the same db object. A fixture can be overridden by defining one with the same name closer to the test (in the module, or in a conftest.py in a deeper folder), which is how one test file swaps in a different db.

  • ▸

    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 as cart0, cart1, which tell you nothing in a report. Give them names with ids=[...], an ids= function, or pytest.param(value, id="empty-cart"), and pytest.param(..., marks=pytest.mark.xfail) marks a single row. Stacking two parametrize decorators builds the cross product (2 rows by 3 rows = 6 tests).

Remember this

  1. 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. 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. 3

    If a fixture must clean up (close a connection, delete a file), write it with yield. Recall from Topic 9.2 that yield hands out a value and pauses. In a fixture, the code before yield is setup, the yielded value is handed to the test, and the code after yield is teardown. Teardown runs even when the test fails.

  4. 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. 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. 6

    pytest ships useful built-in fixtures. tmp_path gives each test its own empty temporary folder as a pathlib.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. capsys captures what the code prints so you can assert on it with capsys.readouterr().

  7. 7

    monkeypatch changes 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. 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 as test_line_total[10-3-30], so a failing row is easy to spot, and the other rows still run. Add ids=[...] for readable names. Stack two parametrize decorators and pytest tests every combination of the rows.

  9. 9

    A file called conftest.py holds fixtures that many test files share. pytest finds it automatically, so tests use its fixtures without importing them. A conftest.py applies to the folder it sits in and everything below, so you can keep general fixtures at the top of tests/ and specific ones deeper.

  10. 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

01

What is a pytest fixture, and how does a test use one?

02

What do fixture scopes mean, and what is the risk of a wide scope?

03

How does yield work in a fixture, and when does teardown run?

04

Why is @pytest.mark.parametrize better than a loop inside a test?

05

What are tmp_path, monkeypatch and capsys for?

Practice

01

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.

02

Parametrize a test for is_even(n) with at least five rows, including 0 and a negative number.

03

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 yield fixture 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, monkeypatch and capsys.

  • Done when you know that shared fixtures belong in conftest.py.