Skip to main content

Fixtures & parametrize

Level 3: Level 3: Patternsmedium17 minpytestfixturesparametrizetdd

Share setup with fixtures and cover many cases with parametrize while you TDD a module.

Fixtures and parametrize

Why this matters

Every test for an inventory module needs the same starting state: a stock dict, maybe a temp file or a DB connection. Copy that setup into each test and one change to the shape breaks twenty tests at once. pytest fixtures give you one named source for that setup, and parametrize lets a single test body cover a whole table of cases so a failure points at the exact row that broke. Interviewers watch for this. Writing five near-identical test_ functions signals you do not know the tooling.

Fixtures: named, injected setup

A fixture is a function decorated with @pytest.fixture that builds a value your tests need. Any test that names the fixture as a parameter receives the returned value. pytest matches by name and injects it.

import pytest

@pytest.fixture
def base_stock():
    return {"apple": 5, "pear": 2}

def test_restock_adds_item(base_stock):        # pytest passes base_stock in
    result = restock(base_stock, {"plum": 3})
    assert result["plum"] == 3
Check yourself
base_stock is a plain @pytest.fixture that returns {'apple': 5, 'pear': 2}. Ten tests in the file take base_stock as a parameter. How many dicts get built over the run?

By default a fixture has function scope: pytest calls it fresh for every test, so base_stock is a brand-new dict each time and tests cannot leak state into one another. A fixture that uses yield instead of return runs the code after yield as teardown once the test finishes.

Check yourself
A fixture creates a temp file, yields the path, and deletes the file on the line after the yield. During one test an assert fails and the test errors out. Does the delete run?
Order of evaluation
  1. Fixture setupeverything above the yield runs
  2. yieldhands the value to the test as its argument
  3. Test bodyruns with that value
  4. Teardowneverything below the yield, even if the test FAILED
The highlighted stage is the reason to prefer yield over return: teardown runs whether the test passed, failed, or raised, so a fixture can safely own a temp file, a database transaction, or an open connection.

Scope decides how often that whole cycle repeats:

Table
Widening the scope trades isolation for speed, and the trade goes wrong in one specific way: a mutable value at session scope lets one test's mutation reach every later test, producing failures that depend on test ORDER.
scope=Set up once perReach for it when
function (default)every testthe value is mutable and tests must not leak into each other
classtest classa group of tests shares expensive but read-only setup
moduletest fileone connection or server serves every test in the file
sessionwhole test runthe setup is very expensive and genuinely immutable
Widening the scope trades isolation for speed, and the trade goes wrong in one specific way: a mutable value at session scope lets one test's mutation reach every later test, producing failures that depend on test ORDER.

Parametrize: one body, many cases

@pytest.mark.parametrize takes a string of parameter names and a list of value rows. pytest runs the test once per row and reports each as its own case.

@pytest.mark.parametrize("stock, additions, expected", [
    ({"a": 1}, {"a": 1}, {"a": 2}),        # shared key sums
    ({}, {"x": 4}, {"x": 4}),              # new key added
])
def test_restock(stock, additions, expected):
    assert restock(stock, additions) == expected

Two rows means two independent results, so a failure names the row instead of just "test_restock failed".

The demo below shows the restock you will build. It copies stock with dict(stock), then adds each quantity onto result.get(item, 0), returning a new dict. This sandbox has no pytest installed, so the practice tests express the same ideas directly: a helper builds the base stock and a list of case tuples is looped over. The concepts (shared setup, a table of cases) are identical; only the injection machinery differs.

Pitfall: shared mutable fixtures

Check yourself
A fixture is declared @pytest.fixture(scope='module') and returns {'apple': 5}. restock has a bug: it does result = stock instead of result = dict(stock), so it mutates the dict it was handed. You run the file. What do you see?

A fixture that returns a mutable object at function scope is safe, but widen the scope and that object is shared:

@pytest.fixture(scope="module")
def base_stock():
    return {"apple": 5}

Now every test in the module gets the same dict. If restock mutates its input (for example result = stock instead of result = dict(stock), which makes result and stock the same object), one test's change bleeds into the next, and tests pass or fail depending on order. The Practice suite checks exactly this: return a new dict and never touch stock.

Check yourself
You have six cases to cover. Option A is one test with a for loop over six tuples. Option B is @pytest.mark.parametrize with six rows. Case three is broken and case five is broken too. What is the difference in what you learn?

Interview nuance: parametrize is not a loop inside one test. A for loop stops at the first failing assert and hides every case after it. parametrize generates N separate tests, so all N run, each gets its own id in the report, and you can xfail or skip a single case with pytest.param. That independence, together with function-scope fixture isolation, is what makes a suite deterministic no matter what order it runs in.

Worked example (Python)
def restock(stock, additions):
    result = dict(stock)
    for item, qty in additions.items():
        result[item] = result.get(item, 0) + qty
    return result


print(restock({"apple": 5}, {"apple": 5, "plum": 3}))  # {'apple': 10, 'plum': 3}

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement restock(stock, additions). Return a new dict merging additions into stock, summing quantities for shared items.

restock({"apple": 5}, {"apple": 5, "plum": 3}) is {"apple": 10, "plum": 3}.

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Make the fixture-backed, parametrized suite pass: implement restock(stock, additions) in inventory/store.py to merge additions into a new copy of stock (summing shared quantities, never mutating the input). Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.