Module 2: Fixture Architecture The Backbone

2. The fixture as a reusable piece

Overview

In the previous lesson you saw the complete backbone as a snapshot. Now we start with the brick: one fixture. Before composing, before the conftest.py hierarchy, before scope, you have to understand thoroughly what a fixture is inside and why it deserves to be the base of the framework and not just any helper function. Because at first glance they look alike: a fixture is a function with a decorator that returns an object, and a helper function also returns an object. The difference is not in how it looks, but in three things a fixture does that a normal function does not —and of those three, the one that matters today is the most important of all: the fresh-instance guarantee.

By the end of this lesson you will know how to write a fixture (@pytest.fixture over a function that assembles something), request it from a test (naming it as a parameter), and explain with a real run why that is different from sharing a constant: each test receives its own freshly assembled instance, so a test that modifies its scenario does not contaminate the next. You are going to see the shared-constant bug happen for real, watch it disappear with a fixture, and use pytest --setup-show to see the fixture being assembled anew before each test. It is step one of the backbone: without this, composing has nothing to lean on.

Connection to the module: this lesson is the loose brick; the rest of the module places it, composes it and tunes it. Lesson 3 moves the fixture to conftest.py so the whole suite uses it without importing it. Lesson 4 gives it scope —what here is "fresh per test" is really the default function, and you will see you can change it—. Lesson 5 composes: a fixture that requests another. Here we stay with a single fixture, in a single file, so the attention is on what it is and what it guarantees, not on where it lives or how it connects. The module boundary is respected: no markers, no elaborate builders; just the brick and its guarantee.

A baking mold, not a cake on the counter

Imagine a bakery with two ways of giving a cake to each customer who asks for one to decorate as they wish.

The first way: there is one already-baked cake on the counter, and each customer decorates it right there. The first customer puts strawberries on it. The second arrives, and the cake already carries the first one's strawberries —it is not clean, it is someone else's cake—. If the second expected a blank cake, they get a surprise: the state the previous customer left is still there. And worse: which cake each one gets depends on the order in which they arrived. A shared cake on the counter.

The second way: the bakery has a mold and a recipe, and when a customer asks for a cake, it bakes a new one with that mold and hands it over fresh out of the oven, blank. The first customer puts strawberries on their cake. The second receives another cake, baked with the same mold but clean, with no trace of the first one's strawberries. Each customer decorates on a fresh canvas, and the order in which they arrive changes nothing. A mold that produces a new cake per order.

A fixture is the mold, not the cake on the counter. When you write @pytest.fixture over a function that assembles an empty Calendar, you are not putting a Calendar on the counter for everyone to share: you are giving pytest a recipe to bake a new Calendar every time a test requests it. The test that adds a booking to its calendar does not dirty the next test's, because the next one receives a freshly baked one. That is the fresh-instance guarantee, and it is exactly what a shared constant —the cake on the counter— does not give you. You are going to see the counter problem and the mold solution with code that runs.

Worked example: the counter bug and the mold that fixes it

Let us start with the cake on the counter: a shared mutable constant between two tests. It is a pattern seen often because it seems harmless —"I create the list once at the top and everyone uses it"—.

# test_shared_constant.py  —  the cake on the counter (do NOT do this)
SHARED_BOOKINGS = []


def test_add_one_booking():
    SHARED_BOOKINGS.append("bk-1")
    assert len(SHARED_BOOKINGS) == 1


def test_starts_empty():
    # this test assumes an empty list, but the previous one already contaminated it
    assert SHARED_BOOKINGS == []

SHARED_BOOKINGS is a list created once, at the top of the file. The first test adds "bk-1" to it and asserts it has one element —correct—. The second test asserts the list is empty. Run both. What to expect:

.F                                                                       [100%]
=================================== FAILURES ===================================
______________________________ test_starts_empty _______________________________

    def test_starts_empty():
        # this test assumes an empty list, but the previous one already contaminated it
>       assert SHARED_BOOKINGS == []
E       AssertionError: assert ['bk-1'] == []
E
E         Left contains one more item: 'bk-1'
E         Use -v to get more diff

test_shared_constant.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_shared_constant.py::test_starts_empty - AssertionError: assert ['bk-1'] == []
1 failed, 1 passed in 0.02s

Read it slowly, because this bug is more treacherous than it looks. The second test fails not because of anything it did wrong, but because of what the first one left it: the list arrived as ['bk-1'] because test_add_one_booking modified it and the change persisted. The counter cake arrived with the previous customer's strawberries. And notice the poisonous detail: if you ran test_starts_empty alone, it would pass —the list would be blank because nobody touched it—. It only fails when it runs after the other. That is what makes these bugs so hard: the test passes or fails depending on the order, on what ran before, and that is the last thing anyone suspects when hunting for the cause.

Now the mold: the same idea, but as a fixture.

