Module 1: From Units To Integration
1. Module introduction: Reservo debuts a real repository
Description
Welcome to the contract and integration testing guide. If you're coming from the test doubles guide, you already know how to do something powerful: isolate a unit from its collaborators. You learned to replace the real PaymentGateway with a double that says it charged without charging anything, the EmailSender with a spy that records the sends without sending them, and the database with a FakeBookingRepository that stores bookings in an in-memory dict. With those doubles, BookingService.book runs its full logic —validate, charge, save, confirm— in hundredths of a second, without touching a card or filling anyone's inbox. It's a tool you'll keep using for the rest of your career.
And it has a blind spot. A double is not the real thing: it's an assumption about how the real thing behaves, written by you. When you wrote the FakeBookingRepository, you decided that save would store the object as-is and that get would return it identical. That's a reasonable assumption —and the real database doesn't work that way—. SQLite doesn't store Python objects: it stores text and numbers in a table. When you ask for a booking back, it reconstructs it from those columns, and the datetime you stored comes back as a str. The fake and the real thing don't behave the same. Your unit test, which only talked to the fake, never noticed. Production, which talks to SQLite, did. That crack —between what your double assumes and what the real thing does— is the problem the two disciplines in this guide exist to close.
Connection to the module: this lesson is the map, not the territory. Here you won't close the gap yet; you'll see it. You'll meet this guide's Reservo —the same BookingService with its collaborators, now with a real SqliteBookingRepository alongside the fake—, the order of the eight modules, and the border with the sister guides. This module installs the why of integration: why a green unit test isn't enough, what it means to test the pieces together, and why the seam between two components is at once the opportunity to double and the risk of diverging. Modules 3 and 4 give it the systematic solution (the contract); modules 5, 6, and 7 take it to real resources (SQLite, files, HTTP). It all starts here, understanding what breaks when you connect the real piece.
Analogy: the scale model and the building
Think of an architect designing a building. Before building, they put together a scale model: each piece —the columns, the beams, the slabs— is drawn and calculated separately, and each one, in isolation, is perfect. The column holds the weight it's supposed to hold; the beam has the calculated strength; the slab meets its standard. You check piece by piece and everything passes. But a building doesn't collapse because a column is weak: it collapses at the joints, at the point where the beam rests on the column and you discover that the bolt is two millimeters too short, or that the steel in one piece expands differently from the steel in the other. Each piece met its standard separately; nobody verified that they fit together. That's why, before people come in, a different test is done: the assembled structure is loaded and you watch whether the joints hold. It doesn't test the pieces —that's already been done—; it tests the joins.
A unit test tests the pieces. price_cents computes correctly; refund_cents applies the right anchors; book, with doubles, orchestrates in the proper order. Each piece, in isolation, passes. An integration test tests the joints: what happens when BookingService rests on the real SqliteBookingRepository, when the booking one piece writes is read by the other, when the datetime crosses the seam between the Python object and the SQLite table. The building with all the correct pieces collapses at a poorly calculated joint; the system with all its unit tests green breaks at a seam where the double assumed one thing and the real thing does another. This guide is learning to test the joints.
Reservo, now with a real repository
Let's recall the domain. It doesn't change from the sister guides:
# reservo/models.py — the domain
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Room:
id: str
name: str # "Focus", "Studio", "Boardroom"
capacity: int
hourly_cents: int # price per hour, IN CENTS (int) — never float
@dataclass
class Member:
id: str
name: str
tier: str # "basic" | "pro" (pro gets a 20% discount)
@dataclass
class Booking:
id: str
room_id: str
member_id: str
start: datetime
end: datetime
status: str # "confirmed" | "cancelled"
price_cents: int # what was charged for this booking, in cents
The orchestrator doesn't change either: BookingService(calendar, clock, payments, emails, repo) still coordinates its collaborators. book validates availability, charges, saves, and confirms; cancel computes the refund with the clock, returns the money, saves the cancelled status, and notifies. The anchor numbers you'll see over and over: Focus costs 2500 cents per hour; three hours for a pro member (20% discount) cost 6000 cents; and on cancellation, refund_cents returns 6000 at 72h before the start, 3000 at 36h, and 0 at 12h.
What's new in this guide lives in a single collaborator: the repository. Until now you only had one, the fake:
# reservo/doubles.py — the fake repository (you already know it)
class FakeBookingRepository:
def __init__(self):
self._store = {}
def save(self, booking):
self._store[booking.id] = booking
def get(self, booking_id):
return self._store[booking_id] # KeyError if it doesn't exist
def find_by_room(self, room_id):
return [b for b in self._store.values() if b.room_id == room_id]
Now its real twin appears, the one that plays "the real system" throughout the guide. It uses sqlite3, which ships with Python —zero external dependencies—, and stores bookings in a table:
# reservo/sqlite_repo.py — the REAL repository, with sqlite3 from the stdlib
from reservo.models import Booking
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
member_id TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
status TEXT NOT NULL,
price_cents INTEGER NOT NULL
)
"""
class SqliteBookingRepository:
def __init__(self, connection):
self._conn = connection
self._conn.execute(SCHEMA)
def save(self, booking):
self._conn.execute(
"INSERT INTO bookings "
"(id, room_id, member_id, start, end, status, price_cents) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"room_id=excluded.room_id, member_id=excluded.member_id, "
"start=excluded.start, end=excluded.end, "
"status=excluded.status, price_cents=excluded.price_cents",
(
booking.id,
booking.room_id,
booking.member_id,
booking.start.isoformat(), # datetime -> ISO text
booking.end.isoformat(),
booking.status,
booking.price_cents,
),
)
self._conn.commit()
def get(self, booking_id):
row = self._conn.execute(
"SELECT id, room_id, member_id, start, end, status, price_cents "
"FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
raise KeyError(booking_id)
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=row[3], end=row[4], # comes out as str, not as datetime
status=row[5], price_cents=row[6],
)
def find_by_room(self, room_id):
rows = self._conn.execute(
"SELECT id, room_id, member_id, start, end, status, price_cents "
"FROM bookings WHERE room_id = ?",
(room_id,),
).fetchall()
return [Booking(*r) for r in rows]
You don't need to memorize the SQL —we'll break it down calmly in modules 5 and 6—. Just notice two lines that are the whole story of this module. In save, we store booking.start.isoformat(): the datetime is converted to text to fit in a TEXT column. In get, we read start=row[3] without converting it back: it comes out exactly as it was stored, a str. The fake returned the datetime intact; the real one returns text. Two implementations of the same BookingRepository interface (save, get, find_by_room), with behavior that doesn't match. That mismatch is what a unit test can't see and an integration test can. (This version of get doesn't reconstruct the datetime yet —we leave it broken on purpose to see the gap—; we fix it in module 3, when it's time to contract-test the repository.)
Worked example: the gap, at a glance
Before getting into the why, let's see the gap with our own eyes —a preview of the module, don't write it yet—. It's the same book, tested twice. Once with the FakeBookingRepository (a unit test: the isolated unit with an in-memory double); once with the real SqliteBookingRepository (an integration test: BookingService and the real repository, together). Both tests assert exactly the same thing: that the saved booking has price_cents == 6000 and that its start is the one we booked.
# tests/test_book_persists_correctly.py
import sqlite3
from datetime import datetime
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, 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) # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9)
def make_service(repo):
return BookingService(
Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo,
)
# --- unit: the repository is an in-memory double ---
def test_book_persists_the_booking_with_fake_repo():
repo = FakeBookingRepository()
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the correct charge
assert saved.start == START # the booking was stored intact
# --- integration: the repository is real SQLite ---
def test_book_persists_the_booking_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the correct charge
assert saved.start == START # <-- here the two repos diverge
Two tests, identical except for which repository they receive. Let's run them.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_persists_correctly.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_book_persists_correctly.py::test_book_persists_the_booking_with_fake_repo PASSED [ 50%]
tests/test_book_persists_correctly.py::test_book_persists_the_booking_with_sqlite_repo FAILED [100%]
=================================== FAILURES ===================================
_______________ test_book_persists_the_booking_with_sqlite_repo ________________
...
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the correct charge
> assert saved.start == START # <-- here the two repos diverge
E AssertionError: assert '2026-03-10T09:00:00' == datetime.datetime(2026, 3, 10, 9, 0)
tests/test_book_persists_correctly.py:50: AssertionError
========================= 1 failed, 1 passed in 0.02s ==========================
There's the gap, no rhetoric. The unit test with the fake passes: the saved booking has the correct price and the intact start. The integration test with the real repository fails, and it fails exactly on the start line: '2026-03-10T09:00:00' (a str) is not equal to datetime.datetime(2026, 3, 10, 9, 0). The price, an integer, crossed the seam without trouble in both repos —that's why price_cents == 6000 passes in both—; the datetime didn't. The fake could never catch this bug, because the fake is the assumption that turned out false. Only the real piece reveals it. This is the whole module in two tests: learning to see, understand, and value that difference.
The guide's map: the eight modules
It's worth seeing the full journey, because each module leans on the previous one. The guide takes you from "I know how to isolate with doubles" to "I know how to verify that the real pieces fit, and I keep my doubles honest with a contract."
| Module | Topic | The idea in one sentence |
|---|---|---|
| 1 | From units to integration (this one) | Why integration exists: isolated pieces pass, the joints fail |
| 2 | The double that lied | A concrete divergence: the fake returns None where the real one raises, and the bug passes the unit test |
| 3 | Contract testing: consumer and provider | The contract: a shared spec both sides honor, built by hand |
| 4 | Verifying the contract from both sides | The consumer test and the provider test; running the same battery against the fake and the real |
| 5 | Integrating real components together | BookingService + real SqliteBookingRepository, crossing the seam |
| 6 | Real boundaries: DB, files, HTTP | A SQLite transaction, a file, a call to a stdlib http.server |
| 7 | Test data and isolation in integration | Rollback to isolate, fixtures for real resources, keeping tests repeatable |
| 8 | Capstone | A consumer-driven contract + its verification against the fake and the real + an integration suite |
This module is the conceptual foundation. If you understand well what a seam is, why a double can lie, and when testing the joints is worth its cost, everything else is learning the concrete tools to do what you'll already know needs doing.
What this guide does NOT cover (the border)
It's worth marking the limits from the start, because there are neighboring topics that look like they belong here and belong to another guide in the Testing ecosystem.
Doubles are not re-taught. This guide assumes you already know how to build and use a stub, a spy, a mock, and a fake, and that you understand the seam and dependency injection. All of that is the sister guide test-doubles-and-test-data-guide. Here we start from "I already know how to double; now I want to verify that my double doesn't lie and test against the real thing."
Testing a full web app doesn't belong here. This guide works with Reservo's in-process services plus a real SQLite repository (stdlib) and, at most, a minimal HTTP boundary with the stdlib http.server. It doesn't teach FastAPI, routes, or a web framework's request-response cycle. Testing a real backend application —with its framework, its server, its end-to-end HTTP— is testing-backend-applications-guide. We teach you to test the seam; it teaches you to test the app.
Fundamentals and TDD are testing-fundamentals-and-tdd-guide; property-based testing (Hypothesis) is property-based-and-advanced-testing-guide; and running all this in CI is testing-in-cicd-guide. Every time a topic touches those edges, we link it and move on.
Common mistakes
Believing that "all unit tests green" means "the system works". What happens: the unit suite is impeccable, green end to end, and someone concludes the system is healthy and deploys. Why it happens: it's intuitive to think that if each piece passes, the whole passes. How to detect it: ask yourself whether any test touches the real piece at the seam you care about —the database, the network—. If they all use doubles, no joint was tested. How to fix it: unit tests test the pieces; you need at least one test that tests the joints with the real thing. That's the integration test, and that's why this guide exists.
Confusing "unit test" with "fast, small test". What happens: someone calls any short test a unit test, even if it touches SQLite or the network, as long as it runs fast. Why it happens: "unit" sounds like "little code", not "isolated". How to detect it: if the test could fail for something that isn't your code —a full disk, a locked file, a different date—, it isn't isolated, and it isn't a unit test. How to fix it: the keyword is isolation, not size. A unit test isolates the unit with doubles; the moment you let a real piece in, you've crossed into integration —which is fine, as long as you know you did it—.
Believing integration replaces unit tests. What happens: someone, burned by a seam bug, decides to "test everything against the real thing" and throws the doubles in the trash. Why it happens: if the real thing caught the bug, the real thing always seems better. How to detect it: if your suite takes minutes, is fragile, and fails because of the infrastructure's weather, you've swung to the other extreme. How to fix it: it's not integration versus unit; it's integration plus unit. The unit tests give you speed and precision; the integration ones, confidence in the joints. The pyramid in lesson 3 is exactly the proportion between the two.
Exercises
Exercise 1 — Unit or integration? For each of these Reservo tests, say whether it's a unit test (isolated unit with doubles) or an integration test (two or more real pieces together): (a) testing price_cents(FOCUS, ANA, 3) == 6000 directly; (b) testing book with FakeBookingRepository, StubPaymentGateway, and SpyEmailSender; (c) testing book with the real SqliteBookingRepository and verifying that the row landed in the table; (d) testing that SqliteBookingRepository.get of a missing id raises KeyError.
See solution
- (a)
price_centsdirectly — unit. It's pure logic: it takes data, returns an integer, no collaborators. It doesn't even need doubles. It's the cleanest unit test there is. - (b)
bookwith three doubles — unit.BookingServiceis the unit; its collaborators are all doubled (fake, stub, spy). Nothing real crosses the seam: the orchestration logic is tested in isolation. Fast and deterministic. - (c)
bookwith the realSqliteBookingRepository— integration. HereBookingServiceand the real repository work together: the booking the service creates crosses the seam into SQLite and is stored in a real table. Both pieces and their joint are tested. - (d)
SqliteBookingRepository.getof a missing id — integration. Even though only the repository is involved, you're testing the real component against its real resource (the database), not a double. It's a narrow integration: a single real piece against its boundary. (The "narrow vs broad" distinction is lesson 6.)
The rule you're discovering: as soon as a real piece appears at the seam —the SQLite repository, the network, the disk—, you stopped isolating and moved to integrating. With only doubles, it's unit; with at least one real piece crossing its seam, it's integration.
Exercise 2 — Why price_cents yes and start no? In the worked example, the integration test failed on start but the assertion price_cents == 6000 passed, even against the real repository. Explain why the price crossed the seam without trouble and the datetime didn't.
See solution
Because price_cents is an integer, and SQLite has a native type for integers: the price_cents INTEGER column stores 6000 as a number and returns 6000 as a number. The value goes and comes back across the seam without changing type, so saved.price_cents == 6000 is int == int and passes.
The start, on the other hand, is a datetime, and SQLite has no native type for datetime. It has to be serialized: in save we convert it to text with .isoformat() so it fits in the start TEXT column. When read back, it comes out as text —a str— and nobody converts it back to datetime. So saved.start == START is str == datetime, which is False.
The underlying lesson: the seam converts data from one format to another (Python object ↔ table row), and in that conversion the types that have no native equivalent change shape. The fake never serializes anything —it stores the object as-is—, so it never exposes this problem. Only the real piece, which does serialize, reveals it. That's why a double can lie precisely about what matters most to test.
Exercise 3 — The unit test that wasn't enough. Imagine your team has 200 Reservo unit tests, all green, all with FakeBookingRepository. It's deployed to production, which uses SqliteBookingRepository, and an hour later a screen showing "Your booking starts on {start}" looks broken. Without yet knowing the solution (modules 3 to 7), explain: why did the 200 green tests not prevent it, and what kind of test was missing?
See solution
The 200 green tests didn't prevent it because none touched the real piece at the seam where the bug lived. They all used FakeBookingRepository, which returns the datetime intact, so they all saw a start that was a real datetime and a screen that formatted fine. The bug —that SqliteBookingRepository.get returns start as a str— lives exactly in the difference between the fake and the real, and no test that uses only the fake can see it. The unit tests tested the BookingService piece to perfection; nobody tested the joint between BookingService and the real database.
What was missing was an integration test: at least one test that connected BookingService (or the repository directly) with the real SqliteBookingRepository and verified that a stored-and-retrieved booking keeps its start in the shape the screen expects. That single test —crossing the seam with the real piece— would have failed red before the deploy, pointing to the exact line. The whole guide is how to write that test, and how to keep the fake honest with a contract so that the next divergence also trips.
Summary and next step
In this lesson you took the leap that defines the guide: from isolating a unit with doubles to verifying that the real pieces work together. You met this guide's Reservo —the same BookingService, now with a real SqliteBookingRepository alongside the FakeBookingRepository you were already using— and you saw, with the scale model and the building, that a system with all the correct pieces breaks at the joints. And you saw the gap with real output: the same book that passes green with the fake fails with the real repository, exactly on the line of the datetime that crosses the seam and changes shape.
Before moving on you should be able to: distinguish a unit test (doubles, isolated) from an integration test (real pieces together); explain why "all unit tests green" doesn't guarantee the system works; and recount, with the start example, how a double can lie about exactly what matters most to test.
What comes next is sharpening the first of those ideas until it has no ambiguity. In lesson 2 we'll put the two definitions side by side —what exactly a unit test is, what exactly an integration test is, what question each one answers— with the same book seen by both. Understanding that distinction precisely is the foundation everything else stands on.
Resources
- pytest documentation — Getting Started — the official gateway to pytest, the tool we run and cite every output in the guide with; useful to reconfirm your environment (Python 3.14, pytest 9.1.1) before starting.
sqlite3— DB-API for SQLite (Python documentation) — the reference for the stdlib module that plays the "real system" throughout the guide; in particular, the section on how SQLite handles (and doesn't handle) Python types, which is the root of thedatetimedivergence.test-doubles-and-test-data-guide— the sister guide where you built theFakeBookingRepositoryand the other doubles; if anything about stubs, spies, or fakes is shaky, that's the place to review before continuing.testing-backend-applications-guide— the sister guide on the other side of the border: testing a real web app (framework, routes, end-to-end HTTP), which this guide deliberately leaves out.