Module 5: Integration Testing Real Components Together
1. Module introduction: now, the pieces together
Description
You've reached the module that names half the guide. Up to here you've done an enormous amount of work without yet joining two real pieces. Module 1 showed you the gap —a green unit test can hide a broken integration— and module 2 sharpened it with a concrete divergence. Modules 3 and 4 gave you the contract: a shared battery you run against the FakeBookingRepository and against the SqliteBookingRepository, and that screams in red if the fake lies about any clause you've written. With that, you no longer pray for the double to behave like the real one: you verify it. But notice what, exactly, a contract does. It runs the fake alone, verifies its clauses; runs the real one alone, verifies its own. It certifies each piece separately, against a spec. It never put BookingService to talk with the real repository in a live flow.
That's what's missing, and it's precisely what the word integration means: connect two real components and see them work together, crossing the seam, in a real flow. Not "the service against a double" or "the repository against its spec": the service and the repository, both real, collaborating. It's the difference between certifying separately that the outlet meets the standard and that the charger meets the standard, and actually plugging the real charger into the real outlet and seeing whether it charges the phone. This module is plugging in. You're going to write your first real integration test —BookingService with the SqliteBookingRepository—, a booking the service creates, that's written to a SQLite table and read back. And you're going to see, with pytest output, something no previous module showed you: the complete flow catching a bug that the isolated contract, if it had a gap, let through.
Connection to the module: this lesson is the map of the territory you're going to travel. Here you meet the conceptual leap —from "each piece certified" to "the pieces working together"—, you see a first real book→get green so you know where we're going, and you get the map of the eight lessons and the border with what comes next. Lesson 2 nails the definition of "real integration test"; lesson 3 builds the first one in depth; lesson 4 gives you the rule of what to double and what not; lesson 5 names solitary versus sociable; lesson 6 collects the reward —integration catching the datetime→str in the complete flow—; lesson 7 measures its cost. The hard border: the specific boundaries —transactions, files, HTTP— are module 6, and the data and isolation —rollback, resource fixtures— are module 7. Here we install integration as a concept and write the first real test.
Analogy: certifying the pieces versus assembling the furniture
Think of a piece of furniture you buy in parts to assemble at home. The factory does impeccable quality control: every screw meets its thread standard, every board has the specified thickness, every hinge withstands the opening cycles it promises. They certify piece by piece, each against its spec sheet, and all pass. That's exactly what a contract does: it verifies that each component meets its specification, in isolation. And yet anyone who's ever assembled furniture knows that certified pieces don't guarantee a piece of furniture that stands. Board A's hole doesn't line up with board B's; the screw meets its standard but is two millimeters longer than the wood's thickness and pokes out the other side; the perfect hinge doesn't close because the door and the frame, each correct, together don't fit. The problem is never in one piece: it's at the moment when two pieces come together and you discover their contact surfaces don't match.
Assembling the furniture —putting the real screw into the real hole of the real board— is the integration test. It doesn't test the pieces; the factory quality control already did that. It tests the joins: that the booking BookingService creates really enters the SQLite table's column, that the datetime one piece writes the other can read and use, that the complete flow stands on its joints. In this module you stop reading spec sheets and grab the screwdriver: you plug the real service into the real repository and see whether the furniture holds up.
Back to Reservo, with the real repository plugged in
Let's recall the two pieces we're going to join. The orchestrator hasn't changed since the doubles guide: BookingService(calendar, clock, payments, emails, repo) coordinates its collaborators. book validates availability, charges, saves, and confirms; cancel reads the booking, computes the refund with the clock, refunds, saves the cancelled status, and notifies. The anchor numbers as always: Focus costs 2500 cents per hour; three hours for the pro member Ana (20% discount) cost 6000 cents; on cancellation, refund_cents returns 6000 at 72h before the start, 3000 at 36h, and 0 at 12h.
The other piece is the real repository, the one you already met in module 1 and that the module 3 and 4 contract certified alongside the fake:
# 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(), 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]
It's sqlite3, which ships with Python, without any external dependency. It stores each booking as a row of a table of text and numbers. Note down, for later, the same line that was the whole story of module 1: in get, start=row[3] comes out as text, a str, because save stored it with .isoformat() and nobody converts it back. In this module, that seam will finally be exercised in a complete flow, and you're going to see what happens when another real piece tries to use that str.
Worked example: your first integration, at a glance
Before getting into definitions, let's see where we're going. This is a real integration test, the simplest there is in Reservo: real BookingService conversing with the real SqliteBookingRepository. There's no fake at the seam that matters to us —the repository is SQLite in the flesh—. The service books; the booking crosses the seam into a SQLite table; and then we read it back with get and verify that the complete flow left what it should. The collaborators that are not the seam under test —the payment, the email, the clock— stay doubled, and in lesson 4 you'll see why that's correct and not a cheat.
# tests/test_book_get_integration.py — book -> get against real SQLite
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) # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9)
def make_service(repo):
return BookingService(
Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo,
)
def test_book_then_get_persists_through_the_real_db():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id) # read from the real table
assert saved.id == booking.id
assert saved.room_id == "focus"
assert saved.member_id == "m-ana"
assert saved.status == "confirmed"
assert saved.price_cents == 6000 # the correct charge, crosses intact
Notice what it verifies and what it doesn't. It checks that the booking, after being written to SQLite and read back, keeps its id, its room, its member, its confirmed status, and its price of 6000 cents. It doesn't yet verify the start —the field we know changes shape—; we'll get to that assertion in lesson 6, when we use it to catch the bug. Here we want the first thing anyone would want from an integration: that the basic flow —book and read back against the real database— works.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_get_integration.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
tests/test_book_get_integration.py::test_book_then_get_persists_through_the_real_db PASSED [100%]
============================== 1 passed in 0.01s ===============================
Green. There's your first integration: BookingService and SqliteBookingRepository, both real, working together. The booking the service created was really written to a SQLite table and read back with the fields that matter intact. It's not a unit test —the repository isn't a double, it's the piece that runs in production—; and it's not a contract —we didn't verify an interface's clauses, we verified a complete book→get flow crossing the seam—. It's what this whole module develops, seen in one test. Everything that follows is understanding what you just did, doing it deliberately, and collecting what integration gives you back.
The module's map: the eight lessons
It's worth seeing the journey, because each lesson leans on the previous one and all build toward lesson 6's reward.
| Lesson | Topic | The idea in one sentence |
|---|---|---|
| 1 | The pieces together (this one) | From "each piece certified separately" to "the pieces working together crossing the seam" |
| 2 | What a real integration is | Two or more real components crossing the seam being tested, not a double on each side |
| 3 | BookingService + SQLite together | book writes to a real table; get reads it back; the booking survives reopening the file |
| 4 | What to double and what to keep real | Double the slow/non-deterministic/external; leave real the seam you test |
| 5 | Solitary versus sociable | Everything doubled around (solitary) versus leaving the neighbors real (sociable) |
| 6 | Integration catches what the unit doesn't | book→cancel→get: the fake passes, SQLite blows up with a TypeError, and the fix makes it green |
| 7 | The cost of integration | Hundreds of times slower on disk, and you have to seed and clean the database |
| 8 | Mini-project | Write the book→get integration against real SQLite and turn in the verified flow green |
This module is the first real integration test and the criterion for writing it well. If you understand what a real integration is, what to leave real and what to double, and why the complete flow catches what the isolated pieces don't, modules 6 and 7 are tuning the tools to do it at the difficult boundaries and with the data under control.
What this module 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 the next module.
The specific boundaries in depth are module 6. Here we use SQLite as "the real piece on the other side of the repository seam", and we exercise it in a flow. But the quirks specific to each boundary —a SQLite transaction with its commit and its rollback, reading and writing a real file, an HTTP call to a stdlib http.server, and how to do all that fast and deterministic— are module 6. In this module, SQLite is the real collaborator we integrate, not the resource whose boundaries we study under a magnifying glass.
The data and isolation are module 7. You'll see that an integration needs to seed the database beforehand and leave it clean afterward, and in lesson 7 we'll name it as a cost. But the techniques for doing it well —isolating tests with rollback, fixtures that create and destroy a temporary database, keeping the tests independent and repeatable when they share real state— are module 7. Here we seed and clean by hand just enough for the first integration to run; the art of isolating comes later.
The web framework isn't this guide's. Reservo integrates in-process: BookingService plus a real SqliteBookingRepository, and at most, in module 6, a minimal stdlib http.server. Testing a complete web app —with FastAPI, routes, the end-to-end request-response cycle— is testing-backend-applications-guide. Every time a topic touches that edge, we link it and move on.
Common mistakes
Believing that "the contract passed green" already proves the pieces work together. What happens: the module 3 and 4 contract certified the fake and the real one, eight greens, and someone concludes that there's no need to integrate. Why it happens: a green contract is a strong guarantee and it's easy to believe it covers everything. How to detect it: ask yourself whether any test put BookingService to use the real repository in a flow, or whether only the repository was verified against its spec. If nobody exercised the collaboration, the joints weren't tested. How to fix it: the contract certifies each piece separately; integration verifies that they collaborate. They're different and complementary guarantees —you'll see it raw in lesson 6, where a contract with a gap lets through a bug the complete flow catches—.
Calling "integration" any test that touches a real piece in passing. What happens: a test doubles the repository but uses a real in-memory Calendar, and someone calls it integration. Why it happens: "uses something real" sounds like integration. How to detect it: ask yourself which is the seam the test tests and whether that seam has a real piece on each side. If the seam that matters to you is doubled, it isn't an integration of that seam, however many incidental real pieces there are. How to fix it: integration is about a concrete seam; name it and verify that you cross it with the real piece. Lesson 2 sharpens this distinction.
Wanting to integrate everything at once to "really test it". What happens: someone, excited, builds BookingService with the real payment, the real email, and the real database, and calls it the definitive test. Why it happens: "all real" seems the most honest. How to detect it: if your test charges real cards, sends real emails, or takes seconds, you've gone from integration to a fragile and expensive end-to-end. How to fix it: a good integration leaves real only the seam it tests (the repository) and doubles the rest (payment, email, clock). That mix is lesson 4, and the reason it exists is lesson 7's cost.
Exercises
Exercise 1 — Contract or integration? For each description, say whether it's a contract test (certifies a piece against its spec, in isolation) or an integration one (two real pieces working together in a flow): (a) running the repository's four-clause battery against the FakeBookingRepository and against the SqliteBookingRepository; (b) calling service.book(...) with a real SqliteBookingRepository and then repo.get(...) to verify the booking landed; (c) verifying that SqliteBookingRepository.get of a missing id raises KeyError; (d) running book→cancel→get with BookingService and the real repository.
See solution
- (a) Contract. It's the parametrized battery from modules 3 and 4: it verifies each repository implementation against the same clauses, separately. There's no
BookingServiceflow crossing the seam; there's a spec certified against two providers. It's pure contract. - (b) Integration. Real
BookingServiceand realSqliteBookingRepositorycollaborate: the service creates the booking, writes it to the real database, and we read it back. Two real pieces crossing the seam in a flow. It's lesson 3's integration. - (c) Contract (or narrow integration of the repository against its database). It exercises the real repository against its real resource, without
BookingService. It's one of the contract's clauses; you can also see it as the narrowest possible integration —one piece, one seam—. The fine distinction is lesson 2's; what it's not is a unit test with doubles. - (d) Integration. The complete flow with two real pieces:
bookwrites,cancelreads and recomputes,getreads again, all against real SQLite. It's lesson 6's broad integration, the one that catches thedatetime→str.
The rule you're sharpening: the contract asks "does this piece meet its spec?"; integration asks "do these two real pieces work together?". They're different questions, and this guide gives you both tools so you don't confuse them.
Exercise 2 — What the first integration doesn't verify. The worked example's test checks id, room_id, member_id, status, and price_cents, but deliberately does not verify saved.start == START. Without running anything, explain why that assertion was left for lesson 6 and what you think would happen if you added it now.
See solution
It was left out because start is the field we know changes shape when crossing the seam: save stores it with .isoformat() as text, and get returns it as str without converting it back to datetime. If you added assert saved.start == START to the example's test, it would fail, because it would compare '2026-03-10T09:00:00' (a str) against datetime(2026, 3, 10, 9, 0), and that's False. It's exactly module 1's divergence.
The reason for postponing it is pedagogical: this lesson wants to show you an integration passing, so you see the tool's clean shape before using it to catch bugs. The fields we chose to verify —id, room, member, status, price— are all of types with a native equivalent in SQLite (text and int), so they cross the seam without changing shape and the test passes green. The start assertion, which is the one that reveals the bug, we reserve for lesson 6, where we'll also take it to the complete flow book→cancel→get to see that the problem isn't only of shape but of use: cancel can't subtract a str.
Exercise 3 — The argument for your team. A colleague says: "we already have the module 3 and 4 contract green for the repository; writing integration tests too is duplicating work". Write a three- or four-sentence answer that explains why the contract doesn't replace integration, leaning on the furniture analogy.
See solution
A possible answer:
"The contract is the factory quality control: it certifies that each piece meets its spec sheet separately —the repository saves and reads, raises when an id is missing, updates instead of duplicating—. But a piece of furniture doesn't collapse because a piece is defective; it collapses at the joints, when the real screw enters the real hole and you discover they don't align. Integration is assembling the furniture: it puts BookingService to use the real repository in a book→get flow, and tests the join, which is exactly what the contract, verifying each piece in isolation, doesn't touch. It's not duplicating work: it's testing a different thing —the collaboration, not the pieces—, and in lesson 6 we're going to see a bug the complete flow catches and a contract with a gap lets through."
The essence: don't pit contract and integration against each other, but locate each. The contract guarantees the pieces meet their spec; integration guarantees they collaborate. A mature team has both, because each covers a class of failure the other doesn't see.
Summary and next step
In this lesson you took the leap that names the module: from certifying each piece separately (the module 3 and 4 contract) to seeing them work together crossing the seam (integration). With the assembled furniture you understood that certified pieces don't guarantee a piece of furniture that stands: the joins fail where nobody tested them. And you saw your first real integration with pytest output: BookingService and SqliteBookingRepository, both real, with a book→get that writes to a SQLite table and reads it back, green. You have the map of the eight lessons and the border with modules 6 (specific boundaries) and 7 (data and isolation).
Before moving on you should be able to: distinguish a contract test (a piece against its spec) from an integration one (two real pieces together); explain why the contract doesn't replace integration; and recognize what we left real (the repository, the seam under test) and what doubled (payment, email, clock) in that first integration, even if the in-depth why is lesson 4.
What comes next is nailing the definition until no ambiguity remains. In lesson 2 we're going to say precisely what a real integration test is —and isn't—: why "touching a real piece" isn't enough if it isn't the seam you test, and what an integration asserts that a unit test is unable to assert. Understanding that definition with an edge is what lets you write integrations that prove something, instead of confusing tests that are neither one thing nor the other.
Resources
- pytest documentation — Getting Started — the official gateway to pytest, the tool we run and cite every output in the module 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 component" on the other side of the repository seam throughout the integration; zero external dependencies.- Martin Fowler — IntegrationTest — the frame that defines what an integration test is and why the term means different things to different people; context for the definition lesson 2 nails.
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 module leaves out and works only with the in-process services plus SQLite.