Module 5: Integration Testing Real Components Together
6. Integration catches what the unit doesn't
Description
This is the module's climax, the lesson where you collect everything you built. Until now you built integrations carefully and saw them pass green, keeping the assertions within what crosses the seam without trouble. It was deliberate: you wanted to see the tool's clean shape. Now the tool is going to work. We're going to take the complete flow book→cancel→get against the real repository, and you're going to see, with pytest output, how integration catches a bug that the unit test, however much you run it, can't catch —and that a contract with a gap would also let through—. The bug is an old acquaintance: the datetime SQLite returned as a str —the one module 1 exposed and module 3 already fixed with datetime.fromisoformat—. Here we reintroduce it on purpose, for a moment, not to discover it again, but to show a failure mode that neither the unit nor a contract with a gap would see coming. And here you're not going to see it as a failing assertion; you're going to see it as something worse and more realistic: a TypeError that blows up the flow, because cancel tries to use that str in a date subtraction and can't.
The difference from module 1 is crucial and it's this lesson's reason for being. In module 1, the datetime bug showed up as an assertion saved.start == START that gave False —you noticed it only if you looked at the field—. A contract that verifies the round-trip with == catches it, yes. But what if your contract had a gap and only verified the status and the price_cents, forgetting the start? Then the contract passes green, the fake and the real one "match" in everything the contract looks at, and the bug lives on. This is where integration shows its unique power: it doesn't verify the data's shape by inspecting it, but exercises its use in a real flow. cancel doesn't look at the start: it subtracts it. And a str can't be subtracted from a datetime. The complete flow blows up where neither the unit (which uses the fake, with a real datetime) nor an incomplete contract (which didn't look at the start) would see it coming.
Connection to the module: this lesson is the reward all the previous ones pointed to. Lesson 1 promised you'd see integration working; lesson 2 defined it; lesson 3 made it tangible; lessons 4 and 5 taught you to set it up well and name it (here we compare the solitary book→cancel→get with the sociable one). Now you see why it's worth it: integration catches a class of bug —those of use in the real collaboration— that no other tool sees completely. And it closes by recalling module 3's fix, so you don't stay with just the red: datetime.fromisoformat in get, and the book→cancel→get flow green. Lesson 7 will put the cost of all this on the table; this one puts the benefit.
Analogy: the key that goes in but doesn't turn
Think of a new key you had copied. You inspect it and it looks identical to the original: the teeth in place, the same profile, the same thickness. If you inspect it —compare it with the original by eye—, it passes: "it's a correct copy". That's what a contract that verifies the data's shape does: it looks at the booking that comes back and checks field by field that it looks right. But a key doesn't exist to look right; it exists to turn in the lock. And there are copies that look perfect and, when you put them in the real lock and try to turn, jam —a tooth half a millimeter too tall, an angle minutely off that the eye doesn't catch but the lock does—. Visual inspection would never have said so; only using the key in the real lock reveals it.
An integration test is putting the key in the lock and turning. It doesn't inspect the booking that comes back from the repository; it uses it in the real flow —cancel reads it and tries to subtract its start—. The str SQLite returns is the copy that looks perfect (it has the correct value, '2026-03-10T09:00:00', the date right) but doesn't turn: when cancel tries the subtraction booking.start - now, the lock jams with a TypeError. A unit test with the fake uses the original key (a real datetime, which turns perfectly). A contract that only inspects some fields can approve the copy without ever putting it in the lock. Integration is the only one that puts the real key in the real lock and discovers it doesn't turn.
The complete flow: solitary green, sociable red
Let's set up the experiment. Two tests of the same flow book→cancel→get. The first is solitary: all the neighbors doubled, the repository is the FakeBookingRepository. The second is sociable: the same flow, with the real SqliteBookingRepository. The only difference is which piece is at the repository seam —lesson 5's controlled experiment, now over the complete flow—. The clock is at CLOCK = 2026-03-01, nine days before the booking's start, so the expected refund is the full one, 6000.
A warning before running: in module 3 you already fixed the SqliteBookingRepository —its get reconstructs the datetime with datetime.fromisoformat—, so this flow, against the fixed repository, would run green. To revive the scene and see the failure, in this experiment we revert that fix for a moment: we leave the get as it was in module 1, returning the start as str without reconstructing it. It's the same bug as always, brought back on purpose to show how it manifests in use:
# reservo/sqlite_repo.py — get() reverted to the module 1 bug (only for this experiment)
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], # module 1 BUG: comes out as str, not datetime
status=row[5], price_cents=row[6],
)
With that broken get put back, we set up the experiment.
# tests/test_book_cancel_get.py — the complete flow book -> cancel -> get
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) # 9 days before -> full refund (6000)
def make_service(repo):
return BookingService(
Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo,
)
# unit: the whole flow against the in-memory fake
def test_book_cancel_get_with_fake_repo():
repo = FakeBookingRepository()
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
refund = service.cancel(booking.id)
assert refund == 6000
assert repo.get(booking.id).status == "cancelled"
# integration: the same flow against real SQLite
def test_book_cancel_get_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
refund = service.cancel(booking.id) # <-- cancel reads the start back
assert refund == 6000
assert repo.get(booking.id).status == "cancelled"
Read them: they're twins. Same book, same cancel, same two assertions. The only thing that changes is the repository. If "reading a booking and using it" were the same with both, they'd give the same result. Let's run them.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_cancel_get.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_cancel_get.py::test_book_cancel_get_with_fake_repo PASSED [ 50%]
tests/test_book_cancel_get.py::test_book_cancel_get_with_sqlite_repo FAILED [100%]
=================================== FAILURES ===================================
____________________ test_book_cancel_get_with_sqlite_repo _____________________
def test_book_cancel_get_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
> refund = service.cancel(booking.id) # <-- cancel reads the start back
tests/test_book_cancel_get.py:44:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
reservo/services.py:40: in cancel
refund = refund_cents(booking, booking.price_cents, now) # computes
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
booking = Booking(id='bk-m-ana-...', room_id='focus', member_id='m-ana', start='2026-03-10T09:00:00', end='2026-03-10T12:00:00', status='confirmed', price_cents=6000)
price_paid_cents = 6000, now = datetime.datetime(2026, 3, 1, 9, 0)
def refund_cents(booking, price_paid_cents, now):
"""Refund according to the lead time from now to booking.start."""
> hours_until = (booking.start - now).total_seconds() / 3600
E TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime'
reservo/pricing.py:11: TypeError
========================= 1 failed, 1 passed in 0.05s ==========================
There's the reward, no rhetoric. The solitary flow with the fake passes; the sociable one with the real one fails, and not on an assertion but in the heart of cancel. Read the trace from bottom to top: service.cancel(booking.id) calls refund_cents(booking, booking.price_cents, now), and there hours_until = (booking.start - now) blows up with a TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime'. Look at the values pytest shows you: booking.start is '2026-03-10T09:00:00' —a str, in quotes— and now is datetime.datetime(2026, 3, 1, 9, 0) —a datetime—. They can't be subtracted. The cancel read from the real repository a booking whose start came back as text, and when trying to do date arithmetic with it, the flow broke.
Why neither the unit nor an incomplete contract sees it
This is what makes integration special, and it deserves breaking down.
The unit test doesn't see it, because it uses the fake. The FakeBookingRepository stores the whole object in a dict and returns it intact: booking.start comes out as the original datetime. When cancel does booking.start - now, it's datetime - datetime, which works perfectly, and the refund comes out 6000. The solitary test passes green, and it isn't wrong —it's true for the fake—. But the fake is the original key: it always turns. No test that uses only the fake can discover that the real copy doesn't turn.
A complete contract does catch it; an incomplete one, no. The module 3 and 4 contract verifies the repository's round-trip. If your clause is repo.get(id) == booking —comparing the whole object—, it catches the start as str, because a Booking with start='...' (text) isn't equal to one with start=datetime(...). Perfect. But you write the contracts, and they have exactly the gaps you leave. If your clause was looser —"the status and the price_cents survive the round-trip", forgetting the start—, the contract passes green: the fake and the real one match on status and price_cents. The datetime bug lives in your contract's gap, invisible.
Integration catches it even if the contract had that gap. And this is the key: integration doesn't depend on you having thought to verify the start. It doesn't inspect fields; it exercises the flow. cancel uses the start in a subtraction, and that subtraction blows up with the str, without any of your assertions having to point at the start. Integration catches the bug by use, not by inspection. That's why an integration of the complete flow is an irreplaceable complement to the contract: the contract protects you from the divergences you enumerated; integration protects you from the ones you didn't think to enumerate but that the real flow triggers.
It's the moral of the whole module: the contract certifies that the pieces fulfill the clauses you wrote; integration verifies that they really collaborate, even in what you didn't write. You need both.
The fix, and the flow green
Don't stay with the red. The fix isn't new: it's the same one you already applied in module 3, the one that makes the provider faithful. Recall the datetime bug that module 1's integration exposed and module 3 fixed: SqliteBookingRepository.get converts the text back to datetime when reading, with datetime.fromisoformat, so the booking it returns fulfills the same contract as the fake —a start that's a real datetime—. We undo the rollback above and the flow goes back to green. Here's the fixed get, as a reminder:
# reservo/sqlite_repo.py — get() fixed
from datetime import datetime
# ...
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=datetime.fromisoformat(row[3]), # text -> datetime back
end=datetime.fromisoformat(row[4]),
status=row[5], price_cents=row[6],
)
With get returning start and end as datetime, cancel can subtract without trouble. Let's run the complete flow book→cancel→get against this fixed repository:
# tests/test_book_cancel_get_fixed.py — the complete flow against the FIXED repo
def test_book_cancel_get_full_flow_against_real_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END) # writes the booking
refund = service.cancel(booking.id) # reads, computes, saves cancelled
assert refund == 6000 # full refund (9 days before)
assert repo.get(booking.id).status == "cancelled" # the final status, re-read
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_cancel_get_fixed.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_cancel_get_fixed.py::test_book_cancel_get_full_flow_against_real_sqlite PASSED [100%]
============================== 1 passed in 0.01s ===============================
Green. The complete flow book→cancel→get runs end to end against real SQLite: book writes the booking, cancel reads it back (now with the start as a datetime), computes the full refund of 6000 (nine days of lead time), saves the cancelled status, and get confirms it ended up cancelled. The copied key now turns: the integration that before blew up with a TypeError passes clean, because the provider fulfills the contract its consumer needs. Notice the complete cycle this demonstrates —write, read, recompute on what was read, rewrite, re-read— all against a real database. That's a broad integration flow, and seeing it green is the signal that BookingService and SqliteBookingRepository really collaborate, not just resemble each other.
Common mistakes
Fixing the bug by making the fake also return a str. What happens: someone, to make the unit test "reflect" the bug, makes FakeBookingRepository.get return the start as text. Why it happens: it seems that aligning the fake to the real one closes the divergence. How to detect it: if your fake now reproduces an undesirable behavior of the real one, you baked the bug into the double instead of fixing it. How to fix it: the divergence is closed by deciding the correct behavior —get must return a datetime— and making both providers fulfill it. The fake already fulfills it; the real one is fixed with datetime.fromisoformat. The fake shouldn't imitate the real one's defects; both should fulfill a correct contract.
Believing a green contract makes the flow integration unnecessary. What happens: someone has the repository's contract green and concludes that the book→cancel→get flow is already covered. Why it happens: a green contract gives a lot of confidence. How to detect it: ask yourself whether your contract verifies every field of the round-trip and every use the service makes of the data. If the contract has a gap (it didn't verify the start), and no test exercises the real flow, the datetime bug lives in that gap. How to fix it: keep the contract (it protects you from what you enumerated) and add a flow integration (it protects you from what the real use triggers even if you didn't enumerate it). They're complementary; neither makes the other unnecessary.
Reading the TypeError as "a bug in the test" instead of "a bug the test found". What happens: the TypeError comes out and someone says "the integration test is poorly written, fix it so it passes". Why it happens: a test that blows up seems like a broken test. How to detect it: look at where the trace explodes. If the error is in the production code (reservo/pricing.py, inside cancel), the test isn't wrong: it found a real bug that in production would also blow up. How to fix it: the fix isn't touching the test, it's fixing the provider (get that converts back). The test did exactly its job —catch, before production, a TypeError the user would have suffered when canceling—. Changing the test to silence it would be covering up the bug.
Exercises
Exercise 1 — Predict where it blows up. Without running anything, imagine that instead of cancel, the flow were book→get and then you tried saved.end - saved.start to compute the duration, with the real repository (buggy, without the fix). Would it pass or fail? If it fails, with what error and on what conceptual line?
See solution
It would fail, with a TypeError of the same kind. With the buggy repository, get returns saved.start and saved.end as str (ISO text). When trying saved.end - saved.start, you'd be subtracting str - str, and Python doesn't define subtraction between strings: it would raise TypeError: unsupported operand type(s) for -: 'str' and 'str'.
It's the same mechanism as the lesson, with a variant: here the two operands are str (both come from the repository), while in cancel it was str - datetime (the repository's start minus the clock's now, which is a datetime). In both cases, the cause is the same —the repository returns text where the code expects a datetime— and the symptom is the same —the flow blows up when using the value, not when inspecting it—. And in both cases, the fix is the same: have get convert back with datetime.fromisoformat, so the fields come back as datetime and the arithmetic works.
Exercise 2 — The contract with the gap. Write a repository contract clause that would pass green for the fake and the buggy real one (that is, that has the gap), and explain why the book→cancel→get flow would catch the bug that clause doesn't see.
See solution
A clause with the gap would verify only the fields that cross the seam without changing shape:
def test_save_then_get_preserves_status_and_price(repo):
booking = a_booking() # status="confirmed", price_cents=6000
repo.save(booking)
got = repo.get(booking.id)
assert got.status == "confirmed" # text: survives
assert got.price_cents == 6000 # integer: survives
# (does NOT verify got.start or got.end) <-- the gap
This clause passes green for the fake and for the buggy real one, because status (text) and price_cents (integer) have a native type in SQLite and cross the seam identical in both. The contract stays "green", giving the false impression that the fake and the real one match. But it never looked at the start, which is exactly where they diverge.
The book→cancel→get flow catches the bug this clause doesn't see because it doesn't depend on the clause having looked at the start. cancel uses the start in a subtraction, and that subtraction blows up with the str, without any assertion having to point at the start. The loose clause didn't see it because it only inspected two fields; integration sees it because it exercises the real use of the third. There's the complementarity: the contract covers what you enumerate, integration covers what the flow triggers. A contract with gaps needs an integration that exercises the use to plug them.
Exercise 3 — Why refund == 6000 and not another anchor? In the fixed flow, the test verifies refund == 6000. Explain where that number comes from given CLOCK = datetime(2026, 3, 1, 9) and START = datetime(2026, 3, 10, 9), and what would need to change to verify the 3000 anchor.
See solution
The refund is 6000 because the lead time is enormous. refund_cents computes hours_until = (booking.start - now). With now = CLOCK = 2026-03-01 09:00 and booking.start = 2026-03-10 09:00, the difference is exactly nine days, i.e. 216 hours. Since 216 >= 48, it falls into the first policy tier (hours_until >= 48 → full refund), so it returns price_paid_cents, which is 6000. It's the full-refund anchor.
To verify the 3000 anchor (the 50%, tier 24 <= hours_until < 48), you'd have to set the clock to a lead time within that range —for example, 36 hours before the start—. With START = 2026-03-10 09:00, that would be CLOCK = datetime(2026, 3, 8, 21) (36 hours before 9:00 on the 10th). Then hours_until = 36, which falls in 24 <= 36 < 48, and refund_cents would return 6000 * 50 // 100 = 3000. The test would verify refund == 3000. (And for the 0 anchor, a clock less than 24h before the start, like 12 hours before.)
What this shows: in an integration of the cancel flow, the doubled clock (FixedClock) is what lets you choose which anchor you exercise, by setting it to whatever lead time you want. That's why the clock is doubled even though the repository is real: it's non-deterministic, and freezing it turns each anchor into a datum of the test. It's lesson 4's rule in action —real the seam (repository), doubled the non-deterministic (clock)— within the flow that catches the bug.
Summary and next step
In this lesson you collected the module's reward. You took the complete flow book→cancel→get against the real repository and saw, with pytest output, how integration catches what the unit doesn't: the solitary flow with the fake passes green, the sociable one with SQLite blows up with a TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime', because cancel tried to subtract a start that came back as text. With the key that goes in but doesn't turn you understood the difference between inspecting a data's shape (what a contract does) and using it in the real flow (what an integration does), and why a contract with a gap —that didn't look at the start— would let through a bug the flow catches by use. And you didn't stay with the red: you restored module 3's fix (datetime.fromisoformat in get) and saw the complete flow green.
Before moving on you should be able to: explain why the unit test with the fake is blind to this bug; explain why integration catches it even though an incomplete contract didn't see it (use versus inspection); and apply the fix in the provider without falling into the false fix of making the fake imitate the defect.
You've now seen integration's benefit in all its glory. It's time to see the bill. In lesson 7 we put real numbers on the table: how much slower touching SQLite really is than a fake —we'll measure it with the fake against in-memory SQLite against on-disk SQLite— and what burden seeding and cleaning the database adds in each test. With that cost in hand, you'll know how to decide when to pay for an integration and when a contract or a unit is enough, which is the criterion that closes the module.
Resources
datetime.fromisoformat— Python documentation — the function that reconstructs adatetimefrom the ISO text SQLite returns; the exact fix that putsgetto fulfill the contract and thebook→cancel→getflow green.sqlite3— Adapter and converter recipes (Python documentation) — the section that explains why SQLite doesn't store adatetimeas such and how to register the conversion back automatically, an alternative to the manualfromisoformatof the fix.- pytest documentation — How to understand failure reports — to read the
FAILURESblock and follow the trace that goes fromservice.canceltorefund_centsto theTypeError, as in the worked example. - Martin Fowler — IntegrationTest — the frame that explains why an integration verifies the real collaboration and catches use bugs that the isolated verification of each component may not enumerate; the basis of this lesson's contract/integration complementarity.