Module 2: The Double That Lied

3. When the fake returns `None` and the real one raises

Description

This is the module's central lesson, the one that names the guide in its purest version. So far you've seen the double's lie described and grounded; now you're going to see it run, complete, with both pieces plugged in and the real pytest output side by side. The scenario is the cleanest possible: a divergence of behavior, not of shape. No data changes type, no datetime turns into text. The only thing that differs between the fake and the real one is what they do when you ask them for a booking that doesn't exist: the careless fake returns None, the real SqliteBookingRepository raises KeyError. One behavior versus another. And on that single difference a bug is built that passes the unit test green and blows up in production red.

What makes this divergence so instructive is that the code under test is well written —for the fake—. There's no programming error a reviewer could point a finger at. The developer wrote a reasonable feature (canceling is idempotent), tested it thoroughly against their test repository, and saw it work. Their test is correct, their code is correct, their fake is correct in the sense that it does what they believe. And yet the real system is broken, because all that correctness rests on an assumption —"get returns None if it doesn't exist"— that the fake confirms and the real one refutes. You're going to see the exact moment the assumption breaks, at the line number, with the KeyError in plain view.

Connection to the module: this lesson is the demonstration lessons 1 and 2 promised. Lesson 1 stated the problem and gave you a glimpse; lesson 2 explained why the divergence was inevitable (here, the "written by hand" cause in its clearest form); this one runs it from start to finish. Lessons 4 and 5 will show other families of divergence —types, order, uniqueness, transactions—, but they're all variations of what you see here: fake green, real red, same scenario. Lesson 6 will explain why the unit test is structurally unable to see this, and lesson 7 will measure what it costs. If you understand this lesson deeply —not just that it fails, but why the green was a lie and where exactly it breaks—, you have the whole module in your hand.

Analogy: the coat check that returns an empty hanger

Imagine two coat checks at two theaters. At both you leave your coat and they give you a number. The difference appears when you show up with a wrong number —one that doesn't correspond to any coat—. The first coat check, staffed by someone new, looks at the rack, finds nothing, and instead of telling you hands you the empty hanger: it doesn't protest, doesn't warn, gives you "nothing" with a smile. The second coat check, with the strict protocol, looks, finds nothing, and tells you to your face: "that number doesn't exist". Both did their job; they only differ in how they treat the case of the coatless number. One returns empty silently; the other raises its voice.

Now imagine you build an automatic machine that picks up coats, and you tune it by testing it only with the first coat check. Your machine learns: "if they give me the empty hanger, there was no coat, so I move on". It works wonderfully, you test it a hundred times, perfect. The day you connect your machine to the second coat check —the one with the strict protocol—, you give it a wrong number, and the coat check shouts "that number doesn't exist" at you instead of giving you the empty hanger. Your machine doesn't know what to do with a shout: it was programmed to expect an empty hanger, not a protest. It jams. The careless FakeBookingRepository is the first coat check (returns None, the empty hanger); the real SqliteBookingRepository is the second (raises KeyError, the shout); and your code —the machine— tuned itself to the first and jams with the second. The whole lesson is that machine jamming, in pytest, with the line number.

The setup: the feature, the fake, and the real one

Three pieces. First, the feature the developer built: an idempotent cancellation. The business rule is sensible: if a member clicks "Cancel" twice, or opens an old link to an already-deleted booking, we don't want an ugly error; we want to handle it calmly —there's nothing to cancel, so the refund is 0—. The developer writes the guard the only way that makes sense if get returns None:

# reservo/idempotent.py — a new feature: cancel is idempotent
from reservo.models import Member
from reservo.pricing import refund_cents


class IdempotentBookingService:
    """Like BookingService, but canceling a nonexistent booking
    is NOT an error: it simply refunds 0 (in case the member double-clicks
    or opens an old link). The dev wrote the guard assuming that
    repo.get() returns None when the booking doesn't exist."""

    def __init__(self, clock, payments, emails, repo):
        self._clock = clock
        self._payments = payments
        self._emails = emails
        self._repo = repo

    def cancel(self, booking_id) -> int:
        booking = self._repo.get(booking_id)
        if booking is None:               # ← assumes get returns None if it doesn't exist
            return 0                       # nothing to cancel, nothing to refund
        refund = refund_cents(booking, booking.price_cents, self._clock.now())
        member = Member(id=booking.member_id, name=booking.member_id, tier="pro")
        if refund > 0:
            self._payments.refund(refund, member)
        booking.status = "cancelled"
        self._repo.save(booking)
        return refund

The heart is the line if booking is None: return 0. Read it with the developer's eyes: it's defensive, it's clear, it handles the rare case elegantly. There's nothing to object to... if get fulfills the assumption.