# test_fixture_fresh.py  —  the mold that bakes a new one per test (do this)
import pytest


@pytest.fixture
def bookings():
    # each test receives a NEW list
    return []


def test_add_one_booking(bookings):
    bookings.append("bk-1")
    assert len(bookings) == 1


def test_starts_empty(bookings):
    # fresh instance: the previous test does not affect it
    assert bookings == []

Three changes, and each one matters. First, the list is no longer a module constant: it is a fixture, a function bookings decorated with @pytest.fixture that returns a new list. Second, each test that wants it requests it by name, putting it as a parameter: def test_add_one_booking(bookings). Third —and it is the consequence— each test receives the result of running the fixture, and pytest runs the fixture anew for each test. Run both. What to expect:

..                                                                       [100%]
2 passed in 0.00s

Both green. The same scenario that used to fail —one test adds, the other expects empty— now works, and not because we changed what the tests assert, but because each one receives its own freshly baked list. test_add_one_booking adds "bk-1" to its list; test_starts_empty receives another list, empty, with no trace of the first. The mold produced two cakes, it did not share one.

How do I know it really bakes it twice? You do not have to believe it: pytest shows it. Run the same suite with --setup-show, the flag that shows you the scaffolding being assembled:

collected 2 items

test_fixture_fresh.py
        SETUP    F bookings
        test_fixture_fresh.py::test_add_one_booking (fixtures used: bookings) .
        TEARDOWN F bookings
        SETUP    F bookings
        test_fixture_fresh.py::test_starts_empty (fixtures used: bookings) .
        TEARDOWN F bookings

============================== 2 passed in 0.00s ===============================

There is the proof. SETUP F bookings appears twice —once before each test—. The F is the scope (function: assembled per function, the default, subject of lesson 4). Each SETUP is a freshly baked cake; each TEARDOWN is the fixture ending its cycle for that test. pytest did not reuse the list: it created it from scratch for test_add_one_booking, discarded it, and created another for test_starts_empty. The --setup-show is the window to the backbone, and you are going to use it throughout the module to see what fixtures do underneath.

Anatomy of a fixture

Now that you saw one work, let us name its parts precisely, because each one reappears in the lessons that follow.

The @pytest.fixture decorator. It is what turns a normal function into a fixture. Without it, bookings would be a common function and a test that requested it as a parameter would fail with "fixture not found". The decorator is the label that tells pytest "this function is a scaffolding recipe; when someone requests something with this name, run it and hand them the result". It requires import pytest at the top of the file.

The name of the fixture. It is the name of the function: bookings. And it is also the name by which the tests request it. This correspondence —the test parameter must be named the same as the fixture— is how pytest connects the requester with the recipe. There is no registry, no central list: pytest sees a parameter named bookings, looks for a fixture named bookings, runs it, and passes the result. That is why the name matters so much and why it is best that it says what it delivers: focus_room, booking_service, pro_member.

The body: what it assembles. It is the code that builds the object —create the list, the Room, the Calendar—. It runs once per test that requests it (with the default scope). This is where the scaffolding that used to be copied in each test lives.

The value it returns. What the fixture delivers to the test. With return, the fixture delivers the object and ends. (There is a second form, with yield, that also cleans up afterward; that is lesson 6. For now, return.) The test receives that value in its parameter and uses it like any variable.

Bring the four parts together and you have the canonical form of a fixture:

import pytest

from reservo.models import Room


@pytest.fixture
def focus_room():                                        # name: focus_room
    return Room(id="focus", name="Focus",                # body: assembles the Room
                capacity=4, hourly_cents=2500)            # value: returns it


def test_focus_costs_2500_per_hour(focus_room):          # requests focus_room
    assert focus_room.hourly_cents == 2500               # uses it

focus_room is a piece of Reservo's backbone: the Focus room with its rate of 2500 cents per hour. Any test that needs that room requests it by name and receives it assembled, fresh, without repeating the constructor. When the module ends, focus_room will live in a conftest.py and dozens of tests will use it; today we have it in the same file to see the whole brick.

The three things a fixture does and a helper function does not

Go back to the question from the start: if a fixture is "a function that returns an object", why not use a normal helper function and call it from each test? You could write def make_focus_room(): return Room(...) and call make_focus_room() inside each test. It works. So, what does the fixture give you that the helper function does not?

One: the fresh-instance guarantee is given by pytest, not you. With a helper function, you have to remember to call it inside each test to get a new object; if for convenience you call it once and store the result in a constant, you go back to the counter cake. With the fixture, the fresh instance is automatic: pytest runs the recipe for each test that requests it, without you doing anything. The guarantee stops depending on your discipline.

Two: it is discovered without importing (via conftest.py). This is lesson 3, but it is worth previewing because it is an enormous difference. A helper function has to be imported in each file that uses it (from helpers import make_focus_room). A fixture placed in conftest.py is available in all the tests of that folder without a single import. When you have fifty test files, "no imports" versus "one import per file" is the difference between a backbone and a tangle of dependencies.

