Module 1: From Loose Scripts To A Framework
4. The four layers of a framework
Overview
By the end of this lesson you will hold the complete map of a test framework: its four layers —fixtures, utilities, configuration and data—, what problem each one solves, how they lean on one another, and why separating them is what lets you change one piece without touching the others. This is the workshop blueprint before nailing the first board, and it is also the index of the whole guide: every layer you see named here has a dedicated module later (fixtures in 2 and 3, config in 4, utilities in 5, plugins in 6, data and environments in 7). Leaving this lesson knowing the map means that, when in module 5 you build a shared assertion, you know exactly which layer you are standing on and which others it relates to.
And you are not going to see the map drawn in the air: you are going to run a minimal Reservo suite where the four layers work together in two tests —a fixture that provides the world, a factory that manufactures a booking, a shared assertion that verifies the refund, and a marker that lets you run a subset—. Seeing the four layers cooperate in something that runs and gives 2 passed turns the map from an abstract list into a machine you understand.
Connection to the module: lesson 3 defined what a framework is (the structure you build on pytest). This lesson opens that structure and shows you its four compartments. Lesson 5 gets into the tension that cuts across two of those layers (utilities and fixtures versus the clarity of the test); lesson 6 shows what happens when none of the four exists and the suite scales; lesson 7 does the math on how much to invest in building them. Each later module takes one layer from this map and makes it deep. Here you see them all at a glance, so you do not get lost when we go down into each one.
Analogy: the four areas of a professional kitchen
Let us go back to the kitchen that serves three hundred plates a night, because its floor plan explains the framework better than any diagram. A well-set-up professional kitchen has four areas, and it is no coincidence that they resemble our four layers:
- The mise en place: the stations set up with everything ready before the first order arrives —boards, knives, containers in their place, the oil at hand—. It is the setup of each dish, done once and available to all. In the framework, that is the fixtures.
- The mother sauces and standardized techniques: the base sauce that dozens of dishes use, the agreed way of plating. Nobody reinvents the espagnole sauce on each dish; it is made, and the dishes use it. In the framework, those are the utilities —the shared helpers and assertions—.
- The ticket system and the labeled stations: what is cooked on the grill, what cold, what is urgent, what waits. It is what organizes the flow and lets the chef ask for "only the grill stuff now". In the framework, that is the configuration —the markers and options—.
- The pantry and the supplier: where the ingredients come from, in what quantity, with what quality, and how the menu adapts if an ingredient changes with the season. In the framework, that is the data and environments layer —the factories and the per-environment config—.
Remove any of the four and the kitchen limps. Without mise en place, each cook sets up their station from scratch on every dish (the tax of lesson 2). Without mother sauces, everyone invents their own sauce and none tastes the same (copied and inconsistent logic). Without tickets, everything is cooked at once and you cannot prioritize (you cannot run a subset). Without an ordered pantry, the ingredients arrive as they come and the menu breaks when the season changes (fragile, non-portable data). A test framework is those four areas, set up for your suite.
The four layers, one by one
Layer 1 — Fixtures: the reusable setup
What it is. The single place where the test's world preparation lives: building the Focus room, the member Ana, the empty calendar. Instead of each test assembling it, a fixture assembles it once and the tests request it by name.
What problem it solves. Exactly the tax of lesson 2: the copied setup that breaks en masse. With fixtures, the signature of Room lives in one point; when it changes, it is edited there and the tests never even notice.
Where it is deepened. Module 2 (fixture architecture: conftest.py hierarchy, scope, composition) and module 3 (how the folder and conftest.py organization distributes the fixtures across the suite).
It is the backbone of the framework: the other three layers lean on it. A factory (data) usually builds objects that a fixture delivers; a shared assertion (utilities) usually receives objects that came out of a fixture. That is why the guide devotes two modules to it and treats it first.
Layer 2 — Utilities: the shared helpers and assertions
What it is. The library of functions your tests share to act and assert without copying logic: a helper that computes now from a lead time, a custom assertion like assert_refund(booking, paid, hours_before, expected) that encapsulates "cancelling this many hours ahead refunds this much".
What problem it solves. The copy-paste of logic (not of setup). When the same expected-refund calculation, or the same assertion pattern, appears in fifteen tests, a helper puts it in one place. It is the "tax of lesson 2", but of the part that acts and asserts, not the part that prepares.
Where it is deepened. Module 5, including its characteristic tension —reusing without hiding what the test asserts— that lesson 5 of this module already previews.
Layer 3 — Configuration: the framework contract
What it is. The custom markers (@pytest.mark.slow, @pytest.mark.integration, @pytest.mark.pricing) and the options in pyproject.toml or pytest.ini (markers, addopts, testpaths). It is the layer that labels and orders.
What problem it solves. The impossibility of running a subset. Without markers, you run the whole suite or nothing; with them, you run "only the fast ones before the commit" or "only the pricing ones while I work on that rule". It also registers the contract: which markers are valid, so that a typo (@pytest.mark.priceng) is an error and not a test that silently never runs.
Where it is deepened. Module 4 (markers, pyproject.toml, selection with -m, the config as contract).
Layer 4 — Data and environments: the materials, portable
What it is. The factories/builders that manufacture domain objects with sensible defaults and everything override-able (make_booking(price_cents=6000)), and the per-environment configuration (local vs CI) that keeps the framework portable: no absolute paths, no machine assumptions.
What problem it solves. The setup that is almost the same but not identical —where a fixed fixture falls short because each test needs a variation—, and the fragility of a framework that only runs on your computer. A factory gives you "a booking like this, but with these two fields different" without building everything by hand.
Where it is deepened. Module 7 (data and environments architecture, portability) and, for builders/factories in depth, the sibling guide test-doubles-and-test-data-guide, which this layer uses as a component.
How they lean on each other
The four layers are not four isolated drawers; they form a small architecture where each rests on the others. It is worth seeing, because it is what lets you change one without breaking the others:
- Fixtures (layer 1) usually use factories (layer 4): a
paid_bookingfixture can internally callmake_booking(...)to build the booking. The fixture decides what world to deliver; the factory decides how to manufacture each piece. - Utilities (layer 2) usually receive objects that came out of fixtures or factories:
assert_refund(booking, ...)receives abookingthat probably came from a fixture or a factory. - Configuration (layer 3) is cross-cutting: the markers label tests that use any of the other layers, and the options (
testpaths) tell pytest where to discover everything. - Data and environments (layer 4) parametrize the others: the per-environment config can change what a fixture builds (an in-memory database locally, another in CI), without the tests changing.
The valuable property of having separate layers is decoupling: you can change how a factory manufactures a booking (layer 4) without touching the assertion that verifies it (layer 2), or register a new marker (layer 3) without touching any fixture (layer 1). Each layer has one responsibility, and that separation is what keeps the framework maintainable as it grows —the same principle that separates models, views and controllers in a production app—.
Worked example: the four layers in two tests
Let us bring the map down to code that runs. We are going to set up a minimal Reservo framework where the four layers appear, each in its file, and two tests use them.
Layer 3 (config): we register a marker in pyproject.toml, so that pricing is a valid marker and not a silent typo.
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"pricing: tests of the pricing rule",
]
Layer 4 (data): a booking factory with sensible defaults and everything override-able.
# factories.py
from datetime import datetime, timedelta
from reservo.models import Booking
def make_booking(price_cents=6000, start=None, hours=3, status="confirmed"):
"""Booking factory for tests: sensible defaults, everything override-able."""
start = start or datetime(2026, 3, 10, 12, 0)
return Booking(id="b1", room_id="r1", member_id="m1",
start=start, end=start + timedelta(hours=hours),
status=status, price_cents=price_cents)
Layer 2 (utilities): a shared assertion that encapsulates the pattern "cancelling this many hours ahead refunds this much".
# helpers.py
from datetime import timedelta
from reservo.pricing import refund_cents
def assert_refund(booking, paid_cents, hours_before, expected):
"""Shared assertion: cancelling `hours_before` before start refunds `expected`."""
now = booking.start - timedelta(hours=hours_before)
actual = refund_cents(booking, paid_cents, now)
assert actual == expected, f"refund at {hours_before}h: expected {expected}, got {actual}"
Layer 1 (fixtures): the shared setup in conftest.py.
# conftest.py
import pytest
from reservo.models import Room, Member
@pytest.fixture
def focus_room():
return Room(id="r1", name="Focus", capacity=1, hourly_cents=2500)
@pytest.fixture
def pro_member():
return Member(id="m2", name="Bruno", tier="pro")
And the two tests that bring it all together. I marked in comments which layer contributes each piece:
# test_all_layers.py
import pytest
from reservo.pricing import price_cents
from factories import make_booking # layer 4: data
from helpers import assert_refund # layer 2: utilities
@pytest.mark.pricing # layer 3: config (marker)
def test_pro_price(focus_room, pro_member): # layer 1: fixtures
assert price_cents(focus_room, pro_member, 3) == 6000
def test_refund_boundaries():
booking = make_booking(price_cents=6000) # layer 4: data
assert_refund(booking, 6000, 72, 6000) # layer 2: utilities
assert_refund(booking, 6000, 36, 3000)
assert_refund(booking, 6000, 12, 0)
Look at test_refund_boundaries: in three lines it asserts the three anchor refunds (6000, 3000, 0), because the factory gave it the booking and the shared assertion gave it the verification. Without those two layers, that test would have the Booking construction block and the now calculation copied three times. Let us run everything.
What to expect. With pytest -q:
.. [100%]
2 passed in 0.01s
Two tests, green, with the four layers cooperating. Now let us see the configuration layer in action: thanks to the pricing marker, we can run only the pricing test and leave the refunds one out.
What to expect. With pytest -q -m pricing:
. [100%]
1 passed, 1 deselected in 0.01s
1 passed, 1 deselected: pytest ran the test marked with pricing and deselected the other. That -m pricing is the config layer doing its job —letting you run a subset—, and it is impossible without having registered the marker first. The four layers, in fourteen lines of test, executed.
Common mistakes
Putting the four layers in one giant file. What happens: someone understands that there are fixtures, helpers, config and factories, and piles them all into conftest.py, which grows to six hundred lines. Why it happens: "it is all setup, it goes in conftest". But mixing the layers kills the decoupling that makes them useful: you can no longer change a factory without risking a fixture. How to detect it: if your conftest.py has data factories, custom assertions and fixtures, it is doing three jobs. How to fix it: separate by responsibility —conftest.py for fixtures, factories.py for data, helpers.py for utilities, pyproject.toml for config—. The file structure is part of the architecture (module 3).
Building layers the suite does not need yet. What happens: someone reads this map and sets up the four complete layers for a suite of twenty tests, with factories for objects used once and markers that select nothing. Why it happens: the map reads like a checklist of "things I should have". But the layers are responses to pains, not requirements. If you do not have the pain, the layer is dead weight. How to detect it: a factory used by a single test, a marker that never filters, a shared assertion that shares a single line. How to fix it: build the layer when the pain appears (the rule of the third duplicate, lesson 7). The map tells you what exists; judgment tells you when you need it.
Confusing the data layer (factory) with the fixtures one. What happens: someone does not know whether "building a test booking" is fixture or factory, and uses them interchangeably, ending up with fixtures that receive a thousand parameters or factories requested as fixtures. Why it happens: both build objects and the line seems blurry. The distinction: a fixture delivers a ready world that pytest injects by name (good when many tests want the same scenario); a factory is a normal function that manufactures a variation on demand (good when each test wants something slightly different). How to detect it: if you find yourself passing many arguments to a fixture to vary it, you wanted a factory; if you copy the same factory call in twenty tests, you wanted a fixture. How to fix it: use them together —a fixture that internally calls a factory with the common scenario's defaults—, as seen in module 2 and 7.
Exercises
Exercise 1 — Assign each symptom to its layer. For each problem of a Reservo suite, say which layer solves it and in one sentence why. (a) "The empty calendar is built by hand in 60 tests." (b) "The '2500 × hours minus 20%' calculation is copied in 12 pro pricing tests." (c) "I can't run only the refund tests without running the whole suite." (d) "Each booking test builds a full Booking by hand even though it only cares about one field." (e) "The suite fails on my colleague's machine because it assumes a /Users/mike/... path."
See solution
- (a) Fixtures (layer 1). The empty calendar is setup repeated identically: it goes in a
calendarfixture that builds it once. - (b) Utilities (layer 2). A copied calculation or assertion pattern is shared logic: it goes in a helper or custom assertion, not copied 12 times.
- (c) Configuration (layer 3). Running a subset is the marker layer: label the refund tests and run them with
-m refunds. - (d) Data (layer 4). Building a full
Bookingwhen each test varies one field calls for a factory with defaults and overrides:make_booking(price_cents=...). - (e) Data and environments (layer 4). An absolute path breaks portability: it is the "environments" subtopic of layer 4 —the framework must not assume the machine—.
The lesson: every pain has its layer. Diagnosing which pain you have tells you which layer to build, and prevents building layers that cure nothing.
Exercise 2 — Trace the dependencies between layers. In the worked example, the paid_booking fixture (imagine we add it) could build its booking by calling the make_booking factory. (a) Which layer depends on which other in that case? (b) If tomorrow make_booking changes its defaults (say, hours=2 instead of 3), which tests are affected and why can that be good or bad? (c) Why should the assert_refund assertion (layer 2) not depend on the factory (layer 4)?
See solution
- (a) The fixtures layer (1) depends on the data layer (4): the
paid_bookingfixture usesmake_bookingto manufacture its booking. It is the healthy direction —the "ready world" is assembled with "manufactured materials"—. - (b) All the tests that use
paid_bookingwithout overridinghoursare affected. That a change in the factory propagates can be good (you change the default duration in one place and the whole suite inherits it) or bad (a test that implicitly depended onhours=3without saying so breaks or changes meaning unintentionally). The lesson: a factory's defaults are part of the contract; changing them has a propagation radius, just like changing a model. That is why tests that depend on a specific value should request it explicitly (make_booking(hours=3)) instead of relying on the default. - (c) Because they are different responsibilities.
assert_refundverifies a rule (cancelling at X hours refunds Y) and must work with anybooking, whether it comes from a factory, from a fixture or built by hand. If the assertion called the factory internally, you would couple the verification to a concrete way of manufacturing data, and you could not use it to verify a booking you assembled differently. Keeping layer 2 independent of layer 4 is what makes it reusable.
Exercise 3 — Design the map of a small suite. You are given a Reservo suite with these facts: 40 tests, all build the Focus room and the member Ana; 15 verify refunds with the same copied calculation; 10 are slow (they simulate calendars with thousands of bookings) and you want to be able to skip them locally; and 8 build Booking varying only price_cents. Design, in a list, what you would put in each layer (name the concrete fixtures, helpers, markers and factories you would create). Do not write the implementation; just the map.
See solution
A reasonable map:
- Fixtures (layer 1):
focus_room(builds the Focus room at 2500/h) andbasic_member(builds Ana, tier basic). The 40 tests use them. Solves the setup copied 40 times. - Utilities (layer 2): an
assert_refund(booking, paid, hours_before, expected)assertion that encapsulates the refund calculation copied in the 15 tests. Solves the duplicated logic. - Configuration (layer 3): a
slowmarker registered inpyproject.toml, put on the 10 slow tests, to run-m "not slow"locally. Solves the impossibility of skipping a subset. - Data (layer 4): a
make_booking(price_cents=...)factory with sensible defaults, for the 8 tests that only vary the price paid. Solves the fullBookingconstruction when only one field matters.
Judgment notes: the two fixtures are justified (40 and 40 uses); the assertion is justified (15 uses, well above the third duplicate); the marker is justified (10 slow tests, a real need to filter); the factory is justified (8 uses with variation). If any group had two or three uses instead of ten or forty, I would doubt building the layer —the map says what could exist, the count says what is worth building—.
Summary and next step
In this lesson you unfolded the complete map of the framework: its four layers. Fixtures (layer 1), the reusable setup, the backbone that cures the copy-paste tax. Utilities (layer 2), the shared helpers and assertions that cure the copy-paste of logic. Configuration (layer 3), the markers and options that are the contract and let you run subsets. And data and environments (layer 4), the factories and portable config that provide the materials without ties to your machine. You saw the four cooperate in two tests that gave 2 passed, and you saw the config layer filter with -m pricing a 1 passed, 1 deselected. And you understood the key property of keeping them separate: decoupling, which lets you change one without touching the others.
Before moving on you should be able to: name the four layers and the pain each one cures; assign a suite symptom to its layer; explain why separating the layers keeps the framework maintainable; and distinguish a fixture (ready world injected by name) from a factory (variation manufactured on demand).
What comes next is to get into the first great tension of the framework, the one that decides whether your architecture helps or gets in the way. Two of the four layers —fixtures and utilities— exist so you do not repeat yourself. But taken to the extreme, "not repeating yourself" hides what the test asserts, and a test that hides its assertion stops being a readable test. Lesson 5 marks that line: share the Arrange, never hide the Assert.
Resources
- How to use fixtures — pytest documentation — layer 1, in detail. It is the backbone on which the other three lean.
- How to mark tests with attributes — pytest documentation — layer 3: the markers like
@pytest.mark.pricingyou used for the-m. Notice why it is worth registering them in the config. - Configuration options (
pyproject.toml/pytest.ini) — pytest documentation — where layer 3 lives:markers,testpaths,addopts. It is the file where you declare the framework contract. - Test doubles and test data — sibling guide — pytest's "factory as fixture" pattern, which joins layer 1 and layer 4. The depth of builders and factories lives in the
test-doubles-and-test-data-guide; here it is a layer of the framework.