Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Testing & Debugging  ›  Lesson

Fixtures, Parametrisation & Scope

Fixtures and Parametrisation 24 minGive every test fresh data from one definition, and run one test body over a table of cases
You're building a piece ofQuarry — the test rig
This piece — sample_batch(): Builds a fresh, known batch of log lines for every test, with broken lines exactly where you ask.
Scenario Two of Quarry's tests passed alone and failed together because they shared one batch; the rig now builds a fresh one to order for every test.
Your task
Build sample_batch(n, broken): return a new list of n Quarry log lines numbered 1..n. Line i uses level ("INFO", "WARN", "ERROR", "DEBUG")[(i-1) % 4], stage ("yard", "vault", "extractor")[(i-1) % 3] and message "event i"; a line whose number is in broken has no colon. Example: sample_batch(2, [2]) → ["INFO yard: event 1", "WARN vault event 2"].

Fixtures, Parametrisation & Scope

In short: a fixture is a data-builder, declared once as a function and requested by a test just by naming it as a parameter — the runner calls it and hands back a fresh value, every time, so no test can see what another test did to it. A fixture can yield instead of return: everything before the yield is setup, everything after is teardown, and it runs even when the test fails. Parametrisation runs one test body over a table of cases, each becoming its own named test, so an edge case is a new row, not a copied function. Widening a fixture's scope (module, session) shares one value for speed — which reopens the sharing trap on purpose.

A module-level BATCH shared by two tests, both seeing whatever the other left behind, versus a fixture called fresh for each test, giving two separate, untouched lists

Quarry's suite has a dozen tests now, and they've started to look alike: each builds a little batch of log lines, runs a stage, checks the result. The batch-building is copied from test to test with small variations, and last Tuesday one of those copies was edited to add a line — which made a different test fail, but only when the two ran in a particular order.

Find the bug that only shows up in the full suite

Here's that bug boiled down to one module-level list, shared by two tests:

What does this print?
BATCH = ["INFO yard: a", "WARN vault: b"]

def test_append_marker():
    BATCH.append("DEBUG yard: marker")
    assert len(BATCH) == 3

def test_batch_has_two_lines():
    assert len(BATCH) == 2

results = []
for test in (test_append_marker, test_batch_has_two_lines):
    try:
        test()
        results.append("pass")
    except AssertionError:
        results.append("fail")
print(" ".join(results))

The second test is correct; it fails because of something another test did. A suite with this problem is order-dependent: green alone, red with everything, green again when you rerun just the failing test to look at it. The cure: every test gets its own data, built fresh, and nothing a test does survives it.

In pytest you declare the builder once, marked @pytest.fixture, and a test asks for it by naming it as a parameter — dependency injection by name: the test says what it needs, the runner supplies it.

import pytest

@pytest.fixture
def batch():
    return ["INFO yard: a", "WARN vault: b"]

def test_append_marker(batch):
    batch.append("DEBUG yard: marker")
    assert len(batch) == 3

def test_batch_has_two_lines(batch):
    assert len(batch) == 2      # passes in any order — a fresh list each time

The same idea with no pytest at all: inspect.signature reads a function's parameter names, and a dict maps fixture names to the functions that build them.

python

FIXTURES[name]() is called once per test, so each gets a brand-new list. The order problem is gone, and the setup code lives in exactly one place.

Give the vault connection a proper teardown

Some fixtures create something that must be destroyed afterwards — a temporary folder, an open connection. A fixture can yield its value instead of returning it: everything before the yield is setup, everything after is teardown, and it runs even when the test fails.

@pytest.fixture
def vault():
    conn = open_vault(":memory:")
    yield conn            # the test runs here
    conn.close()          # teardown, pass or fail

That's a generator, and a runner drives it with next(): the first call runs up to the yield and hands back the value, the second resumes after it.

python

The finally is what makes the promise hold: the vault closes after the failing test too.

step through it
1def tmp_dir():
2 made = ["tmp/1"]
3 yield made
4 made.clear()
5 
6gen = tmp_dir()
7folder = next(gen)
8folder.append("tmp/1/out.csv")
9count = len(folder)
10next(gen, None)
11print(count, folder)

When a test asks for several fixtures, they're set up in order and torn down in the reverse order — like nested with blocks. If one fixture's setup raises, the test doesn't run at all (pytest reports an error, not a failure), and whatever was already set up still gets torn down.

Building fresh data for every test is the safe default, but some setup is expensive — a large sample file, an in-memory database. pytest lets a fixture declare a scope: function (default, once per test), module (once per file), or session (once for the whole run). A runner implements it with a cache keyed by the thing the value belongs to:

What does this print?
calls = 0

def make_db():
    global calls
    calls += 1
    return {"rows": []}

cache = {}

def get_db(module_name):
    if module_name not in cache:
        cache[module_name] = make_db()
    return cache[module_name]

for module_name in ["test_vault.py", "test_vault.py", "test_yard.py"]:
    get_db(module_name)
print(calls)

