Module 5: Integration Testing Real Components Together
2. What a real integration test is
Description
Lesson 1 showed you an integration without defining it rigorously: we said "two real pieces working together" and left it there. Now we have to nail the definition, because "integration" is one of the most abused words in testing. People call anything integration: a test that takes a bit long, one that uses a real library, one that touches the disk in passing, one that tests two functions on the same line. With a definition that loose you can't decide anything —not what to test, not what to leave real, not what the green guarantees you—. This lesson gives you the precise definition and teaches you to use it like a scalpel: to separate what's really an integration from what only resembles one.
The definition we're going to use has two parts that have to be held together. First: an integration test puts two or more real components to work together. Second, and it's the part most people skip: those real components have to be on both sides of the seam you're testing. It's not enough for there to be a real piece in the test; that piece has to be the seam you're interested in verifying. A test can use ten real pieces by accident and not integrate the seam that matters, if it doubled exactly that one. And conversely: a test with a single real piece can be an impeccable integration, if that piece is exactly the seam under test. The seam, not the count of real pieces, is what defines the integration.
Connection to the module: this lesson sharpens what lesson 1 left coarse, and it's the basis of everything that follows. Once you know that integration is defined by which seam you cross with real pieces, lesson 3 builds that seam in depth (BookingService↔SQLite), lesson 4 tells you what to leave real and what to double around that seam, and lesson 5 gives you the names —solitary, sociable— for the degrees of "how many real neighbors". Without this lesson's precise definition, those distinctions become arbitrary; with it, each one falls into place. And it prepares lesson 6's reward: understanding that an integration asserts something —"these pieces collaborate"— that no unit test can assert, however green it is.
Analogy: testing the pipe versus testing the faucet
Think of a plumber who installed a new pipe connecting the water tank with the kitchen faucet. They want to test that the water arrives. There's a test that seems good and isn't: they close the valve joining the pipe with the tank, connect in its place a water syringe they squeeze themselves, and verify that squeezing makes water come out of the faucet. All real —the faucet is real, the kitchen is real, the water is real—, and yet they tested nothing they cared about: the join between the tank and the pipe, which is where the leak could be, was left out, replaced by their syringe. They used many real things, but doubled exactly the seam they wanted to verify.
The real test is the boring one: open the valve connecting the real tank with the real pipe, and see whether the water reaches the faucet. A single real join —the one that matters— actually tested. That's an integration: not "how many real things there are in the scene", but "is the seam I'm worried about crossed by real pieces on both sides?". The syringe is the double at the wrong seam; the open valve is the real seam under test. In Reservo, doubling the repository and using a real Calendar is the syringe: lots of incidental reality, but the seam you want to test —service↔database— was doubled. Leaving the repository real is opening the valve.
The definition, with its two halves
Let's write it in a way you can apply:
An integration test verifies that two or more real components collaborate correctly through the specific seam you're testing, exercising it in a real flow instead of replacing it with a double.
The two halves, each doing its job:
"Two or more real components". A unit test isolates a unit and doubles all its collaborators; an integration leaves real at least two pieces that talk to each other. In Reservo, BookingService (the unit) and SqliteBookingRepository (its collaborator) are the two real pieces of our basic integration. Note that "real" doesn't mean "the whole constellation": the payment and the email can stay doubled —you'll see it in lesson 4—; what matters is that the two pieces of the seam under test are real.
"Through the specific seam you're testing". This is the half that disambiguates. Every integration test is about a concrete seam —a point where two components connect—. Our integration's seam is BookingService↔BookingRepository, the save/get/find_by_room interface. For the test to be an integration of that seam, the seam has to be crossed by real pieces: real BookingService on one side, real SqliteBookingRepository on the other. If you leave the repository real but double the payment, you're still integrating the repository seam —the payment isn't that seam—. If you double the repository, even if you use a real Calendar, you do not integrate the repository seam: you replaced it with a double, like the plumber's syringe.
From here comes the operating rule: to know whether a test is an integration, don't count the real pieces; identify the seam it tests and ask whether that seam has real pieces on both sides.
Worked example: the incidental real piece versus the integrated seam
Let's see the two halves in code. The two tests below use a real Calendar (a real piece, in memory). The difference is in the repository: the first doubles it with a FakeBookingRepository, the second leaves it real with SqliteBookingRepository. Both pass green. But only the second is an integration of the repository seam; in the first, that seam is doubled, and the real Calendar is incidental reality that doesn't change what the test tests.
# tests/test_real_vs_incidental.py — incidental real piece vs integrated seam
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)
CLOCK = datetime(2026, 3, 1, 9)
# Does NOT integrate the repo seam: the repo is a DOUBLE. The real Calendar is incidental.
def test_repo_seam_is_doubled_calendar_is_incidental():
repo = FakeBookingRepository() # <-- the seam that matters, DOUBLED
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
assert repo.get(booking.id).status == "confirmed"
# DOES integrate the repo seam: the repo is REAL.
def test_repo_seam_is_real():
repo = SqliteBookingRepository(sqlite3.connect(":memory:")) # <-- REAL seam
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
assert repo.get(booking.id).status == "confirmed"
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_real_vs_incidental.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_real_vs_incidental.py::test_repo_seam_is_doubled_calendar_is_incidental PASSED [ 50%]
tests/test_real_vs_incidental.py::test_repo_seam_is_real PASSED [100%]
============================== 2 passed in 0.02s ===============================
Two identical greens on screen, and two completely different things underneath. The first uses a real Calendar, yes —but the seam its assertion examines, the repository's, is doubled with the fake—. That real Calendar adds no confidence about persistence: it's incidental, decorative for the test's purpose. The second leaves the repository real, so its book→get really crosses the BookingService↔SQLite seam: the booking is written to a table and read back. If something in that seam were broken —a malformed INSERT, a column that doesn't exist, a schema constraint—, only the second test would notice. The first would stay green, blind, because the fake doesn't have those problems. Same screen, different guarantees: one tests the repository seam, the other doesn't, and the difference doesn't show in the PASSED but in which piece is plugged into the seam.
What an integration asserts that a unit test can't
It's worth saying explicitly what you buy with an integration, because it's different from what a unit test buys.
A unit test asserts: "this unit's logic is correct, assuming its collaborators behave like the doubles I gave it." It's a conditional claim —"assuming that..."—, and that's why it's fast and precise: it isolated the logic from everything else. When test_book_focus_3h passes with doubles, you know that book charges 6000 and confirms if the repository, the payment, and the email behave like the doubles.
An integration test asserts something the unit test can't: "these two real pieces, connected, collaborate correctly through their seam." It's no longer conditional on a double; it's a claim about the real pieces. When test_repo_seam_is_real passes, you know that BookingService and SqliteBookingRepository, the production ones, understand each other: the booking one creates the other saves and returns readable. No unit test can give you that, because a unit test, by definition, doubled the seam —it swapped the real piece for its assumption—.
That's why the two tests coexist and don't compete. The unit test gives you speed and precision over the logic, with a pending assumption at each doubled seam. Integration collects on that assumption at a concrete seam, paying with slowness and setup (lesson 7). The question is never "unit or integration"; it's "this seam, is it enough for me to assume it (unit + contract) or do I need to actually exercise it (integration)?". Lesson 4 gives you the criterion; this lesson gives you the definition to frame the question well.
Common mistakes
Confusing "uses a real piece" with "is an integration". What happens: someone leaves a real Calendar or a real datetime in a test that doubles the repository and labels it integration. Why it happens: the presence of something real feels like integration. How to detect it: identify the seam the test verifies in its assertions and ask whether that seam has real pieces on both sides. If the seam that matters to you is doubled, the rest of the reality is incidental. How to fix it: name the seam under test before writing the test, and make sure to cross it with the real piece. Reality that doesn't touch that seam doesn't make it an integration.
Believing an integration needs everything real. What happens: someone thinks that for it to count as an integration you have to leave the payment, the email, the clock, and the database all real at once. Why it happens: "real components" sounds like "all". How to detect it: if your test charges cards or sends emails to "be a real integration", you confused the scope. How to fix it: the definition asks for real pieces on both sides of the seam under test, not at every seam. To integrate the repository seam, real BookingService and SqliteBookingRepository are enough; the payment and the email, which aren't that seam, are doubled without ceasing to be a legitimate integration. (That's lesson 4.)
Not naming the seam and ending up with a test that proves nothing clear. What happens: someone mixes real pieces and doubles without deciding which seam it verifies, and you're left with a test that's neither a clean unit nor a clear integration. Why it happens: the test is built "by eye", plugging in whatever's at hand. How to detect it: if you can't complete the sentence "this test verifies that the seam ___ works with real pieces", your test has no crisp purpose. How to fix it: start from the seam. Decide which join you want to test, leave its two sides real, double the rest for cost, and write the assertion that examines that join. A test with a named seam proves something; one without it just runs.
Exercises
Exercise 1 — Does it integrate the repository seam? For each test, say whether it's an integration of the repository seam (real pieces on both sides of BookingService↔repo) or not, and why: (a) book with a real SqliteBookingRepository, payment and email doubled; (b) book with a FakeBookingRepository, but with a real Calendar and a real datetime.now(); (c) SqliteBookingRepository.save followed by get, without BookingService; (d) book with all four collaborators doubled.
See solution
- (a) It does integrate the repository seam. Real
BookingServiceon one side, realSqliteBookingRepositoryon the other; thesave/getseam is really crossed. That the payment and email are doubled takes nothing away: they aren't the seam under test. It's the module's canonical integration. - (b) It does not integrate the repository seam. The repository is doubled with the fake; the seam you'd verify with
repo.get(...)is replaced by the double. The realCalendarand the realdatetime.now()are incidental reality —besides, the realdatetime.now()makes the test non-deterministic, a separate evil—. It doesn't test the service↔database seam. - (c) Yes, it's a narrow integration of the repository↔database seam.
BookingServiceisn't involved, but the real repository is exercised against its real resource (SQLite). It's the narrowest seam: one real piece against its database. The narrow/broad distinction is seen more in lesson 5; what it's not, is a unit test with doubles. - (d) No, it's a solitary unit test. All collaborators doubled; no seam crossed with real pieces. It's the isolated extreme: it tests
book's orchestration logic assuming all its neighbors. Useful, fast, but it integrates nothing.
The rule you applied: look at the seam the test verifies, not the total of real pieces in the scene. (a) and (c) cross a real seam; (b) and (d) don't.
Exercise 2 — Translate what each green asserts. You have two green tests: test_book_focus_3h (all doubled, a unit test) and test_repo_seam_is_real (real repository, an integration). Write, for each, the exact claim its green guarantees, being careful with the conditional part.
See solution
test_book_focus_3h(unit, all doubled): its green guarantees "book's logic is correct —it charges6000, saves a confirmed booking, sends an email— assuming that the repository, the payment, and the email behave like the doubles I gave it." It's a conditional claim about the orchestration logic, with a pending assumption at each doubled seam.test_repo_seam_is_real(integration, real repository): its green guarantees "BookingServiceandSqliteBookingRepository, the real pieces, collaborate correctly through their seam: a booking created by the service is written to the real database and read back with a confirmed status." There's no more "assuming that the repository..."; the repository is the real one, so its behavior was exercised, not assumed.
The difference is exactly in the conditional clause. The unit test has it ("assuming that the double..."); the integration eliminated it for the repository seam (it used the real piece). That's why integration closes a gap the unit can't: it turns an assumption into a verified fact, for that seam.
Exercise 3 — Design the integration of a different seam. So far we've integrated the BookingService↔repository seam. Imagine you wanted to integrate, instead, the BookingService↔PaymentGateway seam with a real payment (for example, a test payment against a sandbox service). Describe which pieces you'd leave real and which you'd double, and explain why this integration is more expensive and why this guide doesn't do it.
See solution
To integrate the payment seam, you'd leave BookingService and the real PaymentGateway real (against the gateway's sandbox environment), and double what's not that seam: the repository (a fake is enough), the email (a spy), the clock (a FixedClock). The seam under test would be service↔payment gateway, and the assertion would verify that a real charge is correctly recorded in the sandbox.
It's more expensive for three reasons. First, it's external and over the network: it depends on a third-party service that can be down, slow, or change without warning, so the test is fragile for reasons unrelated to your code. Second, it has effects and state in another system: even in sandbox, you leave data in the gateway that has to be cleaned, and a bad charge can have consequences. Third, it's slow: a network call takes orders of magnitude longer than an in-process operation.
This guide doesn't do that integration because of its boundary: we work with in-process resources (SQLite, and in module 6 a minimal stdlib http.server), without external dependencies or third-party services. Integrating against a real payment gateway, a SaaS service, or a remote database is end-to-end testing territory and the testing-backend-applications-guide's. Here lesson 4's criterion will tell us that the payment is exactly what's worth doubling —external, with effects, slow—, and that the seam worth integrating in-process is the repository's.
Summary and next step
In this lesson you nailed the definition lesson 1 left large: an integration test verifies that two or more real components collaborate through the specific seam you're testing, exercising it instead of doubling it. With the plumber and their syringe you understood the half almost everyone skips: it's not about how many real pieces are in the scene, but whether the seam that matters to you is crossed by real pieces on both sides. You saw, with two identical greens on screen, how a real Calendar can be incidental reality while the repository seam is doubled, and how leaving the repository real turns the same book→get into a real integration. And you distinguished what each green asserts: the unit test, conditional on its doubles; the integration, a fact about the real pieces.
Before moving on you should be able to: apply the definition by identifying the seam under test and verifying that it has real pieces on both sides; distinguish an incidental real piece from the integrated seam; and translate the green of a unit test (conditional) and that of an integration (about the real thing) without confusing them.
With the definition in hand, it's time to build the integration in depth. In lesson 3 we get into the BookingService↔SQLite seam for real: you're going to see the row book writes to the table by querying it with raw SQL, and check that the booking survives closing and reopening the file on disk —the most conclusive proof that there's a real database, and not a double in disguise, on the other side of the seam—.
Resources
- Martin Fowler — IntegrationTest — the source that discusses why "integration" means different things to different people and why it's worth pinning down the seam and the scope; the frame of this lesson's definition.
- Martin Fowler — UnitTest (solitary vs sociable) — where the isolated test is distinguished from the one that leaves real neighbors; context for what lesson 5 will name and for understanding what each type of green asserts.
- pytest documentation — How to invoke pytest (
-v) — the reference for running tests with-vand reading the per-testPASSED/FAILED, as in the worked example where two greens hide two different guarantees. sqlite3— DB-API for SQLite (Python documentation) — the reference for the real resource that plays "the other side of the seam" when the repository is theSqliteBookingRepository, the piece the definition requires to be real for the test to integrate that seam.