Three: it is composed by declaring other fixtures as parameters. This is lesson 5, the heart of the module. A fixture can request another fixture simply by naming it as a parameter, and pytest assembles the whole graph. A helper function would have to call the other helper functions by hand, passing the results. The declarative composition —"I request calendar and payments, pytest gives them to me assembled"— is what lets you build the composite booking_service without writing the assembly order. No helper function gives you that.

Three differences, and all three point to the same place: a helper function lets you reuse code, but a fixture gives you infrastructure. The first saves you typing; the second holds up the suite. That is why fixtures, and not helper functions, are the backbone.

Common mistakes

Forgetting the @pytest.fixture and seeing "fixture not found" (absent decorator). What happens: someone writes the function that assembles the scenario, requests it as a parameter in a test, and pytest fails with fixture 'focus_room' not found. Why it happens: without the decorator, focus_room is a normal function; pytest does not recognize it as a fixture and, when it sees the test parameter, finds no fixture with that name. How to detect it: the message is literal —fixture 'X' not found— and usually lists the fixtures that do exist; if yours is not in that list, either it has no decorator or it is in another file out of reach. How to fix it: put @pytest.fixture right above the function and make sure you have import pytest. It is the most common beginner error with fixtures and the message leads you by the hand.

Calling the fixture as a function instead of requesting it as a parameter (confusing recipe with dish). What happens: someone writes room = focus_room() inside the test, and pytest throws a strange error or the fixture does not behave as expected. Why it happens: it comes from the habit of helper functions, where you call make_focus_room(). But a fixture is not called: it is requested, putting it as a parameter, and pytest runs it for you. How to detect it: if you see parentheses after the name of a fixture inside a test —focus_room()—, it is misused. How to fix it: remove the parentheses and put focus_room as a parameter of the test function; you receive the assembled result in the focus_room variable. (The exception is the fixture-factory of lesson 7, which returns a function that you do call —but there the fixture is still requested as a parameter, and what you call is its result—.)

Going back to the counter cake with a mutable object "to save" (relapsing into the constant). What happens: someone has a correct fixture, notices that assembling the object "costs", and to save it stores it in a module constant or bumps the scope without thinking, and suddenly the tests start contaminating each other depending on the order. Why it happens: the fresh instance seems like a waste when the object is expensive to assemble. How to detect it: if a test passes alone but fails when it runs after another (or if the order changes the result), suspect shared state. How to fix it: for any object a test can modify —a list, a Calendar, a dict—, the fresh instance is not a waste, it is the guarantee that saves you from the counter bug. If the assembly really is expensive and the object is read-only, then bumping the scope is legitimate, but it is a conscious decision with its trade-off, and it is exactly the subject of lesson 4. When in doubt, fresh per test.

Exercises

Exercise 1 — Turn the constant into a fixture. Here is a test file that shares a Calendar as a module constant. The second test fails because the first added a booking to it. Convert it so each test receives a fresh Calendar. (Remember: Calendar() creates an empty one; calendar.add(booking) adds a booking to it.)

from datetime import datetime

from reservo.calendar import Calendar
from reservo.models import Booking

CAL = Calendar()  # shared: the cake on the counter
some_booking = Booking(id="bk-1", room_id="focus", member_id="m-ana",
                       start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 12),
                       status="confirmed", price_cents=7500)


def test_add_makes_it_non_empty():
    CAL.add(some_booking)
    assert len(CAL.confirmed_for_room("focus")) == 1


def test_new_calendar_is_empty():
    assert CAL.confirmed_for_room("focus") == []
See solution
from datetime import datetime

import pytest

from reservo.calendar import Calendar
from reservo.models import Booking

some_booking = Booking(id="bk-1", room_id="focus", member_id="m-ana",
                       start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 12),
                       status="confirmed", price_cents=7500)


@pytest.fixture
def calendar():
    return Calendar()  # the mold: a new one per test


def test_add_makes_it_non_empty(calendar):
    calendar.add(some_booking)
    assert len(calendar.confirmed_for_room("focus")) == 1


def test_new_calendar_is_empty(calendar):
    assert calendar.confirmed_for_room("focus") == []

Three changes, all three from this lesson: the constant CAL became a fixture calendar with @pytest.fixture; each test requests it as a parameter instead of reading the global; and so each one receives a freshly baked Calendar. Now test_add_makes_it_non_empty adds the booking to its calendar, and test_new_calendar_is_empty receives another empty calendar —both pass regardless of the order—. If you ran --setup-show, you would see SETUP F calendar twice, once per test.

Exercise 2 — Predict the --setup-show. Without running anything, sketch the output of --setup-show for this suite (a pro_member fixture, two tests that request it). How many times does SETUP F pro_member appear? Why?