Second, the repository they tested it with: the careless fake you saw in lesson 1, the one that uses .get() and returns None:

# reservo/doubles.py — the fake as a distracted dev wrote it
class BuggyFakeBookingRepository:
    def __init__(self):
        self._store = {}

    def save(self, booking):
        self._store[booking.id] = booking

    def get(self, booking_id):
        return self._store.get(booking_id)   # ← .get(): returns None, does NOT raise

    def find_by_room(self, room_id):
        return [b for b in self._store.values() if b.room_id == room_id]

And third, the real repository, the SqliteBookingRepository you already know from module 1, whose get does raise KeyError(booking_id) when the row doesn't exist. The three pieces are ready. Now we plug them in.

Worked example: the same test, two repositories

The experiment is deliberately symmetric. Two tests that assert exactly the same thing —canceling a nonexistent booking returns 0, the promise of idempotent cancellation— and that differ in a single thing: which repository the service receives. One receives the careless fake (it's a unit test: the isolated unit with an in-memory double); the other receives the real SqliteBookingRepository (it's an integration test: the service and the real piece, together).

# tests/test_none_vs_raise.py
import sqlite3
from datetime import datetime

from reservo.doubles import (BuggyFakeBookingRepository, FixedClock,
                             SpyEmailSender, StubPaymentGateway)
from reservo.idempotent import IdempotentBookingService
from reservo.sqlite_repo import SqliteBookingRepository

CLOCK = datetime(2026, 3, 1, 9)


def make_service(repo):
    return IdempotentBookingService(
        FixedClock(CLOCK), StubPaymentGateway(ok=True), SpyEmailSender(), repo)


# --- unit: the repo is the careless fake (get -> None) ---
def test_cancel_missing_booking_returns_zero_with_fake():
    repo = BuggyFakeBookingRepository()
    service = make_service(repo)

    refund = service.cancel("bk-does-not-exist")

    assert refund == 0        # the guard `if booking is None` works... with the fake


# --- integration: the repo is real SQLite (get -> raises) ---
def test_cancel_missing_booking_returns_zero_with_sqlite():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = make_service(repo)

    refund = service.cancel("bk-does-not-exist")

    assert refund == 0        # <-- here the real one RAISES before reaching the guard

Both tests cancel "bk-does-not-exist" —an id that was never saved— and expect 0. In a world where the fake and the real one matched, both would pass or both would fail. Since they diverge exactly in the missing-id case, one passes and the other doesn't. Let's run them.

What to expect. On my machine (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_none_vs_raise.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0

tests/test_none_vs_raise.py::test_cancel_missing_booking_returns_zero_with_fake PASSED [ 50%]
tests/test_none_vs_raise.py::test_cancel_missing_booking_returns_zero_with_sqlite FAILED [100%]

=================================== FAILURES ===================================
_____________ test_cancel_missing_booking_returns_zero_with_sqlite _____________

    def test_cancel_missing_booking_returns_zero_with_sqlite():
        repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
        service = make_service(repo)

>       refund = service.cancel("bk-does-not-exist")
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/test_none_vs_raise.py:32:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
reservo/idempotent.py:19: in cancel
    booking = self._repo.get(booking_id)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <reservo.sqlite_repo.SqliteBookingRepository object at 0x10777b620>
booking_id = 'bk-does-not-exist'

    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)       # the real one RAISES if it doesn't exist
            ^^^^^^^^^^^^^^^^^^^^^^^^^^
E           KeyError: 'bk-does-not-exist'

reservo/sqlite_repo.py:50: KeyError
=========================== short test summary info ============================
FAILED tests/test_none_vs_raise.py::test_cancel_missing_booking_returns_zero_with_sqlite - KeyError: 'bk-does-not-exist'
========================= 1 failed, 1 passed in 0.05s ==========================

There's the lie, complete and without rhetoric. The unit test with the fake passes: the guard if booking is None: return 0 received the None it expected and returned 0, just as the developer designed. The integration test with the real SqliteBookingRepository fails, and the traceback tells you the exact story. Follow the trail from top to bottom: the call starts at service.cancel("bk-does-not-exist") (line 32 of the test), enters idempotent.py:19 —the very first line of cancel, booking = self._repo.get(booking_id)—, and there it drops into the SqliteBookingRepository's get, which doesn't find the row (row is None) and does raise KeyError. The KeyError bubbles up and blows up the test. The line if booking is None never ran: the get raised before returning anything, so the guard is dead code against the real piece. The machine tuned itself to the empty hanger and jammed with the shout.

Why the green was a lie, precisely