The price of a wider scope is that the value is shared again — the trap from the opening example, reintroduced on purpose, in exchange for speed. So only widen the scope of things tests read and never change:

broken — fix it

Both tests are correct, but the second fails because the fixture hands every test the same mutable dict. Fix the fixture so each test gets its own database.

If a fixture takes s seconds and the suite has x tests, function scope costs s·x while session scope costs s once — Quarry's 400-test suite needs a per-test fixture under 50 ms to stay under 20 seconds. The usual answer is cheaper setup, and widening scope only for data nothing modifies.

Turn six copies of one test into one table

parse_line has at least six interesting inputs. Six copies of the same test drift apart; pytest takes a table instead:

@pytest.mark.parametrize("line, level", [
    ("WARN yard: disk full", "WARN"),
    ("ERROR vault: write failed", "ERROR"),
    ("INFO yard: a: b", "INFO"),
])
def test_level(line, level):
    assert parse_line(line)["level"] == level

Each row becomes a separate test with its own ID — test_level[WARN yard: disk full-WARN] — so one failing row is reported on its own and the rest still run. Built by hand, a decorator attaches the table and the runner expands it:

python

The lowercase row fails and the others don't — the report says precisely which. unittest does the same with subTest, marking each case separately inside one method:

python

Sometimes every test wants similar data, not identical — ten lines here, a broken line at position three there. A factory fixture returns a function, and each test calls it with what it needs:

@pytest.fixture
def make_batch():
    def build(n, broken=()):
        ...                   # returns n fresh lines
    return build

def test_skips_broken_lines(make_batch):
    lines = make_batch(5, broken=[2])
    ...

That is the piece you build now. (In a real project, fixtures many files share go in conftest.py, found automatically; pytest's own built-ins — tmp_path, capsys — work the same way, requested by name.)

Quick practice

What does this print?
import inspect

FIXTURES = {"n": lambda: 3}

def test_uses_n(n):
    assert n == 3

names = list(inspect.signature(test_uses_n).parameters)
print(names, FIXTURES[names[0]]())
What does this print?
def parametrize(*rows):
    def attach(fn):
        fn.cases = rows
        return fn
    return attach

@parametrize(("a",), ("b",), ("c",))
def test_letter(x):
    assert x in "abc"

print(len(test_letter.cases))

🎯 Your turn

Build sample_batch(n, broken) — the rig's factory for known test data. It returns n Quarry log lines, numbered from 1. Line i takes its level from ("INFO", "WARN", "ERROR", "DEBUG") and its stage from ("yard", "vault", "extractor"), cycling through each in order, and its message is event i. A line whose number is in broken is written without the colon, so parse_line will refuse it. Numbers in broken beyond n are ignored.

sample_batch(3, [])
# → ["INFO yard: event 1", "WARN vault: event 2", "ERROR extractor: event 3"]
sample_batch(2, [2])
# → ["INFO yard: event 1", "WARN vault event 2"]

Every call must build a new list — that is what makes it a fixture and not a shared global.

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upfixture 'x' not found

Before running a test, pytest checks that every parameter names a known fixture. Write missing_fixtures(params, available) returning the parameter names that are not in available, in their original order.

test-designlists
DrillName every parametrised case

pytest names each parametrised case name[v1-v2-...], using the values themselves when they are strings or numbers. Write case_ids(name, cases) returning those IDs for a list of cases (each case is a list of values).

test-designstringscomprehensions
BuildWhat does scope cost?

modules lists, for each test in a run, the file it lives in. Write setup_count(scope, modules) returning how many times a fixture of that scope is built: "function" once per test, "module" once per distinct file, "session" once for the whole run (0 if nothing ran). Raise ValueError for any other scope.

test-designsetsconditionals
BossSetup, test, teardown — in order

Each requested fixture is a yield fixture. Write fixture_log(requested) that simulates one test using them and returns the event log. Set fixtures up in order, logging "setup <name>"; then log "test"; then tear down in reverse order, logging "teardown <name>". A name starting with ! fails during setup: log "setup <name> failed" (without the !), skip the test, and tear down only the fixtures already set up. Build it with real generators using the make helper in the starter.

test-designgeneratorexceptions
CapstoneA runner that injects fixtures

Write run_with_fixtures(source). Execute the source into a fresh dict. Fixtures are functions named fixture_<name>; tests are functions whose names start with test. For each test, in definition order, read its parameter names with inspect.signature, build a fresh value for each by calling fixture_<param>(), and call the test with them. Return {"passed": [...], "failed": [...], "errors": [...]}: a missing fixture or a fixture that raises is an error, an AssertionError in the test is a failure, any other exception in the test is an error.

test-designpytestfirst-class-functionsexceptions
ExplainWhy tests must not depend on each other

Quarry's suite passes when you run test_vault.py alone and fails when you run the whole suite. Explain what is probably happening, why it is so costly, and how fixtures prevent it.

sample_batch.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
sample_batch(n, broken) → listBuild n fresh sample log lines, with the numbered ones in broken missing their colon.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.