Module 2: The Double That Lied
2. Why doubles drift
Description
In the previous lesson I made a strong claim and left it unfounded: the divergence between a double and the real thing isn't a rare accident that happens to the careless, but the natural tendency of every double. It's time to back it up. If diverging really were an accident, the solution would be obvious and cheap: "be more careful", a code review, a style rule. And it isn't. The divergence has three underlying causes, and all three are structural —they live in the very nature of what a double is—, not in the discipline of whoever writes it. Understanding them is what turns "verify your doubles" from a moralistic piece of advice into an engineering necessity.
The three causes are these, and we're going to take them apart one by one. First: a double is written by hand, so its behavior is what you typed, not what the real piece does —nothing, no language mechanism, forces them to match—. Second: a double falls behind, because when the real piece changes (gains a column, a constraint, a type change), the double doesn't change with it; nobody keeps them in sync, and time separates them. Third: a double oversimplifies, because its whole point is to be simpler than the real thing —a dict instead of a database—, and that simplicity means, by definition, leaving out things that in the real thing do matter. All three push in the same direction: separating the double from the real thing. The right question, then, isn't whether your double will diverge, but when and where.
Connection to the module: this lesson explains the cause of everything the rest of the module shows. Lessons 3, 4, and 5 exhibit concrete divergences —of behavior, of types, of order, of uniqueness, of transactions—; this one gives you the frame to understand why each one was inevitable. When in lesson 3 you see the get→None vs get→raises, you'll recognize the "written by hand" cause. When in lesson 4 you see the datetime come back as str, you'll recognize "oversimplifies". And when you think about how to prevent all this, you'll understand why individual care isn't enough and the module 3 contract is needed: because the three causes are permanent forces, and against a permanent force a one-off act of will is useless, but a mechanism that counteracts it always works.
Analogy: the map drawn from memory
Imagine a friend is going to visit you in a neighborhood they barely know, and instead of giving them the official map, you draw one by hand on a napkin: "exit the subway, two blocks straight, turn at the bakery, my building is the blue one". That drawing is a double of the neighborhood: simpler, faster to use, enough for the route you had in mind. And it's going to diverge from the real neighborhood for exactly three reasons. First, you drew it from memory: if you got it wrong and it was the third block, not the second, the error is on the paper from minute zero, and nothing on the napkin warns you —the paper doesn't know what the neighborhood is like, it only knows what your hand drew—. Second, the neighborhood changes and your napkin doesn't: the bakery closes, they add a one-way street, and your map keeps saying "turn at the bakery" months later, more and more outdated. Third, you left out almost everything: you didn't draw the other shops, or the slopes, or the alternate subway exit, because your map only served your route; the day your friend comes out of the other exit, your napkin has nothing to tell them.
The FakeBookingRepository is that napkin. You wrote it from memory (your assumption of how the repository works), it doesn't change when the real repository changes, and it leaves out everything you didn't need for the happy path you had in mind. It's useful for the same reasons the napkin is useful —fast, simple, enough for what was foreseen— and it diverges for the same three reasons. The lesson isn't "make a perfect map on the napkin" (impossible: it would stop being a napkin), but "when arriving really matters, contrast your napkin with the real map". Contrasting the napkin with the official map is the contract; walking the neighborhood for real is integration.
Cause one: it's written by hand, and nothing forces it to match
Let's start with the most basic, because it's the one people most underestimate. In Python, a double is an object that has the methods the seam expects. That's all the language demands. BookingService calls repo.get(id); any object with a get method that accepts one argument fits. Python doesn't compare your get with the real one's get, doesn't verify they return the same type, doesn't check they raise the same exceptions. The match between the double and the real one is your responsibility, and yours alone, held up by nothing more than your attention at the moment of typing.
Let's see it in its crudest form, without a service in between, directly at the repository level. I write two tests that assert opposite behaviors for the same get method of the same interface: one says it returns None for a missing id, the other says it raises. And both pass.
# tests/test_raw_divergence.py
import sqlite3
import pytest
from reservo.doubles import BuggyFakeBookingRepository
from reservo.sqlite_repo import SqliteBookingRepository
def test_fake_get_missing_returns_none():
repo = BuggyFakeBookingRepository()
assert repo.get("nope") is None # the fake: returns None
def test_sqlite_get_missing_raises():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
with pytest.raises(KeyError):
repo.get("nope") # the real one: raises KeyError
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_raw_divergence.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
tests/test_raw_divergence.py::test_fake_get_missing_returns_none PASSED [ 50%]
tests/test_raw_divergence.py::test_sqlite_get_missing_raises PASSED [100%]
============================== 2 passed in 0.02s ===============================
Pause on what you just saw, because it's stranger than it seems at first glance. Two green tests, both honest —each correctly describes what its repository does—, and yet they assert the opposite about the same get method of the same BookingRepository interface. One says "returns None", the other says "raises KeyError". Both green. How can that be? Because nothing forces the two implementations of an interface to match. Python lets them coexist happily, each with its own behavior, and it will only find out they disagree the day the same code depends on one and runs against the other. The BookingRepository interface is an agreement of names (save, get, find_by_room), not of behavior. The behavior you put in by hand, in two different places, and what your hand wrote in the fake need not correspond with what the stdlib's sqlite3 does inside. That's cause one, bare: the double says what you typed, not what the real one does.
Cause two: it falls behind when the real one changes
Cause one explains how a double is born divergent. Cause two explains how a double that was born faithful becomes divergent over time, without anyone touching it. It's the most insidious, because it requires no error: it requires only that time pass and the system evolve, which is what systems do.
Think of a seam's lifeline. On day one, you write the FakeBookingRepository and the SqliteBookingRepository, and you make them match carefully: both get raise, both save and read the same. Faithful. Three months pass. The business asks that no room be booked twice at the same time, and a colleague adds a UNIQUE(room_id, start) to the SqliteBookingRepository's schema. It's a correct change, well done, with its own integration test. But the FakeBookingRepository —which lives in another file, which nobody associated with this change— didn't find out. It's still a dict that accepts any booking. In that instant, without anyone writing a single line of bad code, the fake and the real one stopped matching: the real one now rejects double bookings, the fake accepts them. The divergence wasn't caused by carelessness; it was caused by the normal and healthy fact that software changes, and that a hand-written double in a separate file doesn't change with it.
This is the mechanism that makes the divergence a permanent problem and not one that's fixed once. Every time the real piece gains a column, a constraint, a validation, a type change, a new edge case, a crack opens with its double, unless someone remembers to update both places at once. And "always remembering" isn't a strategy; it's a hope. The number of doubles grows, the team rotates, whoever wrote the fake is gone, and the distance between the double and the real one quietly widens release after release. A green test with that outdated fake doesn't prove the system works: it proves the system would work if the repository were still like it was three months ago. Cause two is time working against you.
Cause three: it oversimplifies
The third cause is, in a way, the most honest of the three, because it isn't a defect of the double: it's its purpose. A double exists to be simpler than the real piece. The FakeBookingRepository is a dict precisely because you don't want the weight of a database in your unit tests: no connections, no SQL, no disk, no serialization. That simplicity is what makes it fast and deterministic, and it's the reason you use it. But simplifying is, by definition, leaving out. And each thing you leave out is a place where the double can diverge from the real thing, because the real thing didn't leave it out.
Look at everything an in-memory dict leaves out compared to the real SqliteBookingRepository, and notice that each omission is both sensible and dangerous:
- Serialization. The
dictstores theBookingobject as-is, with itsdatetimeintact. SQLite can't: it has to convert thedatetimeto text to store it and return it as text when reading it. Thedictleaves out the whole round-trip through a table's columns —and with it, lesson 4's type divergence—. - Constraints. The
dicthas no notion of business unique keys, foreign keys,NOT NULLcolumns. It accepts whatever you give it. SQLite enforces every schema rule —and there lives lesson 5's uniqueness divergence—. - Transactions. The
dictmutates immediately and forever; it doesn't know what a commit, a rollback, or atomicity is. SQLite wraps the writes in transactions that can be undone —and there lives lesson 5's transaction divergence—. - Order. The
dictpreserves insertion order deterministically. SQLite promises no order without anORDER BY, and with an index in the mix it returns a different one —and there lives lesson 4's order divergence—.
Each of those omissions was a good design decision for the double —including them would make it as heavy as the real thing, and then why double it—. But each is a promise the double can't make because it doesn't model the machinery that holds it up. The double doesn't lie about these things out of carelessness: it lies because to be simple it had to leave them out, and what you don't model you can't verify. Cause three is the double's paradox: it's useful because it simplifies, and it diverges because it simplifies. There's no way to have one without the other.
The three together: why care isn't enough
Put the three together and you'll see why "write your doubles carefully" is a true and insufficient piece of advice. Care attacks, at most, cause one: if you're meticulous when typing, you can make your fake be born faithful. But care does nothing against cause two —you can't, by will, prevent the system from changing and leaving your fake behind— or against cause three —you can't, by will, make a dict model transactions without ceasing to be a dict—. Two of the three forces are immune to your discipline. That's why the solution can't be human ("remember", "be careful"): it has to be mechanical. A mechanism that, automatically and every time, verifies that the double and the real one still match in the behavior you care about, and that goes red the day they stop matching —for whatever cause—. That mechanism is the contract: a battery of tests run against the fake and against the real one, demanding that both pass. It's the topic of module 3, and now you know exactly what problem it solves and why no amount of good intentions solves it in its place.
Common mistakes
Treating the divergence as a one-off bug instead of a permanent force. What happens: someone finds a divergence, fixes that fake, and considers the problem closed. Why it happens: it feels like a bug —you found it, you fixed it, it disappeared—. How to detect it: ask yourself what prevents another divergence from appearing the next time the real one changes. If the answer is "that I remember to update the fake", you closed nothing; you postponed the next case. How to fix it: treat the divergence like corrosion, not like a loose nail: you don't fix it once, you set up a mechanism that detects it always. That mechanism is the contract.
Believing a more complex (more "faithful") fake is the solution. What happens: burned by a divergence, someone fattens the fake so it models constraints, order, maybe transactions, bringing it closer and closer to the real one. Why it happens: if diverging comes from simplifying, it seems that stopping simplifying cures it. How to detect it: if your fake starts having uniqueness logic, serialization, and ordering, it's no longer a simple, fast double: it's a reimplementation of the database, with its own bugs, and slower to maintain. How to fix it: don't compete with the real thing by making the fake fatter; that only moves the bugs around. Keep the fake simple for what it's good at (speed in the unit tests) and verify its fidelity with a contract instead of trying to eliminate the simplification that makes it useful.
Confusing "the fake passed my tests" with "the fake matches the real one". What happens: someone tests their fake, sees it behaves as expected, and concludes it's faithful. Why it happens: a fake that passes its own tests feels validated. How to detect it: your tests of the fake were written with the same assumption you wrote the fake with; they confirm your mental model, not the match with the real one. It's the circular oracle problem that lesson 6 takes apart. How to fix it: the only proof that the fake matches the real one is running the same battery against both and demanding that both pass. A fake validated only against itself is validated against nothing.
Exercises
Exercise 1 — Classify the cause. For each divergence, say which of the three causes (written by hand / falls behind / oversimplifies) is the dominant one, and justify it: (a) the fake get returns None because the author used .get() without thinking; (b) the real one gained a cancelled_at column last month and the fake still doesn't know it; (c) the fake accepts a negative price_cents that the real one rejects with a CHECK (price_cents >= 0).
See solution
- (a) Written by hand. The divergence was in the fake from day one, because of what the hand typed (
.get()instead of[...]). There was no change in the real one that caused it, nor a structural simplification: it was a code choice, made by hand, that didn't match the real one's behavior. Pure cause one. - (b) Falls behind. Here the fake was born faithful and time separated them: the real one changed (new column), the fake wasn't updated, and the crack opened without anyone writing bad code. It's cause two's mechanism in its pure state —the divergence was produced by the real one's evolution, not by carelessness in the fake—.
- (c) Oversimplifies. The
dictdoesn't model domain constraints like aCHECK; accepting any value is part of its simplicity. The real one, which does model the constraint machinery, rejects the negative value. The divergence is born from the double leaving out, by design, something the real one enforces. Cause three.
Note that all three produce the same symptom —fake and real disagree— but demand fundamentally different answers. Only (a) would have been avoided with more care when typing; (b) and (c) wouldn't. That's why the contract, which catches all three regardless of the cause, is the general answer.
Exercise 2 — The aging napkin. Apply the drawn-from-memory map analogy to cause two. Describe a concrete change in Reservo's SqliteBookingRepository that would leave the napkin (the fake) "behind", and explain why the author of the change probably wouldn't touch the fake.
See solution
A concrete change: the team decides that find_by_room must return the bookings ordered by start (the upcoming ones first), and adds an ORDER BY start to the SqliteBookingRepository's query, plus an index on (room_id, start) to make it fast. It's a legitimate improvement, with its test.
The author of that change probably doesn't touch the FakeBookingRepository for two very human reasons. First: the fake lives in another file (reservo/doubles.py), not in reservo/sqlite_repo.py; the change is mentally localized to "the SQL query", and the fake doesn't appear on their radar. Second: the fake, being a dict, already returned things in insertion order, which almost always coincides with the order by start in the test data —so no test of the fake goes red, and there's no signal that pushes anyone to look at it—. The napkin keeps saying "the bookings come out in the order you put them in", the real neighborhood now says "they come out ordered by time", and nobody confronted them. The divergence stays latent until a piece of test data with insertion order different from chronological —or production— wakes it up. (It's exactly the case we run in lesson 4.)
Exercise 3 — Fatten the fake or verify it? A colleague, fed up with divergences, proposes rewriting FakeBookingRepository so it models the uniqueness constraint, the order by start, and even a homemade rollback, "so the fake behaves like the real one and they diverge no more". Evaluate the proposal: what does it gain, what does it lose, and what would be better?
See solution
What it gains: on paper, a fake that models more resembles the real one more, so some of today's divergences disappear. The uniqueness one, the order one: if the fake implements them well, they'll match the real one at those points.
What it loses, and it's more than it gains: (1) Speed and simplicity, which were the fake's reason for existing. A fake with constraint, ordering, and transaction logic is no longer a five-line dict; it's a mini database that has to be read, understood, and maintained. (2) Correctness: that reimplementation has its own bugs, and now you have two complex implementations that can diverge from each other in new ways —the fake could implement the homemade rollback wrong, and your tests would trust a rollback that doesn't work—. (3) It doesn't close cause two: however faithful you make the fake today, the day the real one changes, the fat fake falls just as far behind as the thin one —and now it's more work to update—.
What would be better: keep the fake deliberately simple (that's its virtue) and set up a contract: a battery of behavior tests run against the fake and against the real one, that goes red the instant they disagree. That way you don't chase fidelity by reimplementing the database —an infinite and counterproductive task—, but you measure fidelity and find out when it breaks. The fake stays thin and honest; the contract does the surveillance work. That is, precisely, module 3's solution.
Summary and next step
In this lesson you grounded the claim you had pending: doubles diverge by nature, not by carelessness. You took apart the three causes —written by hand (nothing forces it to match the real thing), falls behind (the real one changes and the double doesn't), oversimplifies (leaves out, by design, what the real one enforces)— and you saw, with two green tests asserting opposite behaviors, just how far Python lets a disagreeing fake and real coexist without saying a word. And you understood the practical consequence: since two of the three forces are immune to your discipline, the solution can't be human care, but an automatic mechanism that verifies the match always.
Before moving on you should be able to: name the three causes of the divergence and give a Reservo example for each; explain why "write your doubles carefully" attacks only one of the three; and argue why fattening the fake isn't the solution.
You now know why they diverge. It's time to see a divergence in action, complete, end to end and run. Lesson 3 takes the cleanest behavior divergence —the fake returns None, the real one raises— and carries it to its ultimate consequences: the same scenario, the same code under test, passing green with the fake and blowing up red with the real SqliteBookingRepository. It's the double's lie, exposed with pytest output side by side.
Resources
- pytest documentation — How to write and report assertions — how pytest reports an
assertand apytest.raises, the two ways with which in this lesson we assert opposite behaviors (returnsNone/ raises) over the same interface. sqlite3— DB-API for SQLite (Python documentation) — the reference for the "real one" whose capabilities (serialization, constraints, transactions, order) the fake'sdictleaves out; reading what SQLite does is reading the list of what your fake doesn't model.- Martin Fowler — TestDouble — the canonical vocabulary of doubles and a clear description of why a double is a deliberate simplification of the real thing; the conceptual backdrop of cause three.
test-doubles-and-test-data-guide— the sister guide where doubles are built carefully; this lesson explains why that care, while necessary, isn't enough against causes two and three.