Module 2: The Double That Lied

1. Module introduction: the double that lies

Description

Module 1 ended with a sentence that sounds almost like a paradox: a green test can hide a broken system. You saw it once, with the datetime that the fake returns intact and the real SqliteBookingRepository returns as text. It was a glimpse. This module is the full investigation. We're going to take that crack —the one between what your double assumes and what the real piece does— and open it, measure it, and run it until you understand exactly how it's produced, in how many different forms, why your unit suite is unable to detect it, and what it costs when it reaches production without anyone having seen it.

The problem has a simple root, and it's worth saying plainly from the start. A double is not the real piece: it's an assumption about how the real piece behaves, written by hand by you. When you wrote FakeBookingRepository, you decided what save does, what get returns, what raises and what doesn't. Each of those decisions is a bet about how the real SqliteBookingRepository behaves. If you get it right, the double is a faithful mirror and your unit test tells the truth. If you get even one of those bets wrong, the double lies, and —this is the serious part— it lies in green. The test that uses the double can't contradict the assumption, because the double is the assumption. You ask the double whether your code works, the double answers yes, and the two agree on the same mistaken belief. Green. Deploy. Broken.

Connection to the module: this lesson is the statement of the problem the guide's two disciplines exist to solve. There's no solution yet here —the contract arrives in module 3—; there's diagnosis. You'll meet the map of the six lessons that follow: why doubles diverge (lesson 2), the central get→None vs get→raises divergence run end to end (lesson 3), the divergences of types and order (lesson 4), those of uniqueness and transactions (lesson 5), the structural reason the unit test doesn't see it (lesson 6), and the cost in production (lesson 7), before the mini-project that has you hunt down a divergence yourself (lesson 8). If module 1 gave you the why of integration, this one gives you the exact problem that integration and the contract solve. Without understanding this lie well, the solution of the following modules would seem like bureaucracy to you. With it understood, it will seem obvious.

Analogy: the flight simulator

A pilot doesn't learn to fly by crashing planes. They learn in a flight simulator: a cockpit identical to the real one, with the same levers, the same screens, and a physics model that mimics how the plane responds. The simulator is an extraordinary tool —cheap, safe, repeatable—: you can practice a dead-engine landing a hundred times without putting anyone at risk. It is, in every sense, a double of the real plane. And airlines trust it so much that they certify pilots with simulator hours. It sounds perfect, and almost always it is.

But the simulator has a blind spot that is exactly a software double's: it's only as good as the assumption it was programmed with. Someone had to model how the plane responds, and that model is an assumption about the real plane. If the simulator's model assumes that a certain sensor never freezes, or that the engine reacts a certain way, and the real plane does something else, then the pilot has trained to perfection for a plane that doesn't exist. They passed every exam in the simulator —green, green, green— because the simulator confirms its own model. The crack between the model and the real plane doesn't appear in any simulator exam; it appears at ten thousand meters, with passengers aboard, when the plane does what the simulator never taught it would do. There have been real accidents from exactly this: a simulator that modeled a behavior different from the real plane's.

