Module 2: The Double That Lied
8. Mini-project: find the divergence the unit test doesn't reveal
Description
This is the module's practical close, and it works as an exam of everything you saw. For six lessons I showed you divergences I had already found and run; now it's your turn to do the complete work on a new feature, with a divergence I won't point out to you in advance. The goal isn't for you to guess the answer —I'll give it to you in the reference solution—, but for you to walk through the method: take a piece of code that's tested with a fake, suspect where the green might be lying, and demonstrate it by bringing in the real piece. That method —"I don't believe the green until I confront it with the real thing"— is the skill the whole guide sets out to install, and this mini-project is your first rehearsal of it without training wheels.
The feature is small and realistic: a function that answers "when does this room's next booking start?". It's used, for example, by a panel that shows "Next booking: 09:00" on each room's door. It looks innocent, has its unit test green with the FakeBookingRepository, and hides a divergence from one of the four families you studied. Your submission isn't just "I found the bug": it's the pair of tests that exhibits it (unit green, integration red), the diagnosis of why the green was lying, and —the deliverable that really closes the module— the shape of the contract that would have caught it, described but not implemented, because implementing it is module 3. By the end, you'll have lived the complete cycle of the problem, and you'll be ready for the next guide to give you the solution.
Connection to the module: this lesson gathers the previous seven and puts them in your hands. Lesson 1 stated the problem; lesson 2 explained why it's inevitable; lessons 3, 4, and 5 cataloged the divergences; lesson 6 showed why the unit test is blind to them; lesson 7 put a price on them. The mini-project has you run that whole arc on a case of your own: reproduce the double's lie (lessons 3-5), understand why the unit test doesn't see it (lesson 6), and appreciate what it would cost in production (lesson 7). And it ends by explicitly pointing to module 3, because the last deliverable —describing the contract that would catch it— is, literally, the statement of the problem the consumer-driven contract solves. You close the problem's module right at the door of the solution's module.
Analogy: the drill that didn't rehearse the real exit
A building holds its fire drill and everything goes perfectly: people go down the stairs, gather at the meeting point, the stopwatch marks an excellent time. Green. But the drill was always rehearsed with the same exit door —the main one, wide, clear—. On the day of the real fire, that door is blocked by the flames and you have to use the back emergency exit, narrow, that nobody practiced. The drill didn't lie out of malice; it rehearsed an assumption ("we'll exit through the main door") that the real fire didn't respect. Everyone passed the drill and nobody was ready for the door they actually had to use.
Your mission in this mini-project is to be the inspector who refuses to sign off on the drill without first asking: "and what if the main door is blocked?". That is: look at the feature that passes its unit test green and ask "and what if the real repository doesn't behave like the fake right here?". The inspector doesn't trust that the drill went well; they go to the back door —the real piece— and check whether the plan holds. Finding the divergence is finding the door the drill never rehearsed. And proposing the contract is writing into the protocol "the emergency exit shall also be rehearsed", so that the next drill can't come out green while ignoring the difficult door.
The statement
You're handed this feature, already written and with its unit test green:
# reservo/reporting.py — feature: when does a room's next booking start?
def next_booking_start(repo, room_id, now):
"""Returns the start of the room's next future booking, or None if there is none."""
future = [b for b in repo.find_by_room(room_id) if b.start > now] # compares with now
if not future:
return None
return min(future, key=lambda b: b.start).start # orders by start
The function asks for the room's bookings, keeps the future ones (the ones that start after now), and returns the start of the closest one. The logic is correct as an algorithm. The unit test that accompanies it, with the FakeBookingRepository, is green:
def test_next_start_with_fake():
repo = FakeBookingRepository()
seed(repo) # three Focus bookings: 11h, 9h, 10h
assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)
Deliverables
- The integration test that exposes the divergence. Write a test with the same scenario but using the real
SqliteBookingRepositoryinstead of the fake. It must go red. Paste the real pytest output. - The diagnosis. In prose: which divergence is it (behavior, type, order, uniqueness/transaction)? Why does the fake hide it and the real one reveal it? What did the green unit test actually claim, with its hidden condition?
- The fix, in the right place. Fix the divergence. Decide whether it goes in the feature or in the repository, and justify it. Leave the integration test green.
- The shape of the contract (without implementing it). Describe in one or two sentences the behavior agreement that, verified against the fake and the real one, would have caught this before the deploy. Don't implement it —that's module 3—; just state it precisely.
- The bill, in one sentence. Estimate the production cost if this divergence had been deployed: what would the user see, and in which dimension (late detection, radius, debugging) does it hit hardest?
Rubric
The method is evaluated, not just whether you find the bug. Each row is a module capability.
| Criterion | Insufficient | Competent | Excellent |
|---|---|---|---|
| Exposing the divergence | The integration test doesn't go red, or doesn't use the real repo. | A red integration test with the SqliteBookingRepository. | The test isolates the minimal scenario that diverges and the output shows the cause (the type, the order) in the traceback. |
| Diagnosis | "It fails and that's it" or blames the wrong piece. | Names the divergence family and why the fake hides it. | Restates what the green claimed with its hidden condition, as in lesson 3. |
| Fix | In the wrong place (patches the test or each client). | In the right place, with the test green. | Justifies why it goes in the repository (all clients inherit the contract) and doesn't break other cases. |
| Contract | Absent or vague. | A behavior agreement stated. | A precise agreement that, run against both, would go red on whichever doesn't fulfill it. |
| Bill | Doesn't estimate it. | Names what the user would see. | Locates the dominant cost dimension and why. |
Worked example: the reference solution
Here's the complete submission, the one you'd produce. Let's start with the integration test (deliverable 1). It's the same scenario as the unit test, changing only the repository:
# tests/test_mini_project.py
def seed(repo):
repo.save(a_booking("bk-11", 11))
repo.save(a_booking("bk-9", 9))
repo.save(a_booking("bk-10", 10))
def test_next_start_with_fake():
repo = FakeBookingRepository()
seed(repo)
assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)
def test_next_start_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
seed(repo)
assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)
What to expect. On my machine (Python 3.14.0, pytest 9.1.1): the fake passes, the real one blows up.
python3 -m pytest tests/test_mini_project.py -v
tests/test_mini_project.py::test_next_start_with_fake PASSED [ 50%]
tests/test_mini_project.py::test_next_start_with_sqlite FAILED [100%]
=================================== FAILURES ===================================
_________________________ test_next_start_with_sqlite __________________________
def test_next_start_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
seed(repo)
> assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_mini_project.py:34:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
repo = <reservo.sqlite_repo.SqliteBookingRepository object at 0x10767b230>
room_id = 'focus', now = datetime.datetime(2026, 3, 10, 8, 0)
def next_booking_start(repo, room_id, now):
"""Returns the start of the room's next future booking, or None if there is none."""
> future = [b for b in repo.find_by_room(room_id) if b.start > now] # compares with now
^^^^^^^^^^^^^
E TypeError: '>' not supported between instances of 'str' and 'datetime.datetime'
reservo/reporting.py:4: TypeError
========================= 1 failed, 1 passed in 0.04s ==========================
The diagnosis (deliverable 2). It's a type divergence, from lesson 4's family. The function does b.start > now, comparing each booking's start with a datetime. With the FakeBookingRepository, b.start is a datetime —the fake stores and returns the object intact—, so the comparison datetime > datetime works and the test passes. With the real SqliteBookingRepository, b.start comes back as str —SQLite serialized the datetime to ISO text when saving it and nobody converted it back—, and Python doesn't know how to compare a str with a datetime: it raises TypeError: '>' not supported between instances of 'str' and 'datetime.datetime'. The fake hides the divergence because it doesn't serialize; the real one reveals it because it does. And what the green unit test actually claimed wasn't "the function returns the next booking", but "the function returns the next booking when the repository delivers start as a datetime" —a condition the fake fulfilled silently and the real one doesn't fulfill—.
The fix (deliverable 3). It goes in the repository, not in the feature. The root is that the SqliteBookingRepository doesn't honor the implicit contract "get/find_by_room return Bookings with start of type datetime". It's fixed by reconstructing the datetime when reading:
from datetime import datetime
# in find_by_room and get of the SqliteBookingRepository, when constructing the Booking:
start=datetime.fromisoformat(row[3]), # ISO str -> datetime
end=datetime.fromisoformat(row[4]),
Why in the repository and not in next_booking_start? Because next_booking_start isn't the only one that reads start expecting a datetime: the screen code (.strftime()), the refund calculations (which subtract dates), any temporal comparison, all share the same expectation. If you "fixed" only next_booking_start (for example, converting the str to datetime there), the other clients would still be broken, and you'd repeat the patch over dozens of places until you forgot it in some. The responsibility to deliver Bookings with the correct types belongs to the piece that implements the interface —the repository—, so that all its clients inherit the contract from a single fix. With fromisoformat in the repository, the integration test passes and all clients are protected at once.
The shape of the contract (deliverable 4). The agreement: "find_by_room(room_id) and get(id) return Bookings whose start and end fields are of type datetime, not str." Verified against the fake and the real one, this agreement passes with the fake (which already returns datetime) and fails with the uncorrected SqliteBookingRepository (which returns str), going red on the developer's machine before any deploy. We don't implement it here —building that battery and running it against both implementations is module 3—; it's enough to see that the agreement's statement is precise and checkable.
The bill (deliverable 5). If this is deployed, each room's panel blows up with a 500 error when trying to compute the next booking —a visible screen, on each room's door, broken for everyone—. The dominant dimension is the blast radius: it's not a rare edge case, it's the main function of a screen that's seen constantly, so it affects every person who looks at any room's panel with future bookings. Late detection and debugging also hit (the green authorized the deploy; the TypeError blows up in reporting.py because of a serialization decided in sqlite_repo.py), but it's the radius —an omnipresent screen down for everyone— that makes this incident especially expensive.
Variants to practice the method
If you want to reinforce the method with more repetitions, here are three variants of the same feature, each with a divergence from a different family. For each one, repeat the cycle: write the unit-green / integration-red pair, diagnose, and propose the contract.
Variant A — order. Change next_booking_start for list_room_bookings(repo, room_id) that returns the bookings "in the order they were created" and asserts [0].id == "bk-first". Test it with the fake and with the SqliteBookingRepository with an index on (room_id, start). Which family is it? Where does the fix go?
See hint
It's the order divergence (lesson 4). The fake returns insertion order; the real one with an index returns index order. The fix goes in the repository: an explicit ORDER BY that defines the order the feature needs —and if the feature wants "creation order", the schema needs a field that represents it (a created_at or a chronological id), because "insertion order" isn't a property the engine promises—. The contract: "find_by_room returns the bookings ordered by <explicit field>".
Variant B — behavior. Change the feature for cancel_next(repo, room_id, now) that cancels the next booking, and test it in a room without future bookings, where the code does repo.get(next_id) with a next_id that doesn't exist. Use the careless fake (get→None) and the real one (get→raises).
See hint
It's the behavior divergence (lesson 3), the get→None vs get→raises one. The careless fake returns None and the code continues; the real one raises KeyError. The fix goes in the consuming code (handle the absence with try/except or by checking beforehand whether there are bookings), respecting the contract "get of a missing id raises". The contract: "get(id) of an unsaved id raises".
Variant C — uniqueness. Change the feature for reserve_slot(repo, room_id, start) that saves a booking, and test it by saving two different bookings for the same room and time. Use the fake and the SqliteBookingRepository with UNIQUE(room_id, start).
See hint
It's the uniqueness divergence (lesson 5). The fake accepts both (it doesn't model the constraint); the real one rejects the second with IntegrityError. Here the fake's green certifies a business bug (double booking) as correct. The fix has two parts: the constraint in the database (which the real one already has) and the handling of the IntegrityError in the booking code. The contract: "saving two different bookings for the same room and time must fail".
Common mistakes
Patching the feature instead of fixing the repository. What happens: someone sees the TypeError in next_booking_start and "fixes" it right there, converting the str to datetime inside the feature. Why it happens: it's where the error blows up, so it seems the natural place. How to detect it: ask yourself how many other places read start expecting a datetime —there are some, and they're all still broken with your local patch—. How to fix it: the divergence is born in how the repository serializes; it's cured in the repository, so all clients inherit the fix. Patching the client moves the bug around and multiplies it.
Diagnosing "the real one is wrong" instead of "the real one reveals the divergence". What happens: someone concludes the SqliteBookingRepository has a bug because "it returns str instead of datetime". Why it happens: the real one is the one that fails the test, so it seems the culprit. How to detect it: the real one does what SQLite allows —store text—; the "bug" is that the repository didn't complete the contract when reading (it didn't convert the text back to datetime). The real one isn't wrong for serializing; it's incomplete for not deserializing. How to fix it: frame the finding as "the repository doesn't honor the type contract", not as "the real one is broken". The difference matters for fixing the right piece.
Turning in the red test without the contract. What happens: someone exhibits the divergence with the integration test and considers the mini-project done, without describing the contract that would catch it. Why it happens: the red test feels like the submission. How to detect it: if you can't write in one sentence the behavior agreement that, run against both, would go red, you're missing the deliverable that connects this module with the next. How to fix it: the mini-project doesn't end in "I found the bug"; it ends in "here's the agreement that prevents it". That agreement is the bridge to module 3, and stating it is demonstrating you understood that the solution isn't catching bugs one by one, but verifying agreements.
Summary and next step
In this mini-project you walked through, on your own, the complete cycle of the problem the module taught. You took an innocent feature with its unit test green, suspected the green, and brought in the real piece to expose a type divergence —the start that comes back as str from the SqliteBookingRepository and blows up the str > datetime comparison—. You diagnosed it (which family, why the fake hides it, what the green really claimed), fixed it in the right place (the repository, so all clients inherit the contract), put a price on it (an omnipresent screen down, wide radius), and —what closes the arc— described the contract that would have caught it before the deploy. With the three variants, you practiced the same method on the other three families.
With this you close module 2 and, with it, the complete statement of the guide's problem. You now know: that a double is an assumption that can fail green; why diverging is its natural tendency; the four families of divergence, run; why the unit test is structurally unable to see them; how much it costs when they reach production; and how to reproduce, diagnose, and appreciate a divergence yourself. You have the whole problem in your hand, and you have it with just enough discomfort to want the solution.
That solution begins in module 3. The last deliverable of this mini-project —"describe the behavior agreement that, run against the fake and the real one, would catch the divergence"— is, word for word, the definition of a contract. Module 3 takes that statement and turns it into code: a battery of consumer-driven tests run against both implementations that goes red the instant they disagree, so that none of this module's divergences can reach production green again. We left the problem perfectly stated; let's solve it.
Resources
datetime.fromisoformat(Python documentation) — the method that reconstructs thedatetimefrom the ISO text, the fix that closes this mini-project's type divergence in the repository.sqlite3— SQLite and Python types — why thedatetimecomes back asstr: SQLite has no native type for dates, and deserialization is the repository's responsibility.- pytest documentation — How to write and report assertions — how to read the traceback that exhibits the divergence (the
TypeErrorline, the types that can't be compared), the evidence your deliverable 1 must paste. test-doubles-and-test-data-guide— the sister guide on doubles, in case building the fake or the data scenario requires reviewing builders or fakes; here you used them as a tool, not as a topic.