Module 1: From Units To Integration
4. Seams: where components connect
Description
The pyramid told you that you want some integration tests for "the seams that matter". This lesson answers the question left open: what exactly is a seam, how do you recognize it in the code, and how do you decide what to do with it? The word comes from tailoring —the point where two pieces of fabric join— and in software it means the same: a seam is a point where two components connect, and where, because of that, you can intervene. It's the join an integration test tests and a double replaces.
You already know Reservo's most important seam without having named it that way: the repo parameter of BookingService's constructor. BookingService doesn't create its repository inside; it receives it. That reception point is a seam, because there you can plug in any object that satisfies the BookingRepository interface —save, get, find_by_room—: the FakeBookingRepository in a unit test, the SqliteBookingRepository in production or in an integration test. The seam is what makes both worlds possible. And there's its double edge: the same point that lets you plug in a double to isolate is the point where the double and the real thing can diverge without anyone noticing. The seam is at once your opportunity to double and your risk that the double lies.
Connection to the module: this lesson gives a name and an anatomy to the place where everything the guide teaches lives. Lesson 2 defined integration as "crossing the seam with a real piece"; here you see what that seam is and learn to distinguish the ones that live inside the process (the BookingRepository interface) from the ones at a boundary of the world (the disk, the network). Lesson 5 will show a concrete divergence at a seam; the contract of modules 3 and 4 will be, precisely, the way to keep both sides of a seam honest; and the real boundaries of module 6 are the most dangerous seams of all. Recognizing a seam is recognizing where integration can fail.
Analogy: the outlets in your house
Think of the outlets in the wall. Each outlet is a seam between two worlds: the house's electrical wiring, on one side, and any appliance you plug in, on the other. The outlet exists precisely so those two worlds aren't welded together: you can unplug the blender and plug in the charger, and the wall doesn't notice —it gives power to whatever you plug in—. That separation is what makes the house flexible. But the outlet is also where things can not fit: you take your charger abroad and the outlet has a different shape, or a different voltage, and even though "it's an outlet" and "it's a charger", they don't work together —or worse, they fit but the voltage fries the appliance—. The outlet's contract (the shape of the prongs, 120 volts) is what guarantees that any appliance that respects it works; when one side assumes 120 and the other delivers 240, the outlet fits and the system fails.
BookingService's repo seam is that outlet. BookingService is the wall: it gives power to "whatever you plug in" as long as it satisfies the save/get/find_by_room shape. The FakeBookingRepository and the SqliteBookingRepository are two different appliances that fit in the same outlet. And the datetime divergence from lesson 1 is exactly the voltage problem: the two appliances fit in the outlet (both have get), but one returns a datetime and the other a str —the same outlet, different voltage—, and the appliance that expected datetime "gets fried". A unit test tests your appliance against an outlet you built with the voltage you assume; an integration test tests it against the real outlet, with the real voltage.
Anatomy of a seam
A seam always has two sides and a contract between them.
- The consumer: the side that uses the seam. In Reservo,
BookingServiceis the consumer of the repository: it callsrepo.save(...)andrepo.get(...)expecting certain behavior. - The provider: the side that fulfills the seam.
FakeBookingRepositoryandSqliteBookingRepositoryare two providers of the same repository: each implementssave,get,find_by_roomin its own way. - The contract: the expectations the consumer has about the provider. Here, implicit for now: "
save(b)saves;get(b.id)returns the same booking;getof a missing id raises". Making that contract explicit and verifying it against both providers is, literally, the topic of modules 3 and 4.
This vocabulary —consumer, provider, contract— is what you'll use for the rest of the guide. For now keep the image: in every seam there's a side that expects something and a side that promises it, and integration's job is to verify that the promise is kept with the real piece, not just with the double.
Two kinds of seam: in-process and boundary
Not all seams are equally dangerous. It's worth distinguishing two.
The in-process seam. It joins two objects that live in the same Python memory. The repo seam is one of these: BookingService and the repository are both Python objects, and save/get are ordinary method calls. The data doesn't leave the process; at most it changes shape within it (a datetime that serializes to str). They're relatively docile seams: fast to cross, deterministic, and their divergence is usually one of data shape, like the datetime one.
The boundary seam. It joins your process with something outside it: the disk (a file, the SQLite database in a real file), the network (an HTTP call to another server), the system clock. Here the data really leaves the process and comes back, and on the journey things can happen that never happen in memory: the disk fills up, the file is locked, the network goes down or lags, the connection drops halfway through a transaction. They're the most dangerous seams —and the ones module 6 tackles head-on—, because their divergence isn't only of shape: it's of reliability.
In Reservo, SqliteBookingRepository is interesting because it touches both. When you use it with sqlite3.connect(":memory:"), the boundary seam is soft (the "database" lives in memory); when you use it with a file on disk, the boundary becomes real, with everything that implies. Lesson 7 will measure that difference. For now, recognize that the word "seam" covers everything from a method call in memory to a jump to the network, and that the risk grows as you move away from the process.
Worked example: one seam, two pieces plugged in
The best proof that the repo seam is real is to plug both pieces into it and see that the same BookingService works with both. This parametrized test runs exactly the same flow —book Focus twice, then ask for that room's bookings— once with the FakeBookingRepository and once with the real SqliteBookingRepository. The service's code doesn't change a single line; the only thing that varies is which piece is plugged into the seam.
# tests/test_seam.py — the seam accepts any provider
import sqlite3
from datetime import datetime, timedelta
import pytest
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")
CLOCK = datetime(2026, 3, 1, 9)
def make_service(repo):
return BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
def make_repos():
return {
"fake": FakeBookingRepository(),
"sqlite": SqliteBookingRepository(sqlite3.connect(":memory:")),
}
@pytest.mark.parametrize("kind", ["fake", "sqlite"])
def test_same_service_plugs_into_either_repo(kind):
repo = make_repos()[kind]
service = make_service(repo)
d1 = datetime(2026, 3, 10, 9)
d2 = datetime(2026, 3, 11, 9)
service.book(FOCUS, ANA, d1, d1 + timedelta(hours=1))
service.book(FOCUS, ANA, d2, d2 + timedelta(hours=1))
focus_bookings = repo.find_by_room("focus")
assert len(focus_bookings) == 2 # the seam accepts both pieces
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_seam.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_seam.py::test_same_service_plugs_into_either_repo[fake] PASSED [ 50%]
tests/test_seam.py::test_same_service_plugs_into_either_repo[sqlite] PASSED [100%]
============================== 2 passed in 0.01s ===============================
Two greens. The same service, the same flow, two different providers plugged into the same seam, and find_by_room("focus") returns 2 in both cases. Here the seam behaves the same with both pieces, because find_by_room only compares room_id, which is text and crosses the seam without changing shape. It's the friendly face of the seam: the one that lets you double without fear when the real contract is fulfilled the same on both sides. The dangerous face you saw in lesson 1 and will see again in lesson 5: the same seam, but asking the provider for a datetime, where the fake and the real stop behaving the same. There's only one seam; whether it's friendly or dangerous depends on whether the contract holds at the exact point you ask it about.
Notice the technical detail that makes all of this possible: make_service(repo) accepts any object with save/get/find_by_room. Python doesn't require you to declare a formal interface; it's enough for the piece to have the methods (what's called duck typing). That flexibility is the seam turned into code: the outlet that doesn't ask what appliance you are, only whether you have the right prongs.
Why every seam is both opportunity and risk
It's worth pausing on the double nature of the seam, because it's the tension that organizes the entire guide.
The seam is your opportunity to double. Without a seam, you couldn't isolate anything. If BookingService created its repository inside (self._repo = SqliteBookingRepository(...) in the constructor), there'd be nowhere to plug in a fake: the real piece would be welded in, and every test would touch the database. The seam —receiving the repository from outside— is what opens the gap where the double goes in. Every seam is, literally, a place where you can choose "here I double" (unit test) or "here I leave the real thing" (integration). More seams, more control over what you isolate and what you actually test.
The seam is your risk of divergence. But every seam where you double is a seam where your double could behave differently from the real thing, and where a unit test —which only sees the double— would be unable to notice. The seam not only opens the gap for the double; it opens the gap for the double's lie. The more seams you double, the more logic you test fast and isolated, but the more joints stay unverified against the real thing. That's why the pyramid isn't "double everything": it's "double at the base for speed, and integrate in the middle the seams whose risk of divergence you can't ignore".
The practical conclusion: for each seam, ask yourself two questions. First, "do I need to isolate here for speed and precision?" —if so, double, and you'll have unit tests—. Second, "is the risk of my double diverging from the real thing real and important?" —if so, write also an integration test that crosses this seam with the real piece—. Almost always the answer to both is "yes", and that's why you want unit tests and integration tests over the same seam: the first for speed, the second for truth. The contract of modules 3 and 4 is the tool that makes that second verification systematic, instead of leaving it to chance.
Common mistakes
Not seeing the seam because the piece is welded in. What happens: someone has a BookingService that creates its repository inside and concludes "there's nothing to double here, there's no seam". Why it happens: without injection, the seam is covered up, and what isn't seen seems not to exist. How to detect it: if you can't write a unit test without touching the database, then a seam that should be open is welded. How to fix it: open the seam with dependency injection —receive the repository through the constructor instead of creating it inside—. That's the doubles guide's material; here it's enough to recognize that a covered-up seam is a forced integration, not the absence of a seam.
Confusing "fits" with "fulfills the contract". What happens: someone sees that the fake and the real both have get, concludes they're interchangeable, and is surprised when they diverge. Why it happens: that two pieces fit in the same seam (have the methods) feels like they behave the same. How to detect it: it's exactly the datetime bug: both have get, they fit in the seam, and they return different types. Fitting is the shape of the prongs; fulfilling the contract is the voltage. How to fix it: it's not enough for the piece to have the methods; the behavior of those methods must match what the consumer expects. Verifying that is contract testing (modules 3-4); recognizing that "fits" and "fulfills" are different things is the first step.
Treating all seams as equally risky. What happens: someone writes integration tests for every seam equally, or for none equally, without distinguishing. Why it happens: "seam" sounds like a single thing. How to detect it: if you spend as much integration effort on a trivial in-process seam as on the boundary with the database, you're miscalibrated. How to fix it: distinguish the in-process seams (docile, divergence of shape) from the boundary ones (dangerous, divergence of reliability). Invest your integration budget where the risk is greatest: the boundaries with the disk and the network, not every method call in memory.
Exercises
Exercise 1 — Find the seams. In BookingService(calendar, clock, payments, emails, repo), list the seams there are and classify each one as "in-process" or "boundary" when it uses the real production piece. For each one, name the consumer and the provider.
See solution
BookingService has five seams, one for each collaborator it receives:
calendar— in-process. Consumer:BookingService. Provider:Calendar(logic in memory). The calendar doesn't leave the process.clock— boundary (mild). Consumer:BookingService. Provider: the system clock in production (datetime.now()), which is a call to the operating system. Its "danger" is non-determinism, not reliability.payments— boundary. Consumer:BookingService. Provider: the real payment gateway, which lives on another server: a network seam, expensive and fallible.emails— boundary. Consumer:BookingService. Provider: the real mail server: another network seam.repo— boundary. Consumer:BookingService. Provider:SqliteBookingRepositoryover a real file: a disk seam (and in-process when the database lives in:memory:).
The key observation: almost all of BookingService's seams are boundary seams in production, because almost all its collaborators live outside the process. That's why BookingService is a festival of doubles in the unit tests and a good candidate for integration tests at its highest-risk seams (the repo, above all).
Exercise 2 — Fits but doesn't fulfill. A colleague writes a BrokenRepository whose get always returns None instead of raising when the id doesn't exist. Plug it into BookingService's repo seam. Does the code run (fit)? Does it fulfill the contract? Where and when would the difference blow up?
See solution
Fits: yes. BrokenRepository has save, get, and find_by_room, so Python accepts it at the seam without protest —it has the prongs—. BookingService builds it and uses it without a construction error.
Fulfills the contract: no. The repository's implicit contract says "get of a missing id raises (KeyError/NotFound)". This provider returns None instead. It fits in shape, but violates the expected behavior —the voltage problem again—.
Where and when it blows up: in cancel, which does booking = self._repo.get(booking_id) and then uses booking.price_cents and booking.status. With a valid id, all good. With a missing id, a repo that fulfills the contract would raise right there, with a clear error; this one returns None, and the code continues to None.price_cents, which blows up with a confusing AttributeError, far from the real cause. Worse still: a unit test using a correct fake (which does raise) would never see this bug, because the bug lives in this provider, at this seam. It's exactly the kind of divergence module 2 dissects and the contract of modules 3-4 prevents.
Exercise 3 — Double or integrate this seam? For each seam of Reservo, decide whether in your suite you'd want to double it (unit test), integrate it with the real piece (integration test), or both, and give a one-sentence reason: (a) payments on book's happy path; (b) repo to verify that a booking is saved and read back correctly; (c) calendar to test the overlap rule.
See solution
- (a)
payments— double (unit). The real provider charges real money and lives on the network: you never want it in your suite. It's always doubled with a stub/spy. (The "does the real payment work?" is another kind of test, with the provider's sandbox environment, and is outside this guide.) - (b)
repo— both. Double in the unit tests ofbook/cancel's logic (speed, precision, the base of the pyramid) and integrate with the realSqliteBookingRepositoryin a few tests (verify that persistence really doesn't diverge, like thedatetime). It's the highest-value seam for having both. - (c)
calendar— double/direct (unit).Calendaris pure logic in memory: there's no boundary, no external resource, no risk of divergence between a double and "the real thing" (the realCalendaris already cheap and deterministic). It's tested directly, without integration; it would be a waste of the intermediate level.
The criterion you apply: always double the seams that are expensive or dangerous to touch (payments, emails); integrate the seams whose real provider can diverge in a way that matters (repo); and don't bother the integration level with seams that are already pure and cheap (calendar). The repository seam is the star of this guide precisely because it's the one that most calls for both.
Summary and next step
In this lesson you gave a name and an anatomy to the place where integration lives: the seam, the point where two components connect. You learned its three parts —consumer, provider, contract— and the vocabulary you'll use for the rest of the guide; you distinguished the in-process seams (docile, divergence of shape) from the boundary ones (dangerous, divergence of reliability); and you saw, with the two pieces plugged into the repo seam, that the same seam is at once your opportunity to double and your risk that the double lies. The rule you take away: for each seam, ask whether you need to isolate (double) and whether the risk of divergence matters (integrate too).
Before moving on you should be able to: point out a service's seams and name their consumer, provider, and contract; distinguish "fits" from "fulfills the contract"; and decide, for a given seam, whether you double it, integrate it, or both.
You've seen the friendly seam, where the fake and the real coincide, and I've promised you twice the dangerous seam, where they diverge. It's time to look at it head-on and calmly. Lesson 5 takes the central problem of the entire guide —a green unit test can hide a broken integration— and demonstrates it in miniature, with the repo seam asking for a datetime: the fake passes, the real one fails, and you understand exactly why the green was lying.
Resources
- Martin Fowler — IntegrationTest — the article that frames the seams and the distinction between narrow and broad integration; the conceptual backdrop of this lesson's two kinds of seam.
test-doubles-and-test-data-guide— the sister guide where you learn to open the seam with dependency injection and plug a double into it; if "receive the collaborator through the constructor" sounds shaky, that's the place to review it.- pytest documentation — How to parametrize tests — the technique with which we plug the two pieces (fake and sqlite) into the same seam from a single test, as in the worked example.
sqlite3— DB-API for SQLite (Python documentation) — the reference for the real provider of thereposeam; in particular, how it handles types, which is the source of the divergence we'll see in lesson 5.