Module 5: Integration Testing Real Components Together
4. What to double and what to keep real
Description
In the previous lessons we made a decision over and over without explaining it: we left the repository real and doubled the payment, the email, and the clock. It was the correct instinct, but an instinct can't be taught or defended before a reviewer. This lesson turns that instinct into a rule you can apply deliberately in any integration, not just in Reservo. Because the question "what do I leave real and what do I double?" is the design decision of an integration test, and getting it wrong in either direction ruins the test: if you over-double, you don't test any real joint (you're back to a unit test in disguise); if you leave too much real, you inherit the slowness, fragility, and effects of pieces you didn't want to touch (you approach an expensive end-to-end).
The rule has two halves that complement each other. Keep real the seam you're testing. If the test's purpose is to verify that BookingService and the repository collaborate, the repository has to be real —that's the joint under test, and doubling it would be like the plumber with their syringe—. Double the slow, the non-deterministic, and the external. Every collaborator that's not the seam under test and that's also expensive (because it's slow, fragile, or has effects on the world) is doubled, so the test runs fast, always gives the same result, and doesn't charge cards or send emails. In Reservo that means: real the repository (the seam), doubled the payment (external, with effects, costs money), the clock (non-deterministic), and the email (external, with effects). This lesson gives you that rule, justifies it piece by piece, and shows you what an integration that applies it well looks like.
Connection to the module: this lesson is the criterion that was missing to write integrations with intention instead of by habit. Lesson 2 defined the integration by its seam; lesson 3 made it tangible; this one tells you what to put around that seam. It's the module's hinge: with this rule you can read any integration test and say whether it's well set up, and lesson 5 will put the technical names (solitary, sociable) on the degrees of "how much real you leave around". And it prepares lesson 6: when you leave the repository real but the clock doubled, the book→cancel→get will still be deterministic and cross the seam for real —exactly the mix needed to catch the bug without inheriting the fragility of real time—.
Analogy: the flight simulator
Think of how a pilot is trained to land at a new and difficult airport. They don't send them to do it for the first time with passengers aboard —the cost of an error is unacceptable— nor do they train them by describing the airport in a classroom —that doesn't prove they can land—. They put them in a flight simulator, and there a piece-by-piece decision is made about what's real and what's simulated. The cockpit is real: the same controls, the same levers, the exact same layout as the real plane, because that is what the pilot is practicing —the interaction between their hands and the controls—. In contrast, the outside world (the weather, the runway, the mountains) is simulated: projected on screens, controlled by the instructor, who can add fog or wind whenever they want, repeat the same scenario ten times identically, and there's never a real plane that can crash. Real what's being tested (the interaction with the controls); simulated the expensive, the dangerous, and the unpredictable (the world).
An integration test is a simulator. The seam you test —the controls the pilot operates— is left real: it's what you're verifying, and faking it would prove nothing. The rest of the world —the payment that costs money, the email that reaches real inboxes, the clock that advances on its own— is simulated with doubles: expensive, with effects, unpredictable, exactly what you don't want to trigger for real or depend on cooperating. The mastery of integration design is the same as simulator design: knowing which part of the world to leave real because it's what you test, and which part to simulate because its cost adds nothing to what you want to verify.
The golden rule, in two halves
Let's write it so we can apply it to any seam, not just to Reservo:
In an integration test, keep real the seam you're testing and double every collaborator that's slow, non-deterministic, or external and that isn't that seam.
Let's break down the two halves.
Keep real the seam under test. The integration's purpose is to verify a concrete joint with real pieces on both sides (lesson 2). That joint can't be doubled, by definition: doubling it would be replacing exactly what you want to test. In Reservo, if the test verifies persistence, the repository is real, no exception. This is the non-negotiable side of the rule.
Double the slow, the non-deterministic, and the external. Everything else —the collaborators that aren't the seam under test— is evaluated by its cost, and doubled if it falls into one of these three categories:
- Slow: a network call, a remote service, a heavy operation. An integration test already pays for the real seam it tests; it shouldn't also pay the slowness of pieces it doesn't verify.
- Non-deterministic: something that gives a different result each time —the clock (
datetime.now()), a random generator, the order of an external response—. A test that depends on real time is flaky: it passes today and fails tomorrow without your code changing. The clock is doubled with aFixedClockso the "now" is a datum of the test. - External (with effects on the world): something that touches a system outside your control or produces an irreversible effect —charging a card, sending an email, writing to a third-party service—. Doubling the payment and the email avoids real charges and emails, and along the way lets you verify the effect with a spy without triggering it.
In Reservo, the seam under test is the repository, so it goes real. The three remaining collaborators fall exactly into the three categories: the payment is external and with effects (costs money), the clock is non-deterministic, the email is external and with effects. All three are doubled. The resulting integration really tests the only joint that matters to it and doesn't inherit the cost of any other.
Worked example: real the seam, doubled the rest
Let's see the rule applied. The seam under test is the repository, so it's a real SqliteBookingRepository. The payment is a StubPaymentGateway (doesn't charge, and along the way records the amount to verify it), the clock a FixedClock (deterministic), the email a SpyEmailSender (doesn't send, and records the send). The test verifies the real seam with repo.get(...) and checks, with the doubles, the effects we didn't want to trigger for real.
# tests/test_what_to_double.py — real the seam you test, doubled the rest
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)
def test_book_keeps_repo_real_doubles_the_rest():
repo = SqliteBookingRepository(sqlite3.connect(":memory:")) # REAL: the seam
payments = StubPaymentGateway(ok=True) # doubled: costs money
emails = SpyEmailSender() # doubled: external
clock = FixedClock(CLOCK) # doubled: non-deterministic
service = BookingService(Calendar(), clock, payments, emails, repo)
booking = service.book(FOCUS, ANA, START, END)
# The real seam (the repo) is really verified:
assert repo.get(booking.id).status == "confirmed"
# The doubles record the effects we do NOT want to trigger for real:
assert payments.charges == [(6000, "m-ana")] # charge (stub/spy, no real card)
assert emails.sent == [
("m-ana", "Booking confirmed", "Focus booking confirmed.")] # email (spy)
Read it as a decision map. Each collaborator carries a comment with the reason for its choice: repo real because it's the seam; payments doubled because it costs money; emails doubled because it's external; clock doubled because it's non-deterministic. The assertions are also distributed with logic: the real seam is verified against the database (repo.get(...).status), and the effects we doubled are verified with their doubles' tools (payments.charges, emails.sent). Each assertion looks at the source where its effect leaves a trace.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_what_to_double.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
tests/test_what_to_double.py::test_book_keeps_repo_real_doubles_the_rest PASSED [100%]
============================== 1 passed in 0.01s ===============================
Green, and —this is what matters— green for the right reasons. The test exercised the real seam: the booking was written to SQLite and read back confirmed. And it verified the effects without triggering them: the payment recorded a charge of 6000 to m-ana without touching a card, the email recorded a send without filling an inbox. It ran in 0.01s, deterministic, without effects on the world. This is the shape of a good integration: a real seam actually tested, surrounded by doubles that keep the test fast, repeatable, and without consequences. If tomorrow you wanted to integrate another seam —say the payment's—, you'd invert the choice: real the payment (against a sandbox), doubled the repository. The rule is the same; what changes is which seam you're testing.
The two extremes the rule avoids
It's worth seeing what happens when each half of the rule is ignored, because both errors are common and opposite.
Over-doubling: the unit test in disguise. If you also double the repository —you leave everything doubled—, you no longer have an integration: you have a unit test. There's no real joint under test; you verify book's logic assuming all its collaborators. It's perfect as a unit test —fast, precise— but it doesn't give you what an integration buys: the confidence that the real pieces collaborate. If you think you're integrating and you actually doubled the seam, you have a false sense of coverage: you think you tested the joint and you didn't touch it.
Leaving too much real: the expensive end-to-end. If you also leave the payment and the email real, your test charges real cards and sends real emails. It became slow (network calls), fragile (fails if the external service is down), with effects (leaves real charges and emails that have to be cleaned), and non-deterministic (depends on systems you don't control). You crossed from integration —a real seam, the rest simulated— to end-to-end —everything real—, which is legitimate in its place but much more expensive, and which in this guide is out of bounds (it's testing-backend-applications-guide territory). You tested more, yes, but you paid a disproportionate price to test joints you weren't worried about.
The golden rule navigates between the two: real the seam that matters (not a unit test in disguise), doubled the expensive that isn't that seam (not an unnecessary end-to-end). That's the sweet spot of practical integration.
Common mistakes
Doubling the clock "just in case" but leaving real something that should have been doubled. What happens: someone doubles the clock —good— but leaves the EmailSender real because "after all, it's just an email". Why it happens: the clock is obviously non-deterministic, but the email seems harmless. How to detect it: ask yourself about each real collaborator whether it's slow, non-deterministic, or external with effects. The email is external with effects: sending it for real fills inboxes and depends on a server. How to fix it: apply the rule to each collaborator, not just the obvious suspects. If it isn't the seam under test and falls into one of the three categories, it's doubled. The email is doubled with a spy, which also lets you verify the send.
Doubling the seam you claim to be testing. What happens: someone titles the test "repository integration" but passes it a FakeBookingRepository. Why it happens: the fake is more convenient and fast, and the habit of doubling is strong. How to detect it: read which piece is plugged into the seam the title and the assertions claim to test. If it's a double, you're not integrating that seam. How to fix it: the rule's first half is non-negotiable —the seam under test goes real—. If the fake is more convenient, maybe what you want is a unit test (and that's fine), but then don't call it integration or believe you tested the joint.
Believing "more real is always safer". What happens: someone, to "be safer", leaves the payment and the email real in addition to the repository. Why it happens: the intuition that more reality is more confidence. How to detect it: if your integration charges cards, sends emails, or takes seconds, you left real something the rule says to double. How to fix it: more real isn't safer; it's more expensive and more fragile because of seams you weren't worried about. The confidence you're looking for is about one seam (the repository); the others are covered by the unit test and the contract, cheaper. Leave real only what you test, and double the rest for cost.
Exercises
Exercise 1 — Apply the rule, collaborator by collaborator. You want an integration that verifies the BookingService↔repository seam in the cancel flow. For each of the five collaborators —calendar, clock, payments, emails, repo— say whether you leave it real or double it, and with what double, justifying it with the rule.
See solution
repo→ REAL (SqliteBookingRepository). It's the seam under test; the rule's first half says to leave it real. Without this there's no repository integration.clock→ doubled (FixedClock). Non-deterministic:cancel's refund depends on the "now", and the real clock would make the test flaky and change the result depending on when you run it. Freezing it makes each anchor (72/36/12 h) a datum of the test.payments→ doubled (StubPaymentGateway). External with effects:cancelrefunds, and a real refund would move real money. The stub moves nothing and, as a spy, records the refund to verify it.emails→ doubled (SpyEmailSender). External with effects:cancelnotifies by email. Doubling it avoids sending the email and lets you verify it was sent.calendar→ real or doubled, it doesn't matter (in memory, cheap). TheCalendaris an in-memory structure, neither slow nor non-deterministic nor external; it falls into no category of the rule. Leaving it real costs nothing and isn't the seam under test, so either option is acceptable. The usual thing is to leave theCalendar()real for simplicity, because doubling it adds nothing.
The pattern: the seam under test goes real (repo); the expensive that isn't that seam is doubled (clock, payments, emails); the cheap and without a boundary is indifferent (calendar). That's the rule, applied.
Exercise 2 — Diagnose a poorly set up integration. A colleague writes a test they call "book integration" with these collaborators: real Calendar(), real datetime.now() as the clock, real PaymentGateway(api_key=...), real SmtpEmailSender(host=...), FakeBookingRepository(). Point out the two design errors according to the rule and fix them.
See solution
There are two errors, one for each half of the rule:
Error 1: they doubled the seam they claim to test. The test is called "book integration" but uses FakeBookingRepository(), a double, at the repository seam. If the purpose is to integrate persistence, that seam has to be real. As it is, it integrates nothing of the repository: it's a unit test with a bunch of expensive real pieces around it.
Error 2: they left real three collaborators the rule says to double. The real datetime.now() is non-deterministic (flaky test); the real PaymentGateway is external with effects (charges real cards); the real SmtpEmailSender is external with effects (sends real emails). All three should be doubled.
The fix inverts both decisions: leave real the repository (SqliteBookingRepository, the seam under test) and double the payment, the email, and the clock (StubPaymentGateway, SpyEmailSender, FixedClock). The real Calendar() can stay (it's cheap and without a boundary). The result is the worked example's integration: real the seam, doubled the expensive, deterministic, and without effects. The original test had everything backwards: real what it should have doubled, doubled what it should have left real.
Exercise 3 — The same rule, another seam. Explain how the choice of what to double and what to leave real would change if the test's purpose were to integrate the BookingService↔EmailSender seam (verify that the service and a real email sender collaborate), instead of the repository's. What would you leave real and what would you double, and why is the rule still the same even though the answers change?
See solution
If the seam under test were BookingService↔EmailSender, the rule's first half would say to leave the email sender real (for example, a real sender pointing to a test mail server, or a local http.server that receives the request —exactly the kind of boundary of module 6—). And you'd double everything that's not that seam and is expensive: the repository would become a FakeBookingRepository (it's no longer the seam under test, and the fake is faster), the payment a StubPaymentGateway, the clock a FixedClock. The assertion would verify that the real email went out through the real seam (that the test server received it).
Notice that the answers were inverted compared to the repository example —before the repo was real and the email doubled; now the email is real and the repo doubled— but the rule is identical: keep real the seam you test, double the expensive that isn't that seam. What changes isn't the rule, but which seam you declared to test. That's the power of the golden rule: it's not a list of "the repo always real, the email always doubled", but a criterion that's re-evaluated according to the joint under test. (That said, integrating the real email is a network boundary this guide handles in its minimal form with http.server in module 6; the repository seam, in-process with SQLite, is the one we work in depth here.)
Summary and next step
In this lesson you turned instinct into criterion with integration's golden rule: keep real the seam you're testing (the repository, non-negotiable) and double the slow, the non-deterministic, and the external that isn't that seam (the payment, the clock, the email). With the flight simulator you understood the why: real what's practiced (the controls, the seam), simulated the expensive, dangerous, and unpredictable (the outside world, the costly collaborators). You saw the rule applied in a test that tests the real seam with repo.get(...) and verifies the doubled effects with their spies, green and without consequences; and you located the two extremes it avoids —the unit test in disguise (over-doubling) and the expensive end-to-end (leaving too much real)—.
Before moving on you should be able to: apply the rule to each collaborator of a given seam, justifying real or doubled; diagnose a poorly set up integration (doubled seam, expensive real collaborators) and fix it; and explain why the same rule gives different answers depending on which seam you declare to test.
You now know what to leave real and what to double. Lesson 5 puts a technical name on the degrees of that decision: a solitary test doubles all the unit's neighbors; a sociable one leaves one or more real. You're going to see book with the fake (solitary) and book with the real repository (sociable) side by side, and understand why the partial sociable —one real neighbor, the rest doubled, exactly this lesson's mix— is the workhorse of practical integration.
Resources
- Martin Fowler — UnitTest (solitary vs sociable) — the frame that names "solitary" and "sociable", the degrees of "how many real neighbors" lesson 5 develops and that this lesson's rule decides collaborator by collaborator.
test-doubles-and-test-data-guide— the sister guide where you built theStubPaymentGateway, theSpyEmailSender, and theFixedClock; useful to remember what each type of double verifies (the stub controls what goes in, the spy records what goes out) when you decide what to double with.- pytest documentation — How to write assertions — the reference for the example's assertions, where the real seam is verified against the database and the doubled effects against their spies, each at its source.
sqlite3— DB-API for SQLite (Python documentation) — the piece we leave real because it's the seam under test; the in-process resource, without a network boundary, that plays the cheap "real thing" to integrate versus the payment or the email, which are external.