It's worth saying exactly what each test claimed, because that's where the lesson is. The green unit test claimed: "canceling a nonexistent booking returns 0". It sounds like a claim about the system. It isn't. What it actually verified is narrower: "canceling a nonexistent booking returns 0, when the repository returns None for missing ids". That final clause —the condition about the repository— was hidden, because the fake fulfilled it silently and nobody wrote it as part of what the test claimed to test. The developer read the green as "the system does X"; the green actually said "the system does X if the repo behaves like my fake". And the real repo doesn't behave like the fake. The hidden condition was false in production, so the conclusion —the green— was a lie about the real system, even though it was a truth about the fake.

This is the general anatomy of every lying green, and it's worth engraving: a test with a double doesn't prove a property of the system; it proves a property of the system conditioned on the double matching the real thing. When the match holds, the condition is invisible and the green is honest. When the match fails —for any of the three causes in lesson 2—, the condition stays invisible but is now false, and the green lies without changing color. There's no way, looking only at the unit test, to know which of the two worlds you're in. To know it you have to do one of two things: run the same scenario against the real piece (integration, what we just did) or separately verify that the fake and the real one match at that point (contract, module 3). The unit test, alone, is blind to the difference.

Whose fault is it? Nobody's, and that's the point

It's tempting to look for the culprit, and the exercise reveals why the problem is serious. Was it the feature's developer? They wrote a defensive and reasonable guard, and tested it. Was it the fake's author? They wrote a fake that works for the happy path, with a .get() that looks clean. Was it the test? It asserts something true about the system it tested. Was it the SqliteBookingRepository? It does exactly the right thing: raise when it doesn't find, which is the behavior the contract asks for. Each piece, looked at alone, is defensible. And yet the system is broken. The bug lives in no piece; it lives in the crack between two pieces —between the fake and the real one—, in a disagreement about behavior that neither of the two, on its own, had the responsibility to detect.

That's exactly the kind of bug the guide's disciplines attack, and that's why no amount of piece-by-piece code review would have caught it: a reviewer looking at idempotent.py sees correct code; looking at doubles.py sees a plausible fake; looking at the test sees a valid assertion. The bug only appears when you confront two pieces nobody confronted. Code review looks at pieces; the contract looks at agreements between pieces. Keep this idea for module 3: the contract is the tool that makes explicit the agreement "get of a missing id raises" and verifies it against the fake and the real one at once, so that the careless fake —which returns None— would go red in the contract, on your machine, before any deploy. The crack that today is invisible would become a red line impossible to ignore.

Common mistakes

Reading the if booking is None as the bug. What happens: someone sees the failure, looks at idempotent.py, and "fixes" the guard —removes it, or changes it to a try/except— without understanding the cause. Why it happens: the guard is the most visible thing in the traceback. How to detect it: ask yourself whether the guard would be fine with a repository that returned None; the answer is yes, it's correct for that contract. The bug isn't the guard: it's that the guard assumes a contract (get→None) that the real repo doesn't fulfill (get→raises). How to fix it: first decide what the real contract is —does get of a missing id return None or raise?— and then make both sides fulfill it and the code under test respect it. Changing the guard blindly can cover up this case and open another; the cure is agreeing on the contract, not patching the symptom.

Concluding "then fakes are dangerous, don't use them". What happens: burned, someone decides to test everything against the real SqliteBookingRepository and retire the fake. Why it happens: if the real one caught the bug, the real one always seems better. How to detect it: if your suite starts taking longer and depending on the disk to test logic that doesn't touch the database, you've gone to the other extreme. How to fix it: the fake isn't the villain; the villain is believing the fake without verifying it. The answer isn't to throw out the fake (you'd lose the speed of the pyramid's base), but to verify that it matches the real one (contract) and add a few tests against the real thing (integration). You use both, each for its own purpose.

Assuming a clear traceback means an easy bug. What happens: the KeyError points to an exact line, so someone assumes the bug is trivial. Why it happens: in this instructive example, the error jumps close to its cause. How to detect it: change the guard for handling that does not fail immediately —for example, if the code did bookings.get(id) or default in a larger structure— and the None (or the exception) would travel far before blowing up, with a traceback that points to an innocent place. It's what you'll see in lesson 7 with confusing debugging. How to fix it: don't count on the next divergence bug being as courteous as this one; treat them all with the same seriousness, because the next one may blow up ten functions away from its real cause.

Exercises

Exercise 1 — Predict the color. Without running anything, say whether each test passes or fails, and why: (a) the same idempotent cancel, but tested with the canonical FakeBookingRepository (the one that does self._store[booking_id], i.e., raises) canceling a missing id; (b) the idempotent cancel with the careless fake, canceling an id that does exist; (c) the idempotent cancel with the real SqliteBookingRepository, canceling an id that does exist.