The FakeBookingRepository is your simulator; the SqliteBookingRepository is the real plane. Your unit test is the simulator exam: fast, safe, repeatable, and absolutely reliable as long as the simulator models the plane well. The day the fake assumes that get returns None and the real one raises, you trained your code for a repository that doesn't exist. All the simulator exams come out green. The crack only appears "at ten thousand meters" —in production, with the real piece connected—. The whole guide is, at bottom, learning not to trust the simulator blindly: verifying that its model matches the plane (that's the contract, module 3) and, every so often, flying the real plane (that's integration, module 5).

A first look at the lie

In module 1 the divergence was one of shape: a datetime that changes type when crossing the seam. Here we're going to look at a divergence of behavior, which is even more treacherous because it changes no data: it changes what the piece does when you ask it for something it doesn't have.

The repository's implicit contract, the one we noted in module 1, says: "get of a missing id raises". The real SqliteBookingRepository fulfills it —if it doesn't find the row, it does raise KeyError—. But nothing forces the fake to fulfill it. Imagine a colleague, writing their fake in a hurry, used the dictionary's .get() method instead of bracket indexing:

# 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]

A single character different from the canonical fake: self._store.get(booking_id) instead of self._store[booking_id]. And yet the behavior diverges in the most important case: dict.get(missing_key) returns None silently, while dict[missing_key] raises KeyError. That .get() seemed reasonable —Python has the method, it looks clean— and it just broke the contract without anyone noticing. Now a developer writes a new feature —canceling is idempotent: canceling something that no longer exists isn't an error, it simply refunds 0— and writes it assuming the behavior they see in their fake:

# reservo/idempotent.py — cancel assuming get returns None
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
    ...

The if booking is None is a perfectly sensible guard... if get returned None. With the careless fake, it does, and everything works. With the real SqliteBookingRepository, which raises, that get never returns None: it blows up before reaching the if line. The guard is dead code against the real piece.

What to expect: the lie, at a glance

Let's see the gap with our own eyes —a preview; the line-by-line breakdown is lesson 3—. Two tests that assert exactly the same thing (canceling a nonexistent id returns 0), and that differ only in which repository they receive: the careless fake (unit) or the real SqliteBookingRepository (integration). On my machine (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_none_vs_raise.py -v
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%]
...
reservo/idempotent.py:19: in cancel
    booking = self._repo.get(booking_id)
...
E           KeyError: 'bk-does-not-exist'

reservo/sqlite_repo.py:50: KeyError
========================= 1 failed, 1 passed in 0.05s ==========================

There's the lie, plain. The unit test with the fake passes: the guard received its None and returned 0. The integration one with the real one fails: the get raised KeyError on the first line of cancel, and the guard never ran. The same scenario, a green and a red, depending on which piece is plugged in. Keep the anatomy of the lie: the code was written correctly for the fake, the unit test passes green, and the real piece exposes it in red. The double didn't make a programming error; it played its role as a mirror. The problem is that it reflected a false assumption.

The module's map

This module has a single thesis —doubles diverge, and the divergence lies in green— and attacks it from six angles, each with its real execution:

LessonAngleThe idea in one sentence
2Why they divergeA double is written by hand, falls behind, and oversimplifies: diverging is its natural tendency
3get→None vs get→raisesThe behavior divergence, run: fake green, SQLite red, same scenario
4Types and orderThe datetime that comes back as str; the order the fake promises and the engine doesn't guarantee
5Uniqueness and transactionsThe IntegrityError the fake doesn't raise; the rollback the fake can't do
6Why the unit test doesn't see itThe double is both the subject and the oracle: asking it whether it's right always gives yes
7The cost in productionLate detection, wide blast radius, confusing debugging: the bill the business pays

And lesson 8 puts you on the other side: we give you a feature with a hidden divergence and you write the unit test that stays green, the integration one that goes red, and the diagnosis of why. By the end, you won't just know that doubles lie: you'll know how to smell it, reproduce it, and explain it, which is the condition for appreciating the contract when it arrives in module 3.

What this module is NOT (the border with the doubles guide)

An honest warning is in order, because there's an apparent overlap. In the sister guide test-doubles-and-test-data-guide there's already a lesson titled, more or less, "a fake can diverge from the real thing". If you read it, the basic idea isn't new to you: a poorly written fake returns None where it should raise, and that masks a bug. Here we do not repeat that lesson; we use it as a starting point. The difference of focus is the one that defines this guide:

  • There, the divergence was an anecdote within a module about how to build good fakes: the moral was "write your fake carefully so it honors the contract".
  • Here, the divergence is the central problem that motivates an entire discipline. The moral isn't "be careful" —being careful doesn't scale, people make mistakes—, but "don't trust care: verify that the fake and the real match with a shared battery of tests (the contract) and test every so often against the real thing (integration)". That leap from "be careful" to "verify systematically" is exactly what separates this guide from the doubles guide.