import pytest

from reservo.models import Member


@pytest.fixture
def pro_member():
    return Member(id="m-ben", name="Ben", tier="pro")


def test_pro_tier(pro_member):
    assert pro_member.tier == "pro"


def test_pro_name(pro_member):
    assert pro_member.name == "Ben"
See solution

SETUP F pro_member appears twice —once before each test that requests it—, like this:

        SETUP    F pro_member
        test_x.py::test_pro_tier (fixtures used: pro_member) .
        TEARDOWN F pro_member
        SETUP    F pro_member
        test_x.py::test_pro_name (fixtures used: pro_member) .
        TEARDOWN F pro_member

Why twice: the default scope of a fixture is function (the F in the output), which means "assembled anew for each test function that requests it". Two tests request pro_member, so the recipe runs twice and each test receives its own Member. In this case the Member is read-only —no test modifies it—, so sharing one would not cause bugs; but the safe default is fresh per test, and the scope is bumped only as a conscious decision (lesson 4). The point of the exercise is that you read --setup-show fluently: each SETUP F is an instantiation, and counting how many there are tells you how many times the recipe ran.

Exercise 3 — Fixture or helper function. For each situation, say whether a fixture is preferable or a normal helper function is enough, and why: (a) assembling the Focus room, which thirty tests from different files need; (b) a function that, given a price_cents, returns the string "$25.00" for a legible error message; (c) assembling a Calendar with three bookings that several tests modify in different ways.

See solution
  • (a) Fixture. A scenario that thirty tests from different files need is the textbook case for a fixture in conftest.py: it is defined once, requested by name, and available in all those files without a single import (lesson 3). A helper function would force importing it in each file. It is infrastructure, not a loose calculation.
  • (b) Helper function. Converting 2500 into "$25.00" is a pure transformation of one value into another: it does not assemble a scenario, does not need a fresh instance, does not compose with other fixtures. It is a normal function (def format_cents(cents): ...) you call where you need it. Putting it in a fixture would be forcing the tool. (These formatting and assertion utilities are, in fact, the subject of module 5.)
  • (c) Fixture, and carefully. A Calendar with three bookings that several tests modify screams fixture because of the fresh-instance guarantee: if it were a shared constant, the first test that adds or cancels a booking would contaminate the others (the counter bug of this lesson). Each test needs its own populated and fresh Calendar. It is exactly the kind of mutable scenario the fresh instance exists for.

The rule the exercise distills: use a fixture when it assembles a reusable scenario —especially if it is mutable or if many tests need it—; use a helper function for pure transformations of one value into another. The first is suite infrastructure; the second is a utility. Confusing them leads to fixtures that should not be ones and helper functions that should be fixtures.

Summary and next step

In this lesson you laid the first brick of the backbone: one fixture. You saw what it is inside —@pytest.fixture over a function that assembles an object and returns it— and how a test uses it: it requests it by name, as a parameter, and pytest runs the recipe and hands over the result. And you saw the guarantee that makes it worthwhile, with the analogy of the mold versus the counter cake: a fixture bakes a new instance per test, so a test that modifies its scenario does not contaminate the next. You verified it with code that runs —the shared-constant bug failing because of the order, the fixture fixing it, and --setup-show showing SETUP F once per test as proof of the fresh instance—.

You named the anatomy —decorator, name, body, returned value— and the three things that separate a fixture from a helper function: the fresh instance is guaranteed by pytest, it is discovered without importing, and it is composed by declaring other fixtures as parameters. All three point to the same thing: a fixture is not code reuse, it is suite infrastructure.

Before moving on you should be able to: write a fixture with @pytest.fixture and request it from a test; explain, with the mold analogy, why each test receives a fresh instance and why that avoids order contamination; and read --setup-show to count how many times a fixture is assembled.

What comes next is to take the fixture out of the test file and give it its real home. In lesson 3 you are going to meet conftest.py, the special file where pytest looks for fixtures and offers them to all the tests of a folder without you importing anything —and its hierarchy: the root conftest.py, visible across the whole suite, versus one per folder, visible only in its branch—. There the fixture stops being a loose brick in a file and becomes part of the backbone that holds up the whole suite.

Resources

  • How to use fixtures in pytest — the official guide. The "Fixtures are requested by test functions" section explains exactly what you saw here: a test requests a fixture by naming it as a parameter, and pytest runs it and passes it the result. It is in English.
  • @pytest.fixture in the API reference — the entry for the decorator that turns a function into a fixture, with all its parameters (scope, autouse, params, name). Today you only used the argument-less form; the others arrive in the lessons that follow.
  • pytest --setup-show — the flag that shows you each SETUP and TEARDOWN, the one you used to verify that the fixture is baked anew for each test. It is your window to the backbone throughout the module.