Module 2: Fixture Architecture The Backbone
8. Mini-project: Reservo's fixture backbone
Overview
The moment has come to bring the six pieces together. In the seven previous lessons you built, one by one, the parts of the fixture backbone: the reusable fixture (2), its home in the conftest.py hierarchy (3), scope as an architecture decision (4), composition and the dependency graph (5), yield for setup and teardown (6), and the fixture-factory with autouse (7). This mini-project is where you stop learning the pieces separately and design and write the complete backbone of Reservo, from start to finish, with your own hands. It is the module's capstone, and what you deliver here is not a disposable exercise: it is the infrastructure on which modules 3 to 8 of the guide will lean —the layer organization, the markers, the utilities, the plugins, the data and environments—. They all use the fixtures you design here.
The goal is concrete: a conftest.py with Reservo's data fixtures (focus_room, basic_member, pro_member), its collaborators (calendar, clock, payments, emails, repo), the composed fixture booking_service that joins them, and a fixture-factory make_booking that manufactures bookings on demand. Plus a suite —unit and integration— that uses the whole backbone and runs green. By the end you will have read the complete graph being instantiated with pytest --setup-show and you will have, written by you, the backbone the rest of the guide takes for granted.
Connection to the module: this lesson introduces nothing new; it integrates everything. Every decision you make here —which fixture goes in the root, which scope to choose, what to compose, what to manufacture— is one of the previous lessons applied. It is also the bridge to module 3, which takes this backbone and organizes it into a folder structure by layer; and to module 4, which adds markers and config. The boundary is respected until the end: we do not register markers or touch pyproject.toml (module 4), and the make_booking factory stays in its simple form —the builder in depth is module 7—. Here you deliver the backbone; the following modules build on it.
The blueprint before the bricks
Before writing a line, it is worth having the blueprint of what you are going to build. Reservo's backbone has three strata, and each corresponds to a lesson of the module.
Data stratum: the simple pieces. Reservo's rooms and members are data objects the tests read —focus_room (the Focus room at 2500 cents/hour), basic_member and pro_member (the two tiers)—. They are normal fixtures with return, scope function (the default), no dependencies. They are the leaves of the graph.
Collaborator stratum: the doubles. The BookingService needs five collaborators —calendar (the in-memory calendar), clock (a fixed clock for determinism), payments, emails and repo (the doubles that record charges, emails and bookings)—. Each in its fixture, also function —because the doubles record state and sharing them would contaminate (lesson 4)—. They are independent leaves of the graph.
Composed and manufacturing stratum. On top of the collaborators, the composed fixture booking_service, which requests the five and joins them (lesson 5). And separately, the fixture-factory make_booking, which returns a function to manufacture bookings on demand (lesson 7). The service is the final assembly; the factory is the cutter.
With that blueprint in your head, each fixture you write has a place and a reason. You are not improvising: you are applying the module.
Step 1: the data and collaborator stratum
Start with the leaves of the graph —the fixtures with no dependencies—, all in the root conftest.py so the whole suite reaches them without imports (lesson 3):
# conftest.py (project root)
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, FakePaymentGateway,
FixedClock, SpyEmailSender)
from reservo.models import Booking, Member, Room
from reservo.service import BookingService
# --- Data stratum ---
@pytest.fixture
def focus_room():
return Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
@pytest.fixture
def basic_member():
return Member(id="m-ana", name="Ana", tier="basic")
@pytest.fixture
def pro_member():
return Member(id="m-ben", name="Ben", tier="pro")
# --- Collaborator stratum (doubles) ---
@pytest.fixture
def calendar():
return Calendar()
@pytest.fixture
def clock():
return FixedClock(datetime(2026, 3, 1, 9))
@pytest.fixture
def payments():
return FakePaymentGateway()
@pytest.fixture
def emails():
return SpyEmailSender()
@pytest.fixture
def repo():
return FakeBookingRepository()
Eight fixtures, all return (they assemble an object, do not open resources to close —lesson 6—), all scope function (the default: each test receives a fresh instance, and since the doubles record state, this is mandatory, not optional —lesson 4—). Note that clock uses a FixedClock with a fixed time (2026-03-01 09:00): it is what makes the tests deterministic, a clock that always gives the same time instead of the real system clock.
Step 2: the composed fixture and the factory
Now the upper stratum. The composed fixture booking_service requests the five collaborators as parameters —pytest assembles them and passes them to the constructor (lesson 5)—:
# --- Composed stratum: the service ---
@pytest.fixture
def booking_service(calendar, clock, payments, emails, repo):
return BookingService(calendar, clock, payments, emails, repo)
Five lines worth two hundred: any test that requests booking_service receives the assembled service, and the day BookingService requires a new collaborator, you touch only this fixture (lesson 1). And the factory make_booking, which returns a function to manufacture bookings on demand, with sensible defaults and selective override (lesson 7):
# --- Manufacturing stratum: the Booking cutter ---
@pytest.fixture
def make_booking():
created = []
def _make(room_id="focus", member_id="m-ana", start=None, end=None,
status="confirmed", price_cents=7500):
start = start or datetime(2026, 3, 10, 9)
end = end or datetime(2026, 3, 10, 12)
booking = Booking(id=f"bk-{len(created) + 1}", room_id=room_id,
member_id=member_id, start=start, end=end,
status=status, price_cents=price_cents)
created.append(booking)
return booking
return _make
Notice the return _make without parentheses: it hands over the function (the cutter), not a booking (the cookie). With this, the conftest.py is complete: ten fixtures that form Reservo's backbone, organized in three strata.
Step 3: the suite that uses the backbone
The backbone is worth nothing without a suite that exercises it. Write tests in two folders —unit for the pure logic, integration for the composed flow—, all requesting fixtures by name, none assembling its scenario by hand:
# tests/unit/test_pricing.py — uses the data fixtures
from reservo.pricing import price_cents
def test_basic_member_pays_full_price(focus_room, basic_member):
assert price_cents(focus_room, basic_member, 3) == 7500
def test_pro_member_gets_twenty_percent_off(focus_room, pro_member):
assert price_cents(focus_room, pro_member, 3) == 6000
# tests/unit/test_factory.py — uses the factory
def test_factory_makes_distinct_bookings(make_booking):
a = make_booking(price_cents=7500)
b = make_booking(member_id="m-ben", price_cents=6000)
assert a.id != b.id
assert b.price_cents == 6000
# tests/integration/test_book_flow.py — uses the composed service
from datetime import datetime
def test_booking_a_pro_charges_6000(booking_service, focus_room, pro_member, payments):
booking = booking_service.book(focus_room, pro_member,
datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
assert booking.price_cents == 6000
assert payments.charges == [(6000, "m-ben")]
def test_booking_saves_confirms_and_emails(booking_service, focus_room, basic_member, repo, emails):
booking = booking_service.book(focus_room, basic_member,
datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
assert repo.get(booking.id).status == "confirmed"
assert len(emails.sent) == 1
Look at the five tests: none has a single scaffolding line. Each one requests the fixtures it needs —focus_room, basic_member, booking_service, payments, make_booking— and goes straight to what it asserts. The basic price (7500), the pro discount (6000), distinct bookings from the factory, the recorded charge, the saved booking and the sent email. Reservo's anchor numbers, verified through the backbone.
What to expect. Run the whole suite (Python 3.14.0, pytest 9.1.1):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo_capstone
collected 5 items
tests/integration/test_book_flow.py .. [ 40%]
tests/unit/test_factory.py . [ 60%]
tests/unit/test_pricing.py .. [100%]
============================== 5 passed in 0.01s ===============================
Five green. The backbone holds up the suite: data, collaborators, composed service and factory, all working together.
Step 4: read the complete graph
The last step is to see the backbone. Run one of the integration tests —the one that uses the composed service— with --setup-show, and read the whole graph being instantiated:
SETUP F calendar
SETUP F clock
SETUP F payments
SETUP F emails
SETUP F repo
SETUP F booking_service (fixtures used: calendar, clock, emails, payments, repo)
SETUP F focus_room
SETUP F basic_member
tests/integration/test_book_flow.py::test_booking_saves_confirms_and_emails (fixtures used: basic_member, booking_service, calendar, clock, emails, focus_room, payments, repo) .
TEARDOWN F basic_member
TEARDOWN F focus_room
TEARDOWN F booking_service
TEARDOWN F repo
TEARDOWN F emails
TEARDOWN F payments
TEARDOWN F clock
TEARDOWN F calendar
This output is the whole module condensed into a block. Go through it one last time with everything you learned:
- The five pieces —
calendar,clock,payments,emails,repo— are assembled beforebooking_service, because it depends on them (composition, lesson 5; topological order). - The line
booking_service (fixtures used: calendar, clock, emails, payments, repo)is the graph made text: pytest tells you what the composite depends on. - Everything is
F—scopefunction— because each test needs a fresh instance; the doubles record state and sharing them would contaminate (scope, lesson 4). - The teardown runs in reverse order —
booking_servicefirst,calendarlast— because the last thing set up is the first thing dismantled (yield/LIFO, lesson 6). - Each piece appears once even though the service and the test request it —that is why the test sees the same
paymentsthe service uses (caching by scope, lesson 5).
That is a fixture backbone, designed and read by you. Everything that follows in the guide leans on it.
Your deliverable
Assemble the complete backbone yourself and deliver it. The deliverable has three parts:
- The
conftest.pywith the ten fixtures in their three strata: data (focus_room,basic_member,pro_member), collaborators (calendar,clock,payments,emails,repo), and the composed/manufacturing stratum (booking_service,make_booking). With the reason for each decision: why in the root, whyfunction, what is composed, what is manufactured. - The suite green: at least the five tests above —two of price, one of factory, two of composed flow—, running with
pytestand showing5 passed. None should assemble its scenario by hand; all request fixtures. - The graph read: the output of
pytest --setup-showof an integration test, with a note of your own pointing out in it the composition (the assembly order), the reverse teardown, and thefunctionscope of everything.
As an optional challenge, add a fixture with yield to the backbone —for example, a temp_export_path that creates and deletes a temporary file to test the export of a calendar—, and verify with a test that fails on purpose that its teardown runs anyway (lesson 6). And as a second challenge, override payments in a tests/payment_failures/conftest.py with a double that fails, and verify that booking_service in that folder uses the broken gateway without you redefining booking_service (lesson 5). The two challenges are the module's advanced techniques applied to your backbone.
Common mistakes
Assembling the collaborators inside booking_service instead of composing them (monstrous station, again). What happens: someone writes booking_service creating the Calendar, the FixedClock, etc., inside its body, instead of requesting them as fixtures. Why it happens: it seems shorter to have "everything in one fixture". How to detect it: if your booking_service fixture has collaborator constructors in its body instead of receiving them as parameters, it is not composed. How to fix it: extract each collaborator to its fixture and compose (lesson 5). Only that way can a test request payments to inspect payments.charges —test_booking_a_pro_charges_6000 needs it—, and only that way can you override payments in a folder. The monstrous fixture denies you both.
Bumping the scope of the doubles "to speed up" and contaminating the suite (badly chosen scope). What happens: someone notices the backbone assembles ten fixtures per test and, to "optimize", puts scope="session" on payments or calendar. The suite starts failing in ways that depend on the order. Why it happens: the saving is visible, the contamination is not. How to detect it: if bumping a scope starts making tests that used to pass fail, or a test passes alone but fails in the suite, it is scope contamination. How to fix it: Reservo's collaborators record state (payments.charges, emails.sent, the calendar gets filled), so they must stay at function (lesson 4). Composition already saved you the scaffolding; you do not also need to bump the scope, and doing so here buys bugs.
Putting scenario data in an autouse "so as not to repeat" (invisible magic). What happens: someone sees that several integration tests need a booking in the calendar and adds it in an autouse, so as not to write it in each test. Why it happens: it saves typing. How to detect it: if a test asserts something about a booking its code never created, there is an autouse injecting it. How to fix it: the scenario a test uses in its asserts must be requested explicitly —a seeded_calendar fixture the test names—, not injected by autouse (lesson 7). Reserve the autouse for reset and cleanup. In this backbone, in fact, no autouse is needed: each test requests what it needs, and its code shows its scenario.
Exercises
Exercise 1 — Design the missing fixture. Your backbone has focus_room, but a new test needs to test the Boardroom room (id="boardroom", capacity=12, hourly_cents=6000). Do you add another fixture boardroom_room, or change focus_room for a make_room factory? Decide and justify, and write what you choose.
See solution
It depends on how many different rooms the suite needs, and the rule is the lesson 7 one ("one fixed object" versus "create objects"):
- If only two or three fixed and known rooms are needed (Focus, Boardroom, Studio), the clearest is to add normal fixtures, one per room:
@pytest.fixture
def boardroom_room():
return Room(id="boardroom", name="Boardroom", capacity=12, hourly_cents=6000)
Each test requests the room it needs by name. Simple and legible.
- If the suite needs many varied rooms, or rooms configured on demand (a room with a rate the test chooses, to test the price calculation with different rates), a factory is preferable:
@pytest.fixture
def make_room():
def _make(id="focus", name="Focus", capacity=4, hourly_cents=2500):
return Room(id=id, name=name, capacity=capacity, hourly_cents=hourly_cents)
return _make
And the test does make_room(hourly_cents=6000) for whatever rate it wants. The factory wins when the test needs to control the room's data, not just choose among a few fixed ones.
For this specific case —a second fixed room, the Boardroom—, the normal fixture boardroom_room is the simplest and most correct answer. Reserve make_room for when you really need to manufacture rooms with variable data. The moral: do not turn everything into a factory just in case; choose according to whether you need one fixed object or to create objects your way.
Exercise 2 — Place the fixture in its stratum. A colleague added a fixture seeded_calendar that requests calendar and make_booking and returns a calendar with a confirmed booking. Only the tests in tests/integration/ use it. Which conftest.py does it go in, and why? And what scope should it have?
See solution
It goes in tests/integration/conftest.py, not in the root. The lesson 3 rule (the common ancestor): since only the integration tests use it, its home is that folder's conftest.py. Putting it in the root would make it visible to tests/unit/, which does not need it, cluttering the "lobby board" with a single-floor notice. And it works there because seeded_calendar requests calendar and make_booking, which are in the root: an inner fixture can use outer fixtures (the dependency flows from the inside out).
Scope: function. seeded_calendar returns a Calendar the tests modify (they add or cancel bookings to test overlaps, availability, cancellations). A shared scope would inherit the bookings of the previous tests —the contamination bug of lesson 4—. Each integration test needs its own populated and fresh calendar, so function (the default). Besides, since seeded_calendar depends on calendar and make_booking (both function), it could not be of a longer scope even if it wanted to —a fixture cannot depend on another of a shorter scope (lesson 5)—.
Exercise 3 — Predict the effect of a change in the backbone. Tomorrow, BookingService.__init__ changes and starts requiring a sixth collaborator, a metrics (a double that records metrics). Describe exactly which files and which fixtures you have to touch in your backbone for the five tests to stay green, and how many tests you have to open.
See solution
You have to touch a single file —the conftest.py— and two fixtures in it, and zero tests:
- Add a
metricsfixture in the collaborator stratum of theconftest.py, just like the other five:
@pytest.fixture
def metrics():
return FakeMetrics() # the new double
- Modify the composed
booking_servicefixture so it requestsmetricsand passes it to the constructor:
@pytest.fixture
def booking_service(calendar, clock, payments, emails, repo, metrics):
return BookingService(calendar, clock, payments, emails, repo, metrics)
And that is all. No test to open: the five request booking_service by name and receive, automatically, the service built with the six collaborators. The price tests (test_pricing.py) never even notice because they do not use the service; the flow ones (test_book_flow.py) stay green because the booking_service they receive already carries the new metrics.
Compare this with the "before" suite of lesson 1, where the same change forced touching the two hundred tests that built the service by hand. That is the backbone paying its investment: a change in how the scenario is assembled is made in one place, and the whole suite receives it. It is the entire reason this module exists before all the others.
Summary and next step
In this mini-project you brought the six pieces of the module together into a complete backbone. You designed Reservo's conftest.py in three strata —data (focus_room, basic_member, pro_member), collaborators (calendar, clock, payments, emails, repo), and composed/manufacturing (booking_service composed, make_booking factory)—, each decision a lesson applied: the root for the shared (3), function for the mutable (4), composition for the service (5), factory for the on-demand bookings (7), return because there are no resources to close (6). You wrote a suite of five tests that uses the whole backbone without a scaffolding line, ran it green, and read the whole graph with --setup-show —the composition, the reverse teardown, the function scope, the caching by scope, the whole module in one block—.
And you verified the return on the investment: the change that in the "before" of lesson 1 touched two hundred tests, with the backbone is made in one file and two fixtures, without opening a single test. That is the backbone fulfilling its promise.
Before closing the module you should be able to: design from scratch the fixtures layer of a suite in strata (data, collaborators, composite); justify each decision of location, scope, composition and manufacture with the lesson that backs it; and read a --setup-show as the portrait of your framework's dependency graph.
What comes next is to build on the backbone. In module 3 you are going to take this fixtures layer and organize it into a suite structure —folders by layer (unit/integration) or by feature, one conftest.py per folder, test discovery, and how the structure communicates the framework's intent—. The fixtures you designed here are the ones that will live in that structure. You have the backbone; module 3 gives shape to the complete skeleton.
Resources
- How to use fixtures in pytest — the complete official guide, which you now go through fluently: you recognize the composition, the scope,
yield, the factories andautouseas pieces of the backbone you just built. In English. pytest --setup-show— the flag you used to read your backbone's complete graph. Running it on any unfamiliar suite is the fastest way to understand its fixture architecture without reading the wholeconftest.py.conftest.py: sharing fixtures across files — the reference for the file where your backbone lives, the bridge to module 3, which organizes this fixtures layer into the suite's folder structure. In English.