If "build a fake" or "inject a collaborator" sound shaky, that review is the doubles guide. Here we assume you know how to double; the work is to stop believing the double on faith.

Common mistakes

Believing "the fake honors the contract" is a solved problem because you wrote it carefully. What happens: you wrote your FakeBookingRepository with self._store[booking_id] so it raises, you feel you did your job well, and you consider the matter closed. Why it happens: a fake that's correct today feels permanent. How to detect it: ask yourself who guarantees the fake still matches the real one in six months, when the real one gains a column, a constraint, or a type change, and the fake stays put. Nobody guarantees it, except a test that compares the two. How to fix it: individual care neither scales nor survives time; what survives is an automated verification that both sides match. That's the contract, and that's why module 3 exists.

Confusing "my fake has the same methods as the real one" with "my fake behaves like the real one". What happens: someone sees that the fake and the real one both have save, get, and find_by_room, and concludes they're interchangeable. Why it happens: that two pieces fit in the same seam (have the methods) feels like they behave the same. How to detect it: it's exactly the get→None vs get→raises divergence: both have get, both fit, and they do different things when the id doesn't exist. How to fix it: the interface (the method names) is the shape of the contract; the behavior of those methods is its content, and that's what has to be verified. Fitting isn't fulfilling.

Thinking this problem only happens to careless fakes. What happens: someone reads that a "poorly written" fake diverged and concludes "it doesn't happen to me because I write well". Why it happens: the word "careless" invites the belief that the divergence is a discipline error. How to detect it: module 1's datetime divergence didn't come from a careless fake —the fake was impeccable—; it came from SQLite serializing and the dict not. That chasm is closed by no amount of care, because the fake and the real one are different technologies. How to fix it: accept that the divergence is the natural tendency of every double, careful or not (it's lesson 2's topic), and treat it with tools, not with good intentions.

Exercises

Exercise 1 — Does it lie in green or in red? For each situation, say whether the unit test that uses the fake would pass (green) or fail (red), and whether that answer is faithful to what the real system would do: (a) the fake get returns None for a missing id, the real one raises, and the code under test does if booking is None: return 0; (b) the fake get raises KeyError for a missing id, the real one also raises, and the code doesn't handle the exception; (c) the fake save accepts two different bookings in the same room and time, the real one has a UNIQUE(room_id, start) that rejects it.

See solution
  • (a) Green, and NOT faithful. With the fake that returns None, the if booking is None: return 0 is met and the test passes. But the real one raises, so in production that code never reaches the if —it blows up first—. The green is a lie: it claims a behavior (idempotent cancellation) that the real system doesn't have. It's exactly lesson 3's divergence.
  • (b) Red, and YES faithful. With the fake that raises (the canonical one), the code that doesn't handle the exception also fails in the test, just as it would fail in production with the real one that raises. Here the fake and the real one match, so the test's red is an honest warning: there's an error-handling bug, and you'd see it both with the fake and with the real one. There's no lie; there's a test doing its job well.
  • (c) Green, and NOT faithful. The fake, a dict, doesn't model the uniqueness constraint, so it accepts the two bookings and the test passes. The real one rejects them with IntegrityError. The green hides a business rule (no double bookings) that the fake is unable to express. It's lesson 5's uniqueness divergence.

The pattern: a green test is only information when the fake matches the real one at the point the test exercises. When they diverge, the green doesn't say "the system works"; it says "the fake agrees with itself".

Exercise 2 — The character that changed everything. The careless fake differs from the canonical one by a single character: self._store.get(booking_id) instead of self._store[booking_id]. Explain, in Python terms, why that minimal change produces a behavior divergence, and why the fake's author almost never notices it while writing it.

See solution

