Module 8: Project Contract And Integration For Reservo

6. Isolate the integration with fixture and rollback

Description

Your integration passes green, but it has a crack you haven't attended to yet: it uses a real resource, and real resources persist. An in-memory fake dies with the instance; a SQLite database in a file is still there when the test ends, with the bookings it wrote. If two tests share that database, the first leaves its state and the second inherits it —it starts dirty, and fails for something that isn't its fault—. That's the fragility of shared real state, and it's the price of touching the real thing. In this lesson you see it happen with red output, and then you cure it with module 7's two techniques: a fixture that creates and destroys the resource, and a transaction that's reverted at the end of each test.

Isolation isn't an ornament of deliverable 3: it's what makes it reliable. An integration test that only passes "when it runs alone" or "when it runs first" isn't a test, it's a trap —it gives green by luck and red by contamination—. The three pillars of a test that's worth it are that it be independent (it doesn't depend on others or on the order), repeatable (it gives the same result every time), and deterministic (it doesn't fail because of the environment). Shared real state breaks all three. Isolating is restoring them. You're going to see the disease —a test that contaminates the next— and the two cures running green, and understand when each one is preferable.

Connection to the module: this lesson hardens deliverable 3 that you built in lesson 5. The bookgetcancel flow doesn't change; what changes is how the resource is delivered to it: no longer a loose connection, but one that a fixture creates clean and destroys when it ends, or a transaction that's reverted so nothing survives between tests. It's the capstone's last technical piece before the breaking-change hunt (lesson 7) and the formal delivery (lesson 8). With the isolation in place, your integration stops being a flow that passed once and becomes a test that passes always, alone or accompanied, in any order.

Analogy: the lab bench between experiments

In a chemistry lab, each experiment starts with the bench clean: sterilized equipment, empty tubes, the scale at zero. Nobody does an experiment over the leftovers of the previous one —a forgotten drop of a reagent would contaminate the result, and you wouldn't know whether your measurement is real or is garbage from the past experiment—. There are two ways to guarantee the clean bench. The first: for each experiment, you take new equipment from the cabinet and, when you finish, you throw it away or sterilize it —fresh equipment every time—. The second, faster when the equipment is expensive: you work over a tray on top of the bench, and when you finish you dump the whole tray in the trash, leaving the bench as it was —everything you did is undone in one stroke—.

The two ways are this lesson's two cures. The first —fresh equipment every time— is the fixture that creates and destroys: each test gets a new, empty database, and when it ends it's destroyed. The second —the tray that's dumped— is the transaction's rollback: each test works inside an open transaction, and when it ends a rollback is done, undoing everything it wrote without touching the starting state. In both cases the next test finds the bench clean: the scale at zero, the database with no bookings. Shared real state is doing chemistry over the previous experiment's leftovers; isolation is the discipline of always starting clean.

The disease: a test that contaminates the next

Let's first see the fragility, to understand what we cure. Imagine two integration tests that, for convenience, share a single connection to a database file —with commit, like the real save—. Each books the same room in the same slot, believing it starts clean:

# tests/test_shared_state_is_fragile.py — WITHOUT isolation (on purpose)
import sqlite3
from datetime import datetime

from reservo.calendar import Calendar
from reservo.doubles import FixedClock, SpyEmailSender, StubPaymentGateway
from reservo.models import Member, Room
from reservo.services import BookingService
from reservo.sqlite_repo import SqliteBookingRepository

FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
CLOCK = datetime(2026, 3, 1, 9)

# A single connection to a file, SHARED and with commit: nothing is cleaned between tests.
_conn = sqlite3.connect("leaky.db")
_repo = SqliteBookingRepository(_conn)


def _service():
    return BookingService(Calendar(), FixedClock(CLOCK),
                          StubPaymentGateway(ok=True), SpyEmailSender(), _repo)


def test_first_books_focus():
    booking = _service().book(FOCUS, ANA, START, END)   # books Focus 9-12
    assert booking.status == "confirmed"


def test_second_books_the_same_slot():
    # This test believes it starts clean, but the previous one left its booking in the file.
    booking = _service().book(FOCUS, ANA, START, END)   # the same slot: it clashes
    assert booking.status == "confirmed"

The second test does exactly the same as the first, and it should pass just the same. But it doesn't share only the code: it shares the database. The first test booked Focus from 9 to 12 and left it there, with commit. When the second tries to book the same slot, book queries availability, finds the first test's booking still in the table, and Calendar rejects it. Let's run:

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

python3 -m pytest tests/test_shared_state_is_fragile.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items

tests/test_shared_state_is_fragile.py::test_first_books_focus PASSED [ 50%]
tests/test_shared_state_is_fragile.py::test_second_books_the_same_slot FAILED [100%]

=================================== FAILURES ===================================
_______________________ test_second_books_the_same_slot ________________________

    def test_second_books_the_same_slot():
        # This test believes it starts clean, but the previous one left its booking in the file.
>       booking = _service().book(FOCUS, ANA, START, END)   # the same slot: it clashes

reservo/services.py:18: in book
    raise RoomUnavailable(room.id)
E   reservo.calendar.RoomUnavailable: focus
=========================== short test summary info ============================
FAILED tests/test_shared_state_is_fragile.py::test_second_books_the_same_slot
========================= 1 failed, 1 passed in 0.04s ==========================

There's the contamination, unadorned. The first test passed; the second failed with RoomUnavailable: focus, not because its code is wrong —it's identical to the first's, which passed— but because it inherited the booking the first left in the file. The second test isn't independent: its result depends on the first running before. And it's worse than it looks: if you reversed the order, the other would fail; if you ran the second alone, it would pass. A test that passes or fails depending on what ran before is useless as a safety net —you can't believe either the green or the red—. This is exactly what isolation cures.

Cure A: the fixture that creates and destroys

The first cure is fresh equipment every time: a fixture that delivers, per test, a new and empty database, and destroys it when it ends. It's the one you already used in lesson 5, now seen as an isolation technique. With :memory:, each connection is an independent database that lives only while the connection lives:

# tests/test_isolation.py — Cure A: fresh ephemeral DB per test
@pytest.fixture
def fresh_repo():
    conn = sqlite3.connect(":memory:")          # new, empty DB
    yield SqliteBookingRepository(conn)
    conn.close()                                # destroyed when it ends


def test_isolated_a_sees_one_booking(fresh_repo):
    _book_once(fresh_repo)
    assert len(fresh_repo.find_by_room("focus")) == 1


def test_isolated_b_sees_one_booking(fresh_repo):
    _book_once(fresh_repo)                       # books the SAME slot as A
    assert len(fresh_repo.find_by_room("focus")) == 1

The two tests book the same room and slot, like the ones in the sick example. But now each gets its own :memory: database, so neither sees the other's booking: each finds exactly one booking, its own. The key is the fixture's default scopefunction—: pytest runs it again for each test, so conn is a new connection to a new database each time. The yield delivers the repository to the test; what comes after the yield (conn.close()) is the teardown, which runs when it ends. Fresh equipment, thrown away when finished.

Cure B: the transaction that's reverted

The second cure is the tray that's dumped: a single database —perhaps a file that's expensive to create— shared between tests, but each test works inside a transaction that's reverted at the end, undoing everything it wrote. For this, save can't commit —a commit would make the write permanent and take it out of the rollback's reach—. A variant of the repository is used that leaves the transaction's control to the test:

# reservo/sqlite_repo_tx.py — variant that does NOT commit (leaves control to the test)
class UncommittedSqliteBookingRepository(SqliteBookingRepository):
    def save(self, booking):
        self._conn.execute("INSERT INTO bookings (...) VALUES (...) "
                           "ON CONFLICT(id) DO UPDATE SET ...", (...))
        # no commit: the write lives in the connection's open transaction

And the fixture wraps each test in a return point (SAVEPOINT) and does rollback to it when it ends. The connection to the file is created a single time (scope module), because opening it is what's expensive; what's repeated per test is the savepoint and its rollback:

# tests/test_isolation.py — Cure B: per-test rollback over a shared connection
@pytest.fixture(scope="module")
def shared_conn(tmp_path_factory):
    path = tmp_path_factory.mktemp("db") / "reservo.db"
    conn = sqlite3.connect(path)
    SqliteBookingRepository(conn)               # creates the schema a single time
    conn.commit()
    yield conn
    conn.close()


@pytest.fixture
def rolled_back_repo(shared_conn):
    shared_conn.execute("SAVEPOINT test")       # marks the starting point
    yield UncommittedSqliteBookingRepository(shared_conn)
    shared_conn.execute("ROLLBACK TO test")     # undoes everything of the test
    shared_conn.execute("RELEASE test")


def test_rollback_a_starts_empty(rolled_back_repo):
    assert rolled_back_repo.find_by_room("focus") == []    # nothing from the previous test
    _book_uncommitted(rolled_back_repo)
    assert len(rolled_back_repo.find_by_room("focus")) == 1


def test_rollback_b_starts_empty(rolled_back_repo):
    assert rolled_back_repo.find_by_room("focus") == []    # A's rollback cleaned up
    _book_uncommitted(rolled_back_repo)
    assert len(rolled_back_repo.find_by_room("focus")) == 1

Notice the first assertion of each test: find_by_room("focus") == []. It checks that the test starts empty —that the previous test's rollback really cleaned the tray—. Test A books Focus, verifies there's one, and when it ends the fixture does ROLLBACK TO test, deleting that booking. Test B, over the same connection to the same file, starts and finds the table empty again, because A's rollback undid its own. They share the expensive resource (the connection to the file) but not the state (each cleans itself with its rollback).

Worked example: the two cures, in green

Let's run the four tests —the two from the fresh fixture and the two from the rollback— together:

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

python3 -m pytest tests/test_isolation.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 4 items

tests/test_isolation.py::test_isolated_a_sees_one_booking PASSED [ 25%]
tests/test_isolation.py::test_isolated_b_sees_one_booking PASSED [ 50%]
tests/test_isolation.py::test_rollback_a_starts_empty PASSED [ 75%]
tests/test_isolation.py::test_rollback_b_starts_empty PASSED [100%]

============================== 4 passed in 0.02s ==============================

Four greens. The same two tests that, sharing state, contaminated each other and gave 1 failed, now —isolated— both pass, in any order and whether they run alone or together. Cure A (fresh fixture) and cure B (rollback) achieve the same by different paths: that each test starts with the bench clean. The == [] assertions at the start of the rollback tests are the explicit proof that the isolation works: each starts empty because the previous one undid its own. With this, your deliverable 3 doesn't just pass: it passes always.

When each cure

Both isolate; you choose by the cost of the resource. The practical rule:

  • Fresh fixture per test (cure A) when creating the resource is cheap. With SQLite :memory:, opening a connection and creating the schema is almost free, so a new database per test is ideal: maximum independence, zero risk of leakage between tests, and the code is the simplest —connect, yield, close—. For most of Reservo's integrations, this is the default choice.
  • Rollback over a shared connection (cure B) when creating the resource is expensive. If instead of :memory: you had a real database whose creation takes time (migrations, big schema, seed data), opening it once per test would be slow. There it's better to create it once (scope module or session) and isolate each test with a transaction that's reverted —you pay the cost of creating once and the rollback is blazing fast—.

For this capstone, with SQLite, cure A is the recommended one for its simplicity. Cure B is in your toolbox for the day the real resource is expensive —a real database in testing-backend-applications-guide, for example—. Knowing that both exist, and why you'd choose each, is part of the method.

Common mistakes

Using a file with a fixed name and not cleaning it. What happens: someone creates the database at test.db with a fixed name and doesn't delete it; the next test —or the next run— finds it with old data and fails mysteriously. Why it happens: the real state persists, and a fixed name makes it reappear. How to detect it: if a test passes the first time and fails the second, or depends on the order, it probably shares a file that isn't cleaned. How to fix it: use :memory: (which disappears with the connection) or pytest's tmp_path/tmp_path_factory (a unique file per test that pytest destroys). Never a fixed name without cleanup. This lesson's sick example used leaky.db on purpose to exhibit exactly this.

Doing commit when you want to isolate with rollback. What happens: the rollback strategy is used but the repository's save commits, so the write becomes permanent and the rollback doesn't undo it. Why it happens: the production repository commits, and it's reused as-is. How to detect it: if your fixture does rollback but the tests contaminate each other anyway, someone committed in the middle. How to fix it: to isolate with rollback, the code under test must not commit inside the test —use a variant without commit (like UncommittedSqliteBookingRepository) and leave the transaction's control to the fixture—. A commit closes the transaction and takes what's written out of the rollback's reach: they're incompatible.

Sharing the resource and the state by misusing the scope. What happens: someone puts the repository's fixture in scope module or session to "make it faster", but without rollback, so all the tests share the same database with accumulated state. Why it happens: "sharing the expensive resource" gets confused with "sharing the state". How to detect it: if you raised the scope and the tests started contaminating each other, you shared too much. How to fix it: you can share the resource (the connection, scope module) without sharing the state, if you isolate each test with rollback (cure B). Sharing the resource is a legitimate optimization; sharing the state is the disease. The rollback is what lets you have the one without the other.

Exercises

Exercise 1 — Diagnose the contamination. The test test_second_books_the_same_slot failed with RoomUnavailable: focus, but its code is identical to that of test_first_books_focus, which passed. Explain why it failed, which pillar of a good test broke, and what would happen if you ran test_second_books_the_same_slot alone (without the first).

See solution

Why it failed: the two tests share the same connection to leaky.db, and the real save commits, so the first test's booking stayed permanent in the file. When the second test tries to book the same slot, book queries availability with find_by_room, finds the first test's booking still in the table, and Calendar.is_available returns False, so book raises RoomUnavailable. The second test didn't fail because of its code —it failed because of the state it inherited from the first—.

Which pillar broke: independence. A good test must not depend on others or on the execution order; this one depends on the first running before and leaving its booking. (In passing, repeatability also breaks: the second run could behave differently from the first depending on what's in the file.)

If you ran the second alone: it would pass. Without the first, the file (freshly created or empty) doesn't have the Focus booking, so book finds the slot free and confirms. That's the classic symptom of contamination: the test passes isolated and fails accompanied, or the reverse. A test whose result changes depending on what ran before is useless as a safety net —that's why it has to be isolated—.

Exercise 2 — Choose the cure. For each situation say whether cure A (fresh fixture per test) or cure B (rollback over a shared connection) is preferable, and why: (a) Reservo's integration tests against SQLite :memory:; (b) a suite against a real database whose schema takes 3 seconds to create with its migrations; (c) a single integration test that runs isolated, with no neighbors.

See solution
  • (a) Cure A (fresh fixture). With :memory:, creating a new database per test is almost free, so maximum independence (a clean database per test) costs nothing. It's Reservo's default choice: the simplest code, zero leakage risk, and blazing fast.
  • (b) Cure B (rollback). If creating the schema takes 3 seconds, doing it per test would make the suite slow (3 s × N tests). It's better to create the database a single time (scope module/session) and isolate each test with a transaction that's reverted —you pay the 3 seconds once, and the per-test rollback is instant—. You share the expensive resource without sharing the state.
  • (c) Either (or none elaborate). A test that runs alone, with no neighbors to contaminate it, has less isolation pressure —there's no "previous test" to leave it garbage—. Even so, cure A (fresh fixture) is cheap good practice: it guarantees it starts clean even if tomorrow you add a neighbor. The rule: isolate by default; cure A's cost is so low there's no reason not to apply it.

The criterion in one sentence: cure A when the resource is cheap to create (almost always, with :memory:); cure B when it's expensive and you want to create it once and reuse it isolating with rollback.

Exercise 3 — Why the == [] at the start. The rollback tests start with assert rolled_back_repo.find_by_room("focus") == []. Explain what that assertion verifies, why it's important to have it, and what isolation bug it would catch if the rollback were wrong.

See solution

What it verifies: that the test starts with the database empty of Focus bookings —that is, that the isolation worked and the previous test left no residue—. It's an explicit check of the "I start clean" precondition.

Why it's important: without it, a test could pass by accident even though the isolation were broken. Imagine the rollback failed and test B started with A's booking still there: if B only verified "there's at least one Focus booking", it would pass with A's garbage, and you'd never know the isolation doesn't work. The == [] assertion at the start turns the isolation into something verified, not assumed: if the tray wasn't dumped, this line gives it away.

What bug it would catch: if the fixture's ROLLBACK TO test were wrong —for example, if the save committed and the write survived the rollback, or if the savepoint were misnamed—, test B would start with A's booking still in the table, find_by_room("focus") would return a list of length 1 instead of [], and the == [] assertion would fail at the start of B. That red would say, precisely, "the isolation between A and B doesn't work": exactly the bug you want to pop up early and not have it hide behind a lucky green.

Summary and next step

In this lesson you hardened deliverable 3 with module 7's isolation. You first saw the disease —two tests that share a database with commit and contaminate each other: the second fails with RoomUnavailable for inheriting the first's booking, breaking independence—. Then you applied the two cures and saw them green: the fresh fixture (cure A), which gives a new :memory: database per test and destroys it when it ends —fresh equipment every time—, and the rollback (cure B), which shares a connection to an expensive file but wraps each test in a SAVEPOINT that's reverted —the tray that's dumped—. With the lab bench between experiments you set the image: always start clean. And you learned when each one: A when the resource is cheap (almost always, with :memory:), B when it's expensive and you want to create it once and isolate with rollback.

Before moving on you should be able to: recognize contamination by shared real state and name the pillar it breaks; write a fixture that creates and destroys the resource, and one that isolates with SAVEPOINT/rollback; and choose between the two depending on the cost of the resource.

You have the three deliverables complete and robust: the contract from both sides, and the isolated end-to-end integration. What's missing is demonstrating why they were worth it. In lesson 7 you fire up the machinery: someone changes SqliteBookingRepository.get to return None, and you see the two faces of the same bug —the crime (a distant AttributeError in production, without a contract) and the arrest (the contract's surgical red, before the deploy)—. It's the golden payoff of the whole process you assembled.

Resources