Module 1: From Units To Integration
8. Mini-project: fake green, real red
Description
This is the module's practical close. The previous seven lessons gave you the "why" piece by piece —the difference between unit and integration, the pyramid, the seams, the problem of the deceptive green, the types of integration and their cost—. Now it's your turn to weave it all into a single deliverable that demonstrates the guide's central thesis with your own hands: a unit test with the FakeBookingRepository passes green, while the integration test with the real SqliteBookingRepository fails red because of the seam's divergence. It's not a new tool; it's the whole module condensed into two tests and a diagnosis.
What is really evaluated here isn't that you get the red —that's easy— but that you can explain why the green was lying. Anyone can paste two tests and see that one fails. The deliverable that matters is the diagnosis: naming the seam where the two providers diverge, pointing out the exact field that changes shape when crossing it, explaining why the fake couldn't catch the bug (because the fake is the assumption that turned out false), and locating where the solution lives in the rest of the guide. A student who turns in the red without the diagnosis hasn't demonstrated they understood the module; one who turns in the complete diagnosis has demonstrated they'll never again read a green suite of pure unit tests without asking which seams were left unverified.
Connection to the module: this lesson is module 1's practical exam and its close. It gathers the gap lesson 1 showed you, lesson 2's definition, lesson 4's seam, lesson 5's mechanism of deception, and lesson 7's cost criterion, and presents them as a project with deliverables and a reference solution. After the statement, it summarizes the module and points you to module 2, where we'll see another divergence of the same seam —the fake that returns None where the real one raises— to begin building, starting in module 3, the systematic solution: the contract.
Analogy: the inspector's report
Think of a building inspector who finds a poorly calculated joint —the one from lesson 1's example, the beam that doesn't rest well on the column—. Their job doesn't end when they see the crack; it ends when they turn in the report: where the joint is, which two pieces it connects, why it gives (the short bolt, the steel that expands differently), why the piece-by-piece review didn't detect it (each piece met its standard separately), and whose job it is to fix it. An inspector who only says "something looks wrong" is useless; one who turns in the complete report allows the right thing to be fixed, in the right place, for the right reason.
Your mini-project is that report. The integration test's red is the crack you found; the deliverable is the report that explains it. You'll document where the seam is, which two providers diverge at it, which field gives when crossing it, why the green unit tests didn't detect it, and where —in which modules of the guide— the fix lives. Turning in the report, not just the crack, is what separates "I saw it fail" from "I understand why it fails and what to do about it".
The project: formal statement
Your task is to demonstrate, from start to finish and with real pytest output, that a green unit test can hide a broken integration in Reservo. Specifically:
Write two tests of the same business behavior —book Focus for 3 h for the pro member Ana and verify that the booking was saved correctly (with price_cents == 6000 and the start that was booked)—:
- The unit test uses the
FakeBookingRepository(and a payment stub, an email spy, a fixed clock). It must pass green. - The integration test uses the real
SqliteBookingRepository(same payment, email, and clock doubled; only the repository changes). It must fail red, on thestartassertion.
Deliverables
- The two tests, in a single file, identical except for the repository they receive. The only difference between them must be the piece plugged into the
reposeam; everything else —the data, the assertions— the same. - The real pytest output from running the file with
-v, showing the unit test asPASSEDand the integration one asFAILED, with theAssertionErrorblock that gives away thestart's type. - The diagnosis (the deliverable that weighs most), answering four questions: (a) at which seam do the two providers diverge? (b) which field changes shape when crossing it, and to what type? (c) why couldn't the unit test with the fake catch this bug? (d) where, in the rest of the guide, does the solution live?
- The assertion that does survive. Point out the assertion that passes in both tests (
price_cents == 6000) and explain why that field crosses the seam without trouble while thestartdoesn't.
Worked example: the two tests and their output
Here is the complete file: two twin tests, one with the fake, one with the real. Read them and notice that the only real difference is the repository line —FakeBookingRepository() versus SqliteBookingRepository(sqlite3.connect(":memory:"))—; the rest, including the two final assertions, is identical.
# tests/test_book_persists_correctly.py — the same book, two different repos
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)
def make_service(repo):
return BookingService(
Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo,
)
# --- unit: the repository is an in-memory double ---
def test_book_persists_the_booking_with_fake_repo():
repo = FakeBookingRepository()
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the correct charge
assert saved.start == START # the booking was stored intact
# --- integration: the repository is real SQLite ---
def test_book_persists_the_booking_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the correct charge
assert saved.start == START # <-- here the two repos diverge
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_persists_correctly.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_persists_correctly.py::test_book_persists_the_booking_with_fake_repo PASSED [ 50%]
tests/test_book_persists_correctly.py::test_book_persists_the_booking_with_sqlite_repo FAILED [100%]
=================================== FAILURES ===================================
_______________ test_book_persists_the_booking_with_sqlite_repo ________________
def test_book_persists_the_booking_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the correct charge
> assert saved.start == START # <-- here the two repos diverge
E AssertionError: assert '2026-03-10T09:00:00' == datetime.datetime(2026, 3, 10, 9, 0)
E + where '2026-03-10T09:00:00' = Booking(id='bk-1', ..., start='2026-03-10T09:00:00', ...).start
tests/test_book_persists_correctly.py:50: AssertionError
========================= 1 failed, 1 passed in 0.02s ==========================
There's the complete demonstration: 1 failed, 1 passed. The same book, the same assertion, two verdicts —green with the fake, red with the real—, and the red pointing to the exact line and the exact type: '2026-03-10T09:00:00' (a str) versus datetime.datetime(2026, 3, 10, 9, 0). With this you have deliverables 1 and 2. What's missing is the one that weighs: the diagnosis.
Reference solution
See the complete diagnosis (deliverables 3 and 4)
Deliverable 3 — the diagnosis, four questions.
(a) At which seam do the two providers diverge? At the repository seam: the point where BookingService (consumer) connects with a BookingRepository (provider) through the save/get/find_by_room interface. The FakeBookingRepository and the SqliteBookingRepository are two providers of that same seam, and they fit the same (both have the methods), but they behave differently in the round-trip of save followed by get. The divergence isn't in BookingService's logic —which is identical in both tests— but in the plugged-in provider.
(b) Which field changes shape, and to what type? The start field, of type datetime. When crossing the seam into SQLite it is serialized to text (in save, with .isoformat(), because a database column doesn't store Python datetime objects) and comes back as a str in get, because nobody converts it back to datetime. So saved.start is '2026-03-10T09:00:00' (a str) instead of datetime(2026, 3, 10, 9, 0), and saved.start == START is str == datetime, which is False. The fake, on the other hand, stores the whole object in a dict without serializing anything, so it returns the datetime intact.
(c) Why couldn't the unit test with the fake catch the bug? Because the fake is the assumption that turned out false. When we wrote FakeBookingRepository.get to return the saved object as-is, we encoded the premise "reading a booking returns it identical to how it was saved". The unit test, by testing against the fake, doesn't verify that premise: it takes it for granted —it can't do otherwise, because the fake embodies it—. A test can't catch an error that lives in its own premise. That's why the green is true (the fake really does return the datetime intact) but deceptive: it guarantees "works with the double", not "works with the real piece". The leap from the first to the second is the deception, and only a test that crosses the seam with the real provider —integration— can refute it.
(d) Where does the solution live in the rest of the guide? In two complementary places. The bug fix itself lives in the provider: SqliteBookingRepository.get should convert the text back to datetime (with datetime.fromisoformat(row[3])), or register a type converter in sqlite3, so that its get fulfills the same contract as the fake. The systematic guarantee that the fake and the real don't diverge is the contract testing of modules 3 and 4: a single battery of behavior —"saving and reading returns the same booking, with the same types"— that is run against both providers, so that any future divergence comes out red immediately instead of hiding until production. And the in-depth integration with the real repository, transactions, files, and HTTP is modules 5 through 7. In module 1 we fix nothing: we only demonstrate, understand, and appreciate the gap.
Deliverable 4 — the assertion that does survive. The assertion saved.price_cents == 6000 passes in both tests, including the integration one. The reason: price_cents is an integer, and SQLite has a native type for integers —the price_cents INTEGER column stores 6000 as a number and returns it as a number—. The value crosses the seam without changing type, so saved.price_cents == 6000 is int == int in both cases. The datetime doesn't share the same fate because SQLite does not have a native type for it: it has to be serialized to text, and in that conversion it loses its type. The moral of the contrast: a seam's divergence isn't "all or nothing"; it can affect some fields (the ones the seam has to serialize) and not others (the ones with a native type on the other side). That's why the bug is so treacherous —the object looks almost right, only one field is wrong— and why a contract must verify every field, not just that "something was saved".
Common mistakes
Turning in the red without the diagnosis. What happens: someone pastes the two tests, shows the 1 failed, 1 passed, and considers the project done. Why it happens: the red "feels" like the deliverable, because it's the visible part. How to detect it: if you can't answer the diagnosis's four questions —seam, field, why the fake didn't see it, where the fix lives—, you're missing the central deliverable. How to fix it: the mini-project evaluates the understanding, not the red; the red is the evidence, the diagnosis is the thesis. Write the inspector's report, not just the photo of the crack.
Making the two tests differ in more than the repository. What happens: someone, unintentionally, uses different data or different assertions in the unit and integration tests, and the contrast stops being clean. Why it happens: they're written separately and a difference slips in. How to detect it: if comparing the two tests there's more than one different line (the repository one), the experiment isn't controlled —you can't attribute the red only to the seam—. How to fix it: extract everything common to a helper (make_service(repo)) and to shared constants (START, END), so that the two tests are identical except for the plugged-in piece. An experiment with a single variable is the one that proves something.
Concluding "fakes are bad, they should be deleted". What happens: someone, impressed by the red, decides the problem is the fakes and that everything should be tested against SQLite. Why it happens: if the fake hid the bug, the fake seems the culprit. How to detect it: if your conclusion is "away with the doubles", it contradicts the pyramid (lesson 3) and the cost (lesson 7). How to fix it: the fake isn't the culprit; the illegitimate conclusion we drew from its green is. The fake is still your speed and your precision at the base of the pyramid; what was missing was the integration test that covers the seam and —starting in module 3— the contract that keeps the fake honest. The answer isn't fewer doubles: it's doubles plus integration plus contract, each in its place.
Exercises
Exercise 1 — Predict another divergence of the same seam. Without running anything, design a third test that exposes a divergence different from the datetime between the fake and the real, using the booking's end. Predict what the fake will do and what SQLite will do, and on which assertion the integration one would fail.
See solution
The end is also a datetime, so it suffers exactly the same serialization as the start. A test that books and then verifies saved.end == END:
def test_book_persists_end_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.end == END # END = datetime(2026, 3, 10, 12)
The fake would return saved.end as the intact datetime(2026, 3, 10, 12), so the assertion would pass. SQLite would return saved.end as the str '2026-03-10T12:00:00', and the assertion assert saved.end == START... sorry, assert saved.end == END would fail with AssertionError: assert '2026-03-10T12:00:00' == datetime.datetime(2026, 3, 10, 12, 0).
What this shows: the datetime divergence isn't an accident of one field; it affects all the datetime fields that cross the seam, because the cause is the seam (the serialization to text), not the field. That's why a contract must cover every field of the round-trip: start, end, and any other type without a native equivalent in the database. A contract that only verified price_cents would give a false peace of mind identical to the unit test's.
Exercise 2 — The fix, and why we don't do it yet. The diagnosis says the datetime fix lives in SqliteBookingRepository.get, converting the text back with datetime.fromisoformat(...). Write how the line would look, and explain why in module 1 we do not apply it and leave the red.
See solution
The fixed line in SqliteBookingRepository.get, instead of start=row[3], end=row[4], would be:
from datetime import datetime
# ...
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 that, get would return start as a real datetime, would fulfill the same contract as the fake, and the integration test would go green.
Why we don't apply it in module 1: because this module's goal is the why, not the how. Applying the fix here would resolve this particular divergence, but it would jump over the lesson the module teaches: that the gap exists, that a double can lie, that you need a systematic tool —not a per-bug patch— to guarantee that the fake and the real don't diverge. That tool is the contract of modules 3 and 4. If we fixed the datetime by hand now, tomorrow the None-vs-raise divergence would appear (module 2), and the NOT NULL one, and we'd be patching them one by one without ever building the guarantee that prevents them all. The red stays red on purpose: it's the problem the entire guide is going to solve properly, not with a Band-Aid.
Exercise 3 — Explain it to your team. A colleague, seeing your mini-project, says: "but our 400 unit tests are all green, this is a contrived case". Write the three-sentence answer you'd give them, leaning on what you demonstrated.
See solution
A possible answer, in three sentences:
"Our 400 green unit tests guarantee only one thing: that the logic works with the doubles we gave them —not that it works with SQLite, which is what runs in production—. This mini-project isn't contrived: it's literally our book, with the only difference being plugging in the real repository instead of the fake, and that change alone is enough for a saved booking to return the start as text instead of a date, something none of the 400 tests can see because they all use the fake that returns the date intact. The lesson isn't that the unit tests are superfluous, but that we're missing coverage of the boundary seams with integration and with a contract —the modules that follow—, because a suite of pure doubles, however green, says nothing about the joints with the real pieces."
The essence of the answer: don't attack the unit tests (they're necessary) or dramatize (it's not a rare case, it's the most common seam of any system with a database), but reframe the green —"works with the double", not "works"— and name what's missing: integration at the risk seams and a contract that keeps the doubles honest. It's the module's thesis, said to convince.
Summary and module close
With this mini-project turned in, you close module 1. You demonstrated with your own hands the thesis that holds up the guide: the same Reservo book passes green with the FakeBookingRepository and fails red with the real SqliteBookingRepository, and you were able to explain why —the repository seam, the datetime that serializes to text and comes back as a str, the fake that couldn't catch the bug because it embodied the false premise, and the integer price_cents that does survive because it has a native type in the database—. You turned in the inspector's report, not just the photo of the crack.
You went through the whole module: the gap between doubling and testing the real thing (lesson 1); the precise definitions of unit and integration (lesson 2); the pyramid that dictates the proportion (lesson 3); the seams where integration lives (lesson 4); the mechanism of deception of the green that hides a red (lesson 5); the types of integration to name what you're testing (lesson 6); and the cost that gives everything its shape (lesson 7). You come out knowing why integration exists, with the reflex of looking at any green suite and asking which seams were left unverified against the world.
Where the guide goes next. Module 1 was the diagnosis; now the treatment begins. Module 2 takes another divergence of this same seam —the fake that returns None where the real SqliteBookingRepository raises— and shows how that bug passes the unit test and blows up in production, sharpening the problem until it's ready for its solution. Starting in module 3, that solution arrives: the consumer-driven contract, a battery of behavior you run against the fake and against the real, so that no divergence can hide behind a green again. From there on (modules 5 to 7) you'll take integration to the real resources —SQLite, files, HTTP— with its cost well managed. The gap you only saw today, you're going to close.
Resources
- pytest documentation — How to invoke pytest (
-v, selecting tests) — the reference for running the file with-vand producing your own "What to expect" block with thePASSED/FAILED, as in the worked example. sqlite3— Adapter and converter recipes (Python documentation) — the section that explains the serialization that causes the divergence and how (in the right module) the conversion back todatetimeis registered; the foundation of deliverable 3(d) and exercise 2.datetime.fromisoformat— Python documentation — the function that reconstructs adatetimefrom the ISO text SQLite returns; the provider fix that exercise 2 writes but the module leaves for later.test-doubles-and-test-data-guide— the sister guide where theFakeBookingRepositorywas born; useful to remember that a fake is an implementation you write, and therefore a premise of yours about the real thing —exactly the premise this mini-project put to the test—.