In Python, dict[key] and dict.get(key) behave the same when the key exists —both return the value— but diverge when it doesn't: dict[missing_key] raises KeyError, while dict.get(missing_key) returns None silently (that's precisely .get()'s purpose: a read that doesn't fail). The canonical fake uses [...], which raises, and thus mimics the real SqliteBookingRepository's raise KeyError. The careless fake uses .get(), which returns None, and breaks the contract precisely in the missing-id case.

The author almost never notices because, while writing and testing their fake, they use ids that do exist —they save a booking and read it back—, and on that happy path [...] and .get() are identical. The missing-id case is a path rarely exercised when building the double; it's exercised in production, when an id that isn't there arrives. The divergence lives in the edge the author didn't visit, and that's why it goes unnoticed until the real piece reveals it.

Exercise 3 — Translate the analogy. The flight simulator is a double of the plane. Match each element of the analogy with its equivalent in Reservo and explain the match in one sentence: (a) the simulator's physics model; (b) passing all the simulator exams; (c) the real plane's behavior at ten thousand meters; (d) the certification that assumes the simulator matches the plane.

See solution
  • (a) The physics model ↔ the FakeBookingRepository's code. Both are an assumption, written by hand, about how the real thing behaves. The model assumes how the plane responds; the fake assumes what the repository does. The double's quality is the quality of that assumption.
  • (b) Passing all the simulator exams ↔ a unit suite all green. Both are confidence built on the double. They come out green because the double confirms its own model, not because the real system is healthy.
  • (c) The real plane at ten thousand meters ↔ the SqliteBookingRepository in production. It's where the assumption is put to the test against reality, and where a divergence —that no simulator exam showed— finally appears, with real consequences.
  • (d) The certification that assumes a match ↔ deploying while trusting the green suite. Both are the act of risk: taking the real system as good based on the double passing. The certification is safe only if someone verified that the simulator matches the plane; the deploy is safe only if someone verified that the fake matches the real one. That verification is the contract (module 3) and integration (module 5).

The moral of the analogy: nobody proposes throwing out the simulator —it's too useful—. What's proposed is verifying that its model matches the plane, and complementing it with real flight. Same with doubles: they're not thrown out; they're verified and complemented.

Summary and next step

In this lesson you stated the problem that structures the entire guide. A double is an assumption about the real thing, written by hand; and when the assumption fails, the unit test that uses the double doesn't fail with it, but instead confirms the mistaken belief and passes green. You saw it with the flight simulator —an excellent double and dangerous precisely because of how good it is— and with a first look at the central divergence: a single character, .get() instead of [...], turns a reasonable fake into a liar that returns None where the real one raises. And you marked the border with the doubles guide: here we don't learn to build fakes carefully, we learn to stop trusting care and to verify.

Before moving on you should be able to: state why a double can pass green while the real system is broken; distinguish "the fake has the real one's methods" from "the fake behaves like the real one"; and explain why this problem isn't solved by writing doubles more carefully.

We said the divergence is the natural tendency of every double, not an accident of the careless. It's a strong claim and it needs grounding. Lesson 2 does it: it breaks down the three underlying reasons a double separates from the real thing —it's written by hand, it falls behind, and it oversimplifies— so you understand that the right question isn't whether your double will diverge, but when and where.

Resources

  • pytest documentation — Getting Started — the official gateway to pytest, the tool we run and cite every output in the module with; useful to reconfirm your environment (Python 3.14, pytest 9.1.1) before starting.
  • sqlite3 — DB-API for SQLite (Python documentation) — the reference for the stdlib module that plays "the real plane" throughout the guide; its way of handling types and constraints is the source of several divergences we'll run.
  • dict.get and access by key (Python documentation) — the exact detail behind the None vs KeyError divergence: d.get(k) returns None for a missing key, d[k] raises. A one-character difference, a broken contract.
  • test-doubles-and-test-data-guide — the sister guide where you built the FakeBookingRepository and where it was already mentioned that a fake can diverge; this module takes that idea and carries it all the way to motivating an entire discipline.