See solution
  • (a) Fails (red). The canonical fake raises KeyError for the missing id, just like the real one. So the guard if booking is None is never reached, cancel blows up with KeyError, and the test that expects 0 fails —exactly like the integration one—. A passing lesson: the canonical fake matches the real one at this point, so with it the unit test would already have warned. The lie only appears with the careless fake. A good fake is a good simulator; the problem is that nothing guarantees the fake is good.
  • (b) Passes (green), and is faithful. With an id that exists, get returns the booking in both repos (the dict's .get() finds the key, like [...]), the guard if booking is None isn't met, and cancel follows its normal course computing the refund. Here the careless fake and the real one match —the divergence only lives in the missing-id case—, so the green is honest.
  • (c) Passes (green), and is faithful. The real one finds the row, returns it, and cancel proceeds. The happy path works the same with the fake and with the real one; that's why the divergence hides: in 99% of cases (ids that exist) everything matches, and only the rare case (missing id) reveals the crack.

The pattern that emerges: the divergence isn't everywhere, it's at an edge —the id that doesn't exist—. Edges are exactly the cases the happy path doesn't exercise, and that's why divergences hide there. Testing the edges against the real piece is where integration yields most.

Exercise 2 — The contract that would have caught it. Without implementing it yet (that's module 3), write in one sentence the behavior agreement that, verified against the careless fake, would have gone red before the deploy. Then say which line of the careless fake would have to change to fulfill it.

See solution

The agreement: "get(id) of an id that wasn't saved must raise (KeyError), not return None." A contract is, precisely, a battery of tests that asserts agreements like this one and runs them against all implementations of BookingRepository. Against the real SqliteBookingRepository, this agreement passes (the real one raises). Against the BuggyFakeBookingRepository, this agreement fails —the fake returns None—, and that red appears on the developer's machine, in the contract's suite, without needing to touch production. The divergence stops being invisible: it becomes a red test with a name.

The line that would have to change in the careless fake is a single one: return self._store.get(booking_id) must become return self._store[booking_id]. The .get() (returns None) becomes [...] (raises KeyError). With that change, the fake honors the contract, the idempotent cancel fails also against the fake (warning in a fast unit test), and the developer finds out that their guard assumes something false before writing the whole feature. The contract turns a production bug into a local red test.

Exercise 3 — Fix the system, not the symptom. The team decides that idempotent cancellation is a good feature and wants to keep it: canceling a nonexistent id must return 0, not blow up. But the real SqliteBookingRepository raises KeyError. Describe the correct fix —one that respects the real contract— and explain why it's better than making the repository return None.

See solution

The correct fix lives in the code under test, not in the repository, and consists of handling the exception the real contract promises, instead of assuming a None the contract doesn't give:

def cancel(self, booking_id) -> int:
    try:
        booking = self._repo.get(booking_id)
    except KeyError:
        return 0                       # canceling something nonexistent: refund 0
    ...

Now cancel is idempotent while respecting the repository's contract ("get of a missing id raises"), instead of contradicting it. This cancel passes both with the canonical fake (which raises) and with the real SqliteBookingRepository (which raises), because both fulfill the same contract and the code is written for that contract.

Why is it better than making the repository return None? Because changing the repository so get returns None instead of raising weakens the contract for all its users, not just for cancel. Other clients of the repository that today rely on get raising —to distinguish "doesn't exist" from "exists but is None", or to fail early and clearly— would break silently: they'd receive an unexpected None that would travel into their logic and blow up far away, with a confusing AttributeError (exactly the kind of late bug the doubles guide already described). A get that raises is a stronger and more honest contract: it says "doesn't exist" unambiguously and early. The idempotent feature must handle that contract, not repeal it for everyone.

Summary and next step

In this lesson you saw the double's lie run from start to finish. A developer wrote a reasonable idempotent cancellation, assuming —because their careless fake confirmed it— that get returns None for a missing id. The unit test passed green. The same scenario, against the real SqliteBookingRepository that raises KeyError, failed red, and the traceback showed the guard if booking is None as dead code that's never reached. And you understood the general anatomy of the lying green: a test with a double doesn't prove a property of the system, but a property conditioned on the double matching the real thing —and when the condition fails, the green lies without changing color—.

Before moving on you should be able to: explain why the guard never runs against the real repo; restate what the green unit test actually claimed (with its hidden condition); and propose the fix that respects the real contract (try/except KeyError) instead of repealing it.

This was a behavior divergence: nothing changed type, only the actions differed. Lesson 4 opens two different families, equally treacherous and also run: the divergence of types —the datetime the fake returns intact and real SQLite returns as str, blowing up the code that formats it— and the one of order —the fake that promises insertion order and the real one that, with a production index, returns a different one—. Two new ways for the same green to lie.

Resources