Module 3: Organizing The Suite Layers And Structure

8. Mini-project: reorganize a flat Reservo suite into layers

Overview

By the end of this lesson you will have put into practice, from start to finish, everything the module taught. The project is concrete: you are handed the flat Reservo suite —the twelve tests piled in a single directory, with one conftest.py that mixes the fixtures of the two layers— and you reorganize it into tests/unit/ and tests/integration/, each with its own conftest.py, moving each test to its layer and each fixture to the conftest.py that corresponds to it. You will run the two layers separately to see that the separation works, capture the --collect-only of the new tree to read it as documentation, and deliver a suite that runs clean and tells its own architecture through the shape. It is the "before" of lesson 2 turned into the "after" of the whole module, with your hands.

This matters because reorganizing an existing suite is one of the most frequent and least taught tasks of a project's real life. You almost never design the structure from scratch on an empty suite; you almost always inherit a flat bucket that grew without a plan —yours from months ago, or someone else's— and you have to give it shape without breaking what already works. The key, and what this project trains, is that reorganizing is an operation of shape, not of content: you move files and distribute fixtures, but you do not touch what each test asserts. The test count before and after must be identical. If you end up with more or fewer tests, or with a red, you changed two things at once and lost control. Reorganizing well is changing the box without touching what is inside.

Connection to the module: this lesson is the synthesis. Each step activates a previous lesson: separating the tests into two folders is the organization by layer (lesson 3) and the physical separation (lesson 5); distributing the fixtures among three conftest.py is the per-folder conftest.py (lesson 4); running each layer separately and reading the tree is discovery (lesson 6) and structure as documentation (lesson 7). And it closes the guide so far with a deliverable piece. Remember the boundary: here we categorize by folder; in module 4, the markers will add to this structure a second way of cutting the suite without moving a file.

The workshop you remodel without changing the tools

Think of it this way. You inherit the workshop of a carpenter who kept everything in a bucket: hammers, drill bits, screwdrivers, all jumbled. Your job is to set up the box with compartments —the one from lesson 2— so the workshop is navigable. But there is a golden rule in that remodel: you do not change or sharpen or repair any tool. The hammer that goes into the compartment is the same hammer that was in the bucket; it only changed place. If along the way you decided to sharpen the chisels, and something went wrong afterward, you would not know whether the problem was moving things or sharpening them. The remodel is one of organization, period: the same tools, now in compartments.

Reorganizing a suite is exactly that remodel. The twelve tests you inherit are the tools; the flat directory is the bucket; tests/unit/ and tests/integration/ are the compartments. Your job is to move each test to its compartment and distribute the fixtures to the correct conftest.py —nothing more—. You do not rename tests, do not merge asserts, do not "take the chance to improve": that would be sharpening the chisels in the middle of the move. The proof that you remodeled well and broke nothing is the simplest in the world: the same number of tests, all green, before and after. Twelve go into the bucket, twelve come out into the box.

The starting point: the flat suite

This is the suite you inherit. Twelve Reservo tests, all in a tests/ directory, with a single conftest.py. First, the flat conftest.py —and here is the underlying problem, besides the one of the folders—:

# tests/conftest.py — A SINGLE flat conftest: everything mixed, unit and integration
from datetime import datetime

import pytest

from reservo.calendar import Calendar
from reservo.models import Member, Room
from reservo.service import BookingService


@pytest.fixture
def focus():
    return Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)


@pytest.fixture
def ana():
    return Member(id="ana", name="Ana", tier="basic")


@pytest.fixture
def bruno():
    return Member(id="bruno", name="Bruno", tier="pro")


@pytest.fixture
def at():
    def _at(hour):
        return datetime(2026, 3, 10, hour)
    return _at


# --- these two are ONLY used by the integration tests, but here the WHOLE suite sees them ---
@pytest.fixture
def calendar():
    return Calendar()


@pytest.fixture
def service(calendar):
    return BookingService(calendar)

Read it with the module's eyes. The root conftest.py has everything piled up: the light domain fixtures (focus, ana, bruno, at) next to the heavy integration ones (calendar, service). Since it is in the root, the whole suite sees all six —including the unit tests that will never use service—. It is the contamination of lesson 4: the pasta machine stored in the communal kitchen. And the twelve tests, all in a flat directory:

