Module 8: Project Build A Test Framework For Reservo
2. The fixtures backbone and the `conftest.py` hierarchy
Overview
The assembly begins, and it begins where it should: the backbone. Of all the framework's layers, the fixtures one is the one that holds up the others —the factory produces data the fixtures consume, the tests request fixtures by name, the booking_service that assembles the world of each integration test is a composed fixture—. If this layer is badly placed, everything that leans on it wobbles. That is why it is the first you reassemble, and the one that asks for the most architecture care, because here you do not only write fixtures: you decide where each one lives in the conftest.py hierarchy, and that placement decision is pure design.
By the end of this lesson you are going to have set up Reservo's complete backbone in three levels of conftest.py, each level with a clear responsibility: the root with the infrastructure (the collaborators and the composed booking_service fixture), tests/conftest.py with the domain data (the room, the members, the date, the paid booking), and tests/integration/conftest.py with the single fixture only the integration layer needs (confirmed_booking, a booking created by the real service). And you are going to read the whole graph instantiating itself with pytest --setup-show, the X-ray of the backbone you learned to interpret in module 2. In the end, the framework's first layer will be screwed in and verified.
Connection to the module: this lesson places the piece lesson 1 labeled "M2" in the tree. It is the framework's physical base —lesson 3 will put on top of it the folder structure and the markers, lesson 4 the assertions library, lesson 6 the factory that feeds these data fixtures—. The boundary with the neighboring lessons: here we decide where each fixture lives and how it composes, not what data it manufactures (that is the factory, lesson 6) or what folders contain it (that is lesson 3). And the boundary with the original module 2: there you learned the fixtures mechanism (scope, composition, yield, hierarchy); here you apply it to the framework's concrete architecture, with the placement decisions as the topic.
Analogy: the three floors of an office building
Think of a company's office building. Not everything lives on the same floor, and the reason is not whim: it is that each thing serves a different radius of people. On the ground floor, next to the entrance, is what everyone uses no matter what area they go to —the reception, the elevators, the restrooms, the cafeteria—. No one argues that goes below: if you put it on the seventh floor, the people on the second would have to go up five floors for a coffee.
On the intermediate floors is what many departments but not the street use —the shared meeting rooms, the print room, the employee dining room—. They do not go on the ground floor (a visitor does not enter the print room), but they do not belong to a single department either: several share them. And on a specific floor is what only that department needs —the R&D lab, with its specialized equipment no one else touches—. Putting it on the ground floor would be absurd: it would take up common space with something only a tenth of the company uses.
The conftest.py hierarchy is exactly that building. The root conftest.py is the ground floor: there goes what all the suite uses —Reservo's collaborators, the booking_service—, available to any test without anyone importing it. The conftest.py of tests/ is the intermediate floor: there go the domain data that many folders share —the room, the members— but that are not runner infrastructure. And the conftest.py of tests/integration/ is the department's floor: there goes what only the integration needs —confirmed_booking, the booking that went through the real service—, invisible to the unit tests that never request it. Placing each fixture on its floor according to who uses it is the architecture decision of this lesson. The rule is summed up in one phrase: a fixture lives in the nearest common ancestor of all the tests that use it —neither higher up (you would dirty floors that do not need it), nor lower down (you would leave out tests that do)—.
Level 1: the root — infrastructure and the composed backbone
Start with the ground floor. The root conftest.py is the home of the infrastructure: the pieces that assemble Reservo's world and that all the suite —unit and integration— needs. They are the BookingService's five collaborators and the composed fixture that unites them.
# 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.service import BookingService
# --- Collaborators: each piece in its fixture ---
@pytest.fixture
def calendar():
return Calendar()
@pytest.fixture
def clock():
# 72h before Monday 2026-03-02 09:00, so cancelling refunds everything.
return FixedClock(datetime(2026, 2, 27, 9))
@pytest.fixture
def payments():
return FakePaymentGateway()
@pytest.fixture
def emails():
return SpyEmailSender()
@pytest.fixture
def repo():
return FakeBookingRepository()
# --- The composed backbone ---
@pytest.fixture
def booking_service(calendar, clock, payments, emails, repo):
return BookingService(calendar, clock, payments, emails, repo)
Stop at two architecture decisions, because they are the ones this code makes and that in an exam you would have to justify.
First: the collaborators are in separate fixtures, not assembled inside booking_service. You could have written a single booking_service fixture that built the Calendar, the FixedClock and the others in its body. It would be shorter. But booking_service requests them as parameters —it composes them (module 2, lesson 5)—, and that buys you two things the monster-fixture would deny you. One: a test can request a loose collaborator —the payments— to inspect it (payments.charges), because it is the same instance the service uses inside (pytest assembles each fixture once per test and shares it). Two: you could override a collaborator in a folder —a payments that fails— without touching the definition of booking_service. The composition is not an adornment; it is what makes the backbone inspectable and specializable.
Second: the clock is fixed at a concrete time, 72 hours before Monday. FixedClock(datetime(2026, 2, 27, 9)) is not a random date: the suite's reference Monday is 2026-03-02 09:00, and 72 hours before is exactly 2026-02-27 09:00. With the clock there, when an integration test books something for Monday and cancels it, the anticipation will be 72 hours and the refund will be full (6000) —the anchor number—. A fixed clock instead of the real system clock is what makes the tests deterministic: they always give the same result, today and a year from now. That concrete time is a decision that ties the clock's behavior to a verifiable anchor number.
All these fixtures are function scope (the default): each test receives fresh instances. And it has to be so, it is not optional —the doubles record state (payments.charges grows, emails.sent grows, the calendar fills), and sharing them between tests would contaminate some with the effects of others (module 2, lesson 4)—. The composition already saved you the scaffolding; you do not additionally need to raise the scope, and doing it here would buy bugs that depend on the order.
Level 2: tests/conftest.py — the domain data
Go up to the intermediate floor. Reservo's data —the room, the members, the reference date, the paid booking— are not runner infrastructure: they are domain objects the tests read and assert. Many folders share them, so they do not go in a single test; but they are not the ground floor either. Their home is the conftest.py of tests/.
# tests/conftest.py
from datetime import datetime
import pytest
from tests.factories import make_booking, make_member, make_room
@pytest.fixture
def focus_room():
return make_room() # Focus, 2500/h
@pytest.fixture
def ana():
return make_member() # basic
@pytest.fixture
def bruno():
return make_member(id="m-bruno", name="Bruno", tier="pro")
@pytest.fixture
def monday_9am():
return datetime(2026, 3, 2, 9)
@pytest.fixture
def paid_booking():
# A pro booking paid 6000, for the refund tests.
return make_booking(
member_id="m-bruno",
price_cents=6000,
start=datetime(2026, 3, 2, 9),
end=datetime(2026, 3, 2, 12),
)
Notice the placement decision, which is the intermediate floor's. Why do these fixtures not go in the root next to booking_service? Because there is a difference of nature: booking_service and its collaborators are infrastructure —the testing machinery—, while focus_room, ana and paid_booking are domain data —the material the tests assert on—. Separating them into two levels of conftest.py is not mandatory for it to work (pytest would find them the same in the root), but it is a readability decision: whoever opens the root conftest.py sees the framework's infrastructure; whoever opens the one of tests/ sees the catalog of domain data. Each file tells a coherent story, instead of mixing hooks, collaborators and rooms in a single pile.
And notice something that connects with lesson 6: these data fixtures do not build the objects by hand —they do not write Room(id="focus", ...) with all the fields—, but request them from the factory (make_room(), make_member(...), make_booking(...)). The focus_room fixture is a thin adapter over make_room(): the factory knows how to build the room; the fixture offers it by name to the tests. That is the connection between the fixtures layer (M2) and the data layer (M7): the factory produces, the fixture delivers. For now, make_room and company are functions that exist in tests/factories.py; you build them in depth in lesson 6.
Level 3: tests/integration/conftest.py — what only one floor uses
Go up to the department's floor. There is a fixture that only the integration tests need: a booking that was not manufactured by hand or by the factory, but created by the real service, going through the whole flow —charged, stored, confirmed—. The unit tests never request it (they do not assemble the service), so putting it on the ground floor would dirty the common catalog with something from a single floor. Its home is the conftest.py of tests/integration/.
# tests/integration/conftest.py
from datetime import timedelta
import pytest
@pytest.fixture
def confirmed_booking(booking_service, focus_room, bruno, monday_9am):
# A booking that went through the real flow: charged and stored.
end = monday_9am + timedelta(hours=3)
return booking_service.book(focus_room, bruno, monday_9am, end)
This fixture illustrates two architecture ideas at once.
The dependency flows from inside outward. confirmed_booking lives in tests/integration/ but requests booking_service (from the root), focus_room, bruno and monday_9am (from tests/conftest.py). A fixture on an inner floor can use fixtures on outer floors —just as the R&D lab's room uses the ground floor's elevators—. What cannot happen is the reverse: a root fixture cannot request confirmed_booking, because the root does not "see" inward. The visibility is hierarchical: from inside you see everything outside, from outside you do not see what is inside.
It is a different datum from the intermediate floor's paid_booking, and that is why it lives on another floor. paid_booking (in tests/conftest.py) is a manufactured booking —the factory assembles it with the fields we tell it, without going through the service—; it serves the unit refund tests, which only need a Booking with a certain price. confirmed_booking (in tests/integration/conftest.py) is a booking created by the real flow —it went through booking_service.book, so it was really charged, stored in the repo, triggered the email—; it serves the integration cancellation tests, which need to verify the complete flow. Two bookings, two natures, two floors. That both are "a Booking" does not make them the same fixture: one is test data, the other is the result of exercising the system.
Worked example: read the backbone graph
The backbone is set up on three floors. Now see it instantiating itself. Run one of the integration tests —the one that uses the composed service— with --setup-show, the flag that shows each fixture setting up and tearing down:
python3 -m pytest tests/integration/test_booking_flow.py::test_booking_a_pro_charges_6000 --setup-show
What to expect. On my machine (Python 3.14.0, pytest 9.1.1), trimming the header and the summary to see the graph:
tests/integration/test_booking_flow.py
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 bruno
SETUP F monday_9am
tests/integration/test_booking_flow.py::test_booking_a_pro_charges_6000 (fixtures used: booking_service, bruno, calendar, clock, emails, focus_room, monday_9am, payments, repo) .
TEARDOWN F monday_9am
TEARDOWN F bruno
TEARDOWN F focus_room
TEARDOWN F booking_service
TEARDOWN F repo
TEARDOWN F emails
TEARDOWN F payments
TEARDOWN F clock
TEARDOWN F calendar
This block is the backbone made visible, and it is worth reading it with everything you know, because it proves the three floors connect:
The SETUP order respects the dependency graph. The five collaborators —calendar, clock, payments, emails, repo— are set up before booking_service, because it depends on them and cannot exist until they exist. The line SETUP F booking_service (fixtures used: calendar, clock, emails, payments, repo) says it explicit: pytest shows you what the composed one depends on. No one wrote that order; pytest deduced it from the graph (topological order). The sub-assemblies first, the final assembly after.
The fixtures of the three floors appear together, with no visible borders. In the graph coexist calendar (root), focus_room and bruno (tests/conftest.py), without anything marking what conftest.py each came from. For the test, the hierarchy is transparent: it requests booking_service, focus_room, bruno, monday_9am and payments, and pytest resolves them by looking outward from the test's folder. The hierarchy organizes the source code (what file contains what), not the execution (the test sees them all as a single namespace). That is the building's magic: the visitor uses the reception, the meeting room and the lab without thinking about what floor each is on.
The TEARDOWN runs in reverse order. booking_service is torn down before its collaborators, and calendar (the first to be assembled) is the last to be torn down —the exact reverse of the assembly (LIFO)—. The last thing to be set up is the first to be removed, because the pieces on top depend on the ones below and you cannot remove a piece while something that uses it is still alive.
That this graph assembles clean —with confirmed_booking available only here, focus_room shared, booking_service composed— is the proof that the three-floor hierarchy is well placed. The backbone holds up the suite.
Common mistakes
Putting everything in the root conftest.py "so everything reaches it". What happens: someone, to avoid thinking about the hierarchy, puts the fifteen fixtures —infrastructure, data and the integration one— in the root conftest.py. It works: pytest finds them all. But the file becomes a pile of things with no story, and confirmed_booking —which only the integration uses— ends up visible to the unit tests that never request it, like an R&D lab notice stuck at the reception. Why it happens: the root "reaches everything", so it seems the safe place. How to detect it: if your root conftest.py has fixtures that only one folder uses, or mixes hooks with rooms with doubles without separation, you raised too much. How to fix it: apply the common-ancestor rule —each fixture on the lowest floor that reaches all its users—. The common infrastructure, in the root; the domain data, in tests/conftest.py; what is specific to a layer, in the conftest.py of that folder. The hierarchy is not bureaucracy; it is what keeps each file telling a single story.
Assembling the collaborators inside booking_service instead of composing them. What happens: someone writes a booking_service fixture that builds the Calendar, the FixedClock and the others in its body, without requesting them as parameters. Why it happens: it looks shorter to have "everything in one fixture". How to detect it: if your booking_service fixture has collaborator constructors inside (Calendar(), FakePaymentGateway()) instead of receiving them as arguments, it is a monster-fixture, not a composed one. How to fix it: extract each collaborator to its fixture and compose. Only that way can a test request payments to inspect payments.charges —the test_booking_a_pro_charges_6000 needs it to verify the charge—, because the payments the test requests is the same the service uses. The monster-fixture hides the collaborators inside and denies you that inspection.
Confusing a manufactured datum with a system result. What happens: someone sees they already have paid_booking (a booking) and decides to reuse it also in the integration cancellation tests, instead of creating confirmed_booking. The tests "work", but they are lying: paid_booking never went through booking_service.book, so it was not really charged, not stored in the repo, did not trigger the email. An integration test that cancels a manufactured booking is not testing the real flow. Why it happens: both are "a Booking", and reusing seems to save. How to detect it: if an integration test asserts flow effects (that it was stored, that it was charged, that the email was sent) about a booking the factory manufactured instead of the service created, it is verifying a fake setup. How to fix it: distinguish the two natures. The manufactured datum (paid_booking, from the factory) serves unit tests that only need an object with certain fields; the resulting datum (confirmed_booking, from the service) serves integration tests that verify the complete flow. They live on different floors because they are different things.
Exercises
Exercise 1 — Place each fixture on its floor. For each fixture, say in which of the three conftest.py it goes (root, tests/, or tests/integration/) and why, using the common-ancestor rule. (a) booking_service, which assembles the service and is used by both the integration tests and a unit smoke test that wants to inspect a charge. (b) focus_room, the Focus room, used by pricing tests (unit) and booking tests (integration). (c) busy_calendar, a calendar with a confirmed booking preloaded, used only by the integration double-booking tests. (d) parsed_config, a fixture that reads the framework config and is used by a single test of a file tests/integration/test_environment.py.
See solution
- (a) Root. It is used by tests of two different folders (unit and integration), so its common ancestor is the root. Besides, it is infrastructure (the testing machinery), which is the nature that lives on the ground floor.
- (b)
tests/conftest.py(intermediate floor). It is used byunitandintegration, whose common ancestor istests/. And it is domain data (a room), not infrastructure, so its natural home is the dataconftest.py, not the root. (It would work in the root, but mixing it with the infrastructure dirties that file's story.) - (c)
tests/integration/conftest.py(the department's floor). Only the integration tests use it, so its common ancestor istests/integration/. Putting it higher up would make it visible tounit, which does not need it. - (d) It depends, but probably
tests/integration/conftest.pyor the file itself. If a single test of a single file uses it, the common ancestor is that file —it could live as a local fixture intest_environment.pyitself—. If you foresaw that more integration tests are going to use it,tests/integration/conftest.pyis defensible. The rule: do not raise it higher than where it is really used; a fixture a single test uses does not belong in a sharedconftest.py.
The mechanical rule you practice: find all the tests that use the fixture, find the nearest common ancestor folder, and put the fixture in the conftest.py of that folder —neither higher nor lower—. The nature (infrastructure vs data) breaks the tie between the root and tests/ when the common ancestor is the root.
Exercise 2 — Compose the backbone. You have the five collaborator fixtures (calendar, clock, payments, emails, repo) in the root conftest.py. Write the composed booking_service fixture that unites them, and then explain why a test that requests booking_service and payments receives the same payments the service uses inside.
See solution
The composed fixture requests the five collaborators as parameters and passes them to the constructor:
@pytest.fixture
def booking_service(calendar, clock, payments, emails, repo):
return BookingService(calendar, clock, payments, emails, repo)
Why the test and the service see the same payments: pytest assembles each fixture a single time per test and shares it among all its consumers within that test (cache by scope, module 2). When a test requests booking_service and payments, pytest assembles payments once; it passes it to booking_service (which stores it as its gateway) and also delivers it to the test. They are not two gateways: it is the same instance, referenced from two sides. That is why the test can do booking_service.book(...) and then verify payments.charges —it is inspecting the same object the service just modified—. If booking_service built its own payments inside (monster-fixture), the test would see a different gateway and the inspection would fail. The composition is what guarantees the shared instance.
Exercise 3 — Predict the graph. An integration test requests confirmed_booking, which in turn depends on booking_service, focus_room, bruno and monday_9am. Without running anything, describe: (a) In what approximate order are the fixtures assembled (what goes before what)? (b) Can confirmed_booking, which lives in tests/integration/conftest.py, depend on booking_service, which lives in the root? And could booking_service depend on confirmed_booking? (c) In what order is everything torn down?
See solution
- (a) First the graph's leaves are assembled, then what depends on them.
booking_serviceneeds its five collaborators (calendar,clock,payments,emails,repo), so those go first, thenbooking_service. Also assembled arefocus_room,brunoandmonday_9am(independent leaves). And at the end,confirmed_booking, which depends onbooking_service,focus_room,brunoandmonday_9am—it cannot be assembled until the four exist—. In--setup-showyou would see the collaborators and the data first, andconfirmed_bookingas one of the last, with(fixtures used: booking_service, bruno, focus_room, monday_9am). - (b) Yes it can:
confirmed_booking(inner floor) can depend onbooking_service(outer floor), because the visibility flows from inside outward —an inner floor sees everything on the outer floors—. The reverse cannot:booking_service(root) cannot depend onconfirmed_booking(integration), because the root does not see inward. If you tried, pytest would give a fixture-not-found error when running the unit suite, which does not haveconfirmed_bookingin sight. - (c) In reverse order of the assembly (LIFO).
confirmed_bookingis torn down first (it was the last assembled), then the data andbooking_service, and the collaborators at the end —calendar, which was assembled first, is torn down last—. The last thing in is the first thing out, because the pieces on top depend on the ones below.
The lesson: the graph is deduced by pytest from the dependencies you declare (the parameters), and the conftest.py hierarchy only decides where the code lives for each fixture, not the assembly order or the outward visibility.
Summary and next step
In this lesson you set up the framework's first layer: the fixtures backbone in three levels of conftest.py. The root carries the infrastructure —the five collaborators and the composed booking_service fixture, with the clock fixed 72 hours before Monday so cancelling refunds the anchor number—. tests/conftest.py carries the domain data —focus_room, ana, bruno, monday_9am, paid_booking—, thin adapters over the factory. And tests/integration/conftest.py carries confirmed_booking, the booking created by the real service that only the integration needs. Each fixture on the floor of the nearest common ancestor of its users: neither higher up (you would dirty other floors), nor lower down (you would leave out whoever uses it).
You read the whole graph with --setup-show and verified that the three floors connect seamlessly: the collaborators before booking_service (topological order), the fixtures of all the floors coexisting in a single namespace for the test (the hierarchy organizes the code, not the execution), and the teardown in reverse order (LIFO). The backbone holds up the suite.
Before moving on you should be able to: decide in which conftest.py a fixture goes with the common-ancestor rule; distinguish infrastructure (root) from domain data (tests/conftest.py) from what is specific to a layer (folder's conftest.py); compose booking_service and explain why the test shares an instance with the service; and read a --setup-show as the portrait of your backbone's graph.
What comes next is giving the backbone a physical shape and a contract. In lesson 3 you put the folder structure —tests/unit/ and tests/integration/ as layers governed by testpaths— and the markers: you register smoke, slow and integration in pyproject.toml, activate --strict-markers, and see the -m selection cutting the suite into subsets. The backbone you set up here will live inside that structure, and the markers will let you run only the slice you want.
Resources
- pytest —
conftest.py: sharing fixtures across files — the reference of theconftest.pyhierarchy you applied in three levels; how pytest looks for fixtures outward from the test's folder. - pytest — Fixtures can request other fixtures — the mechanics of the composition with which you assembled
booking_serviceandconfirmed_booking: a fixture names others as parameters and pytest resolves the graph. - pytest —
--setup-show— the flag you used to read the backbone's graph instantiating itself; running it on any foreign suite is the fastest way to understand its fixture architecture.