tests/
├── conftest.py
├── test_booking_flow.py   # integration: assembles BookingService
├── test_cancel_flow.py    # integration: assembles BookingService
├── test_overlaps.py       # unit: pure overlaps()
├── test_pricing.py        # unit: pure price_cents()
└── test_refund.py         # unit: pure refund_cents()

Let us confirm the initial state: the fixture contamination and the starting green. First, what fixtures the whole suite sees:

python3 -m pytest tests --fixtures

What to expect (filtered to our fixtures). On my machine (Python 3.14.0, pytest 9.1.1):

focus -- tests/conftest.py:12
ana -- tests/conftest.py:17
bruno -- tests/conftest.py:22
at -- tests/conftest.py:27
calendar -- tests/conftest.py:35
service -- tests/conftest.py:40

The six fixtures, all in tests/conftest.py, all visible to all tests —the unit pricing ones see service even though they do not touch it—. And the starting green:

python3 -m pytest tests -q
............                                                             [100%]
12 passed in 0.01s

Twelve green. This is the number to preserve: remodeling well means that in the end they are still twelve, green. We take it down noted.

The remodel, step by step

Reorganizing is a sequence of shape moves. None of them touch what a test asserts.

Step 1 — Classify each test by nature. Before moving anything, decide the layer of each file with the criterion of lesson 5 (one piece versus several). The three unit ones are test_overlaps.py, test_pricing.py, test_refund.py —each tests a pure function—. The two integration ones are test_booking_flow.py and test_cancel_flow.py —each assembles a BookingService—.

Step 2 — Create the two folders and move the files. tests/unit/ for the three pure ones, tests/integration/ for the two social ones:

tests/unit/test_overlaps.py
tests/unit/test_pricing.py
tests/unit/test_refund.py
tests/integration/test_booking_flow.py
tests/integration/test_cancel_flow.py

Step 3 — Distribute the fixtures among three conftest.py. Here you apply the building's rule (lesson 4): the shared goes down to the root, the specific goes to its layer. The domain fixtures (focus, ana, bruno, at) are used by both layers, so they stay in the root. The heavy ones (calendar, service) are used only by integration, so they go down to tests/integration/conftest.py. The root conftest.py is left clean:

# tests/conftest.py — ROOT, now only with the shared domain
from datetime import datetime

import pytest

from reservo.models import Member, Room


@pytest.fixture
def focus():
    return Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)


@pytest.fixture
def ana():
    return Member(id="ana", name="Ana", tier="basic")


@pytest.fixture
def bruno():
    return Member(id="bruno", name="Bruno", tier="pro")


@pytest.fixture
def at():
    def _at(hour):
        return datetime(2026, 3, 10, hour)
    return _at

And the heavy ones move to their layer —notice that the root no longer imports Calendar or BookingService; those imports go with the fixtures—:

# tests/integration/conftest.py — the heavy fixtures, now in their layer
import pytest

from reservo.calendar import Calendar
from reservo.service import BookingService


@pytest.fixture
def calendar():
    return Calendar()


@pytest.fixture
def service(calendar):
    return BookingService(calendar)

Step 4 — Verify that the remodel did not change the content. Run the whole suite and confirm that they are still twelve, green. The shape changed; the content did not.

The resulting tree:

tests/
├── conftest.py              # domain: focus, ana, bruno, at
├── unit/
│   ├── test_overlaps.py
│   ├── test_pricing.py
│   └── test_refund.py
└── integration/
    ├── conftest.py          # heavy: calendar, service
    ├── test_booking_flow.py
    └── test_cancel_flow.py

Worked example: the reorganized suite, running by layers

Now the proof that the remodel worked, and that the layers are really separated. First, the fast layer on its own:

python3 -m pytest tests/unit -v

What to expect:

collected 7 items

tests/unit/test_overlaps.py::test_touching_intervals_do_not_overlap PASSED [ 14%]
tests/unit/test_overlaps.py::test_nested_interval_overlaps PASSED         [ 28%]
tests/unit/test_pricing.py::test_basic_member_pays_hourly_rate_times_hours PASSED [ 42%]
tests/unit/test_pricing.py::test_pro_member_gets_twenty_percent_off PASSED [ 57%]
tests/unit/test_refund.py::test_full_refund_at_72h PASSED                 [ 71%]
tests/unit/test_refund.py::test_half_refund_at_36h PASSED                 [ 85%]
tests/unit/test_refund.py::test_no_refund_at_12h PASSED                   [100%]

============================== 7 passed in 0.01s ===============================

Seven unit tests, alone. Now the integration layer on its own:

python3 -m pytest tests/integration -v

What to expect:

collected 5 items

tests/integration/test_booking_flow.py::test_booking_a_room_charges_the_pro_price PASSED [ 20%]
tests/integration/test_booking_flow.py::test_room_is_unavailable_after_it_is_booked PASSED [ 40%]
tests/integration/test_booking_flow.py::test_double_booking_the_same_slot_is_rejected PASSED [ 60%]
tests/integration/test_cancel_flow.py::test_cancelling_72h_ahead_refunds_in_full PASSED [ 80%]
tests/integration/test_cancel_flow.py::test_cancelling_frees_the_room PASSED [100%]

============================== 5 passed in 0.01s ===============================

Five integration ones, alone. Seven plus five are twelve: the same twelve of the flat bucket, not one more or one less. The remodel preserved the content (twelve tests, all green) and gained the shape (two layers runnable separately). That is reorganizing well.

And the proof that the fixtures were distributed and no longer contaminate: --fixtures in the unit layer no longer shows service.

python3 -m pytest tests/unit --fixtures

What to expect (filtered to our fixtures):

focus -- tests/conftest.py:10
ana -- tests/conftest.py:15
bruno -- tests/conftest.py:20
at -- tests/conftest.py:25

Four fixtures, all from the domain, all from the root. calendar and service disappeared from the unit layer's horizon —they now live in tests/integration/conftest.py, out of its reach—. The communal kitchen is left clean; the heavy machine is on its floor. Compared with the --fixtures from the start, which showed all six for the whole suite, this is the end of the contamination.

Read the new tree as documentation

The last deliverable is the board. Capture the tree of the reorganized suite:

python3 -m pytest --collect-only

What to expect:

collected 12 items

<Dir tests>
  <Dir integration>
    <Module test_booking_flow.py>
      <Function test_booking_a_room_charges_the_pro_price>
      <Function test_room_is_unavailable_after_it_is_booked>
      <Function test_double_booking_the_same_slot_is_rejected>
    <Module test_cancel_flow.py>
      <Function test_cancelling_72h_ahead_refunds_in_full>
      <Function test_cancelling_frees_the_room>
  <Dir unit>
    <Module test_overlaps.py>
      <Function test_touching_intervals_do_not_overlap>
      <Function test_nested_interval_overlaps>
    <Module test_pricing.py>
      <Function test_basic_member_pays_hourly_rate_times_hours>
      <Function test_pro_member_gets_twenty_percent_off>
    <Module test_refund.py>
      <Function test_full_refund_at_72h>
      <Function test_half_refund_at_36h>
      <Function test_no_refund_at_12h>

This tree is your final deliverable, and it reads itself (lesson 7): two layers, a core of pure logic (overlap, pricing, refund) under unit/, two complete flows (booking, cancelling) under integration/. The same --collect-only that at the start of the module was a flat list of five modules in a row is now a map. Without having touched what a single test asserts, you turned the bucket into the box with compartments.

Your delivery

The project asks you for three things, and each one exercises something different:

  1. The reorganized suite. Take the flat Reservo suite (the one of this lesson, or one you assemble by piling the twelve tests in a directory with a conftest.py that mixes the fixtures) and reorganize it into tests/unit/ and tests/integration/, with the root for the domain fixtures and tests/integration/conftest.py for calendar and service. It must run green by layers: pytest tests/unit (7) and pytest tests/integration (5).
  2. The proof that the content did not change. Run pytest tests (or plain pytest) before and after the remodel and capture the two counts: 12 passed at the start, 12 passed at the end. That identical number is the demonstration that you reorganized the shape without touching the content —you moved tools, did not sharpen them—.
  3. The tree as documentation and the end of contamination. Capture the --collect-only of the reorganized tree (that reads as the system's map) and the --fixtures of tests/unit showing that it no longer sees calendar or service. Those two are the evidence that you gained both things of the module: a shape that documents and layers that do not contaminate.

An honest "done" criterion, in the spirit of this guide: the remodel is ready not when the suite passes —the flat bucket also passed—, but when it passes by separate layers (7 and 5, not just 12 together), when the --fixtures of the unit layer no longer drags the integration machinery, and when a coworker who did not see the code can read the --collect-only and tell you the architecture of Reservo. If those three things hold, you set up the box with compartments.

Common mistakes

Changing the content "along the way" during the remodel (of scope). What happens: when moving the files, someone takes the chance to rename a test, merge two, or "fix" an assert. Why it happens: having the files open invites improving everything at once. How to detect it: if the count before and after is not identical (12 → 12), or if a red appears, you changed content and shape at once and no longer know which caused what. How to fix it: reorganize in one step —only moving files and distributing fixtures— and verify 12 passed12 passed. Improving the tests is another task, another moment, another commit. One thing at a time.

Forgetting to lower the heavy fixtures to their layer (of incomplete remodel). What happens: someone moves the test files to unit/ and integration/ but leaves all the fixtures in the root conftest.py. Why it happens: moving files feels like "done", and distributing fixtures is the least visible step. How to detect it: run pytest tests/unit --fixtures; if you still see calendar and service, the remodel was left half done —the folders were separated but the contamination continues—. How to fix it: move calendar and service (and their import of Calendar/BookingService) to tests/integration/conftest.py. The reorganization is not only of test files: the fixtures also travel to their layer, or the isolation is only apparent.

Classifying a test by its name or its size instead of its nature (of criterion). What happens: someone puts test_booking_flow.py in unit/ "because it is short" or test_pricing.py in integration/ "because pricing is central". Why it happens: size or importance gets confused with layer. How to detect it: look at what the test assembles —if it sets up a Calendar/BookingService, it is integration; if it calls a pure function, it is unit— no matter how many lines it has or how important the topic is. How to fix it: apply the criterion of lesson 5 (one piece versus several). A test that imports BookingService is not unit even if it fits in five lines; misplacing it would make the structure lie (lesson 7).

Exercises

Exercise 1 — Distribute these fixtures. Besides the six of the example, the flat suite has three more fixtures in its root conftest.py. For each one, say to which conftest.py you move it in the remodel and why. (a) boardroom, a large Room used by pricing tests (unit) and flow tests (integration). (b) booked_calendar, a Calendar with a booking already loaded, used only by integration tests. (c) three_hours, the constant 3 used only by the unit pricing tests.

See solution
  • (a) boardroom → stays in the root (tests/conftest.py). Both layers use it, so it is shared domain, like focus and ana. It goes down to the communal kitchen so both inherit it without duplicating it.
  • (b) booked_calendartests/integration/conftest.py. It assembles a Calendar with state and only integration tests use it: it is heavy machinery of a single layer. It moves with calendar and service.
  • (c) three_hourstests/unit/conftest.py. Only unit pricing tests use it; it is specific to that layer. If tests/unit/conftest.py does not yet exist, the remodel creates it to host it (it is the hours_3 of the previous lessons). It makes no sense in the root or in integration.

The rule you applied: "who uses it" decides the height. All layers → root; only integration → its conftest; only unit → the unit conftest. Distributing the fixtures well is the least visible, and most important, half of the remodel.

Exercise 2 — Verify without running. A coworker says they finished the remodel. Before running anything, what three commands would you ask them to execute to demonstrate they reorganized well, and what would you expect to see in each? Explain what each result would prove.

See solution

Three commands, each proves a different thing:

  1. pytest tests -q (or plain pytest) → I would expect 12 passed. It proves the content did not change: they are still the twelve tests of the flat bucket, all green. If it showed another number or some red, the remodel touched content, not only shape.
  2. pytest tests/unit -q and pytest tests/integration -q → I would expect 7 passed and 5 passed. It proves the layers are really separated and runnable on their own: 7 + 5 = 12, the subsets are disjoint and add up to the total. If pytest tests/unit gave 8, some integration test was misplaced in unit/.
  3. pytest tests/unit --fixtures → I would expect to see focus, ana, bruno, at but not calendar or service. It proves the fixtures were distributed and the contamination ended: the unit layer no longer sees the integration machinery. If it still showed service, the heavy fixtures are still in the root and the remodel was left half done.

The three together cover the two dimensions of a good remodel: content preserved (command 1) and shape gained, both in folders (command 2) and in fixtures (command 3). An optional fourth, pytest --collect-only, confirms that the tree reads as documentation.

Exercise 3 — Close the module: write the specification from the tree. Run pytest --collect-only on your reorganized suite and, using only the shape of the tree and the names of the tests, write the specification of Reservo that the suite documents: how many layers, what pure rules, what flows, and one business rule per test that you can read from its name. Then reflect: what did this reorganization give you that the flat bucket of lesson 2 did not?

See solution

From the reorganized tree, read without opening code, comes this specification:

Architecture: two test layers —unit (pure logic) and integration (complete flows)—.

Pure rules (under unit/):

  • Schedule overlap (test_overlaps): two intervals that touch do not overlap; an interval contained in another does.
  • Pricing (test_pricing): the basic member pays the hourly rate times the hours; the pro gets 20% off.
  • Refund (test_refund): at 72 h from the start the refund is full; at 36 h, half; at 12 h, nothing.

Complete flows (under integration/):

  • Booking (test_booking_flow): booking charges the correct pro price; after booking, the room becomes unavailable; a double booking of the same slot is rejected.
  • Cancelling (test_cancel_flow): cancelling 72 h ahead refunds the full amount; cancelling frees the room.

That is the living documentation of Reservo, extracted from the pure tree.

What the reorganization gave you that the flat bucket did not: the three things of the module. Being able to run by layerspytest tests/unit as a fast net during development, pytest tests/integration when you want to verify the flows—, which in the bucket required naming files by hand. Fixture isolation —the unit layer no longer loads calendar/service—, which in the bucket contaminated everyone. And a shape that documents —the tree you just read as a specification—, which in the bucket was a flat list without architecture. All that without changing what a single test asserts: the same twelve, now in a structure that can be navigated, run in parts and understood at a glance. That is the leap from "a pile of tests that passes" to "a suite with architecture", which was the topic of the whole module.

Summary and next step

In this mini-project you gathered the whole module into a real task: reorganize the flat Reservo suite into layers. You started from the bucket —twelve tests in a directory, with a conftest.py that piled the light domain fixtures next to the heavy integration ones, contaminating the whole suite— and you remodeled it in four steps of pure shape: you classified each test by nature, moved the files to tests/unit/ and tests/integration/, distributed the fixtures (the domain in the root, calendar/service in the integration layer), and verified that the content did not change. You verified it by running: 12 passed before and after (content preserved), pytest tests/unit gives 7 and pytest tests/integration gives 5 (layers runnable separately), the --fixtures of the unit layer no longer shows calendar or service (contamination ended), and the --collect-only draws a tree that reads as the specification of Reservo (shape that documents). You moved the tools without sharpening them: the bucket became the box with compartments.

With this you know how to design and apply the physical structure of a suite: organize by layer or by feature, a per-folder conftest.py that isolates, separate unit from integration to run them on their own, understand the discovery that makes everything possible, and read —and keep honest— the structure as documentation. You no longer confuse "the suite passes" with "the suite has architecture".

What comes next in the guide is module 4: markers and configuration. So far you categorized the tests by their physical place —the folder where they live—. The markers (@pytest.mark.slow, @pytest.mark.integration) will give you a second way of categorizing, orthogonal to the folders: a label you can stick on any test, wherever it is, to then select subsets with -m that cut the suite in a direction the folder structure cannot —for example, "all the slow tests, no matter what layer or feature they live in"—. You will see how to register markers in pyproject.toml, how the configuration becomes the framework's contract, and how folders and markers complement each other: the physical structure you built in this module, plus a layer of labels that crosses it. The foundations of organization you already have; what comes is the second dimension.

Resources