Module 2: The Double That Lied
4. Divergence of types and order
Description
The previous lesson showed a divergence of behavior: the fake and the real one differ in what they do when you ask them for something missing. This lesson opens two more families, and both are of a different and subtler nature, because what the piece does doesn't change: what changes is the shape of the data it returns. The real piece, to be able to store your objects in a table and read them back, has to transform them —convert a datetime to text so it fits in a column, decide what order it delivers the rows in—. The fake, which stores your objects as-is in a dict, transforms nothing. That difference between "transform" and "don't transform" is an inexhaustible source of divergences, and here you're going to run two of the most common ones.
The first is the divergence of types. When a piece of data crosses the seam into the database and comes back, it can return with a different Python type than it had when it went in. The star case, which already peeked out in module 1: a datetime goes in as a datetime and comes back as a str, because SQLite has no native type for dates and it had to be serialized to text. The fake, which never serializes, returns the datetime intact. The second is the divergence of order. When you ask for a list of bookings, in what order do they arrive? The fake, a dict, returns them in insertion order, deterministically. The real database promises no order without an explicit ORDER BY, and with an index in the mix it delivers them in index order —which may not be the one your fake accustomed you to seeing—. Both families share the same trap: the fake gives you a guarantee the real one never promised, and your code learns to depend on it.
Connection to the module: this lesson expands the catalog of divergences the module collects. Lesson 3 covered behavior; this one covers shape (types) and order; lesson 5 will cover constraints and transactions. All three answer lesson 2's "oversimplifies" cause: the fake, being a dict, leaves out serialization (hence the type divergence) and leaves out a real engine's query machinery (hence the order divergence). And all three lead to the same moral that prepares module 3: since the fake leaves out by design what the real one does, trusting it isn't enough; you have to verify the match at every point that matters. Here you'll see two more points where that verification would have caught the bug.
Analogy: the customs that repacks your luggage
Think of two ways to send a box to a friend in another country. The first: you hand it to them, sealed, and they open it exactly as you packed it —each thing in its place, in the order you put it—. The second: you send it through international customs. There the box is opened, each object is logged on a form, some are repacked differently to comply with the rules —the liquid goes into another container, the fragile stuff is wrapped differently—, and on the other side your friend receives a box that contains the same things but repacked: the watch you sent may arrive in a bag labeled "wrist accessory, 1 unit", and things may come in the form's order, not the one you put them in.
The FakeBookingRepository is handing it over in person: the objects come back identical, in their type and their order. The SqliteBookingRepository is customs: to store your bookings it "repacks" them into a table's rows, and when returning them it reconstructs them from that form. The datetime you sent comes back as a str because customs only knew how to log it as text; the bookings come back in the "form's" order (the index), not the one you saved them in. If you wrote your code testing it only with the friend who receives in person, you learned to expect identical objects in identical order —and the day you send through customs, your code finds a labeled watch and a changed order, and doesn't know what to do with them—. The two divergences in this lesson are two ways customs repacks what the in-person friend used to return intact.
Divergence of types: the datetime that comes back as text
Let's start with types, with a realistic scenario: a fragment of code that renders a booking's start time to show on screen. It's typical presentation code —take the booking, format its start as "09:00"— and to format a time, the natural thing is to use .strftime(), the method of datetime objects:
# "screen" code that renders the start time
def render_start(booking):
return booking.start.strftime("%H:%M") # assumes datetime
This code is correct if booking.start is a datetime. With the FakeBookingRepository, it is —the fake stores and returns the object as-is—. With the real SqliteBookingRepository, booking.start comes back as a str, and strs have no .strftime(). Here are the two tests, symmetric again: the same booking, saved and read, rendered; one with the fake, one with the real one.
# tests/test_types_and_order.py (types part)
def test_render_start_with_fake():
repo = FakeBookingRepository()
repo.save(a_booking("bk-1", 9))
assert render_start(repo.get("bk-1")) == "09:00" # with the fake: datetime.strftime
def test_render_start_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
repo.save(a_booking("bk-1", 9))
assert render_start(repo.get("bk-1")) == "09:00" # <-- str has no .strftime
What to expect. On my machine (Python 3.14.0, pytest 9.1.1), the fake passes and the real one blows up with AttributeError:
=================================== FAILURES ===================================
________________________ test_render_start_with_sqlite _________________________
def test_render_start_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
repo.save(a_booking("bk-1", 9))
> assert render_start(repo.get("bk-1")) == "09:00" # <-- str has no .strftime
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_types_and_order.py:34:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
booking = Booking(id='bk-1', room_id='focus', member_id='m-ana', start='2026-03-10T09:00:00', end='2026-03-10T10:00:00', status='confirmed', price_cents=6000)
def render_start(booking):
> return booking.start.strftime("%H:%M") # assumes datetime
^^^^^^^^^^^^^^^^^^^^^^
E AttributeError: 'str' object has no attribute 'strftime'
tests/test_types_and_order.py:21: AttributeError
Read the booking line in the traceback: pytest prints the whole booking, and there's the giveaway —start='2026-03-10T09:00:00', with quotes: it's a str, not a datetime—. The fake would have given start=datetime.datetime(2026, 3, 10, 9, 0), without quotes, an object. The SQLite seam repacked the datetime as ISO text when saving it (booking.start.isoformat() in the save) and returned it as text when reading it (nobody converts it back), and the render_start that assumed a datetime crashes against a str. The unit test with the fake could never see this, because the fake doesn't repack: it returns the datetime you gave it. Only the real piece, which does serialize, reveals that your screen code depends on a type the database doesn't preserve.
Divergence of order: the guarantee the fake invented
The second family is more slippery because the fake and the real one almost always match, until they don't. Consider find_by_room, which returns a room's bookings. The FakeBookingRepository returns them in insertion order: a Python dict preserves the order in which you put the keys, deterministically and documented. So if you save the bookings in a certain order, the fake gives them to you in that same order, always. It's a solid guarantee... of the fake. The question is whether the real SqliteBookingRepository makes the same promise. And the answer is that it promises nothing: a query SELECT ... WHERE room_id = ? without an ORDER BY returns the rows in whatever order the engine considers convenient, and that order depends on internal details —whether there's an index, which one the planner uses, how the table is physically laid out—.
To make the divergence visible, I use a SqliteBookingRepository with a detail any production database would have for performance: an index over (room_id, start). Nothing exotic; it's the most normal optimization in the world to speed up "this room's bookings, by time". I save three bookings in an id order that is not chronological —bk-c at 11 h, bk-a at 9 h, bk-b at 10 h— and ask for find_by_room. The fake gives them to me in insertion order (bk-c, bk-a, bk-b); the real one, with the index, gives them to me in index order, i.e., by start (bk-a, bk-b, bk-c).
# tests/test_types_and_order.py (order part)
# we insert ids in NON-chronological order: bk-c(11h), bk-a(9h), bk-b(10h)
INSERT_ORDER = [("bk-c", 11), ("bk-a", 9), ("bk-b", 10)]
def test_find_by_room_order_with_fake():
repo = FakeBookingRepository()
for id_, h in INSERT_ORDER:
repo.save(a_booking(id_, h))
ids = [b.id for b in repo.find_by_room("focus")]
assert ids == ["bk-c", "bk-a", "bk-b"] # the fake promises insertion order
def test_find_by_room_order_with_sqlite():
repo = SqliteBookingRepositoryIndexed(sqlite3.connect(":memory:"))
for id_, h in INSERT_ORDER:
repo.save(a_booking(id_, h))
ids = [b.id for b in repo.find_by_room("focus")]
assert ids == ["bk-c", "bk-a", "bk-b"] # <-- the real one returns index order
What to expect. Running the whole file (both families together), the fake passes both and the real one fails both:
tests/test_types_and_order.py::test_render_start_with_fake PASSED [ 25%]
tests/test_types_and_order.py::test_render_start_with_sqlite FAILED [ 50%]
tests/test_types_and_order.py::test_find_by_room_order_with_fake PASSED [ 75%]
tests/test_types_and_order.py::test_find_by_room_order_with_sqlite FAILED [100%]
FAILED tests/test_types_and_order.py::test_render_start_with_sqlite - AttributeError: 'str' object has no attribute 'strftime'
FAILED tests/test_types_and_order.py::test_find_by_room_order_with_sqlite - AssertionError: assert ['bk-a', 'bk-b', 'bk-c'] == ['bk-c', 'bk-a', 'bk-b']
========================= 2 failed, 2 passed in 0.04s ==========================
The order's AssertionError is eloquent: the real one returned ['bk-a', 'bk-b', 'bk-c'] (chronological order, from the index) where the fake, and therefore the code that trusted the fake, expected ['bk-c', 'bk-a', 'bk-b'] (insertion order). And here's what's insidious about this divergence: it's a change of scenery that can appear without anyone touching your code. The day the test was written, maybe there was no index, and the SELECT without ORDER BY returned the rows in their physical order (which coincided with insertion), so the test passed with the real one too —by pure luck—. Months later, someone adds the (room_id, start) index to speed up a screen, without touching find_by_room or your code, and the order of the rows changes. Your test —or worse, your production— goes red because of a change that seemed to have nothing to do with it. The fake had sold you an order guarantee the real one never signed, and that guarantee collected its bill on the least expected day.
The common pattern: the fake over-promises
Behind the two families there's a single mechanism, and it's worth naming because you'll recognize it in many future divergences. The fake, being a live Python object in memory, offers free guarantees that cost it nothing to give: it preserves the exact types (it doesn't serialize) and it preserves insertion order (it's a dict). Those guarantees are true of the fake, but they're not part of the BookingRepository's contract —nobody promised that get returns a datetime, or that find_by_room respects insertion order—. Code that's tested only against the fake doesn't distinguish between "what the contract promises" and "what the fake gives away extra", and ends up depending on the gifts. When you connect the real piece, which only fulfills the contract and hands out no gifts, the code that depended on the gifts breaks.
The discipline you take from here is twofold. First, be explicit with the contract: if your code needs start to be a datetime, then converting the text back to datetime when reading is part of the repository's contract, and it has to be written and verified —not left to the chance of the fake giving it away—. If your code needs an order, ask for an explicit ORDER BY and make the contract guarantee it —don't trust the insertion order only the fake respects—. Second, verify the contract against both implementations. A battery that asserts "get returns a Booking whose start is a datetime" and "find_by_room returns the bookings ordered by start", run against the fake and the real one, would go red on whichever doesn't fulfill it —the real one that returns str, or the one that doesn't order— before the bug reaches the screen. That's module 3; here it's enough that you see the fake's free guarantees are debt in disguise.
Common mistakes
Confusing "passes with the real one today" with "the contract guarantees the order". What happens: someone runs the order test against the SqliteBookingRepository without an index, sees it pass (the rows come out in insertion order by physical coincidence), and concludes the order is guaranteed. Why it happens: a green against the real one feels like a guarantee. How to detect it: a SELECT without ORDER BY guarantees nothing, no matter how much it returns what you expect today; the order is an implementation detail that an index, a new engine version, or a reorganized table can change. How to fix it: if you care about the order, write it (ORDER BY start) and test it; if you don't care, don't assert it in the test. Never depend on an order you didn't ask for explicitly, neither with the fake nor with the real one.
"Fixing" the type divergence in the test instead of in the repository. What happens: someone sees the strftime's AttributeError and changes the test to accept a str (assert booking.start == '2026-03-10T09:00:00'), calling it done. Why it happens: making the test green feels like resolving. How to detect it: ask yourself who else reads booking.start expecting a datetime —the screen code, the refund calculations that subtract dates, any temporal comparison—. All of them are still broken; you only silenced the messenger. How to fix it: decide the contract ("get returns a Booking with start of type datetime") and make the SqliteBookingRepository fulfill it by converting the text back to datetime when reading (with datetime.fromisoformat). Fix the piece, not the test.
Believing a fake that preserves types and order is "more faithful" and therefore better. What happens: someone reasons that, since the fake preserves the datetime and the order, it's a high-quality double. Why it happens: "preserves more" sounds like "resembles the real one more". How to detect it: in these two cases, the fake preserves more than the real one guarantees, and that generosity is precisely the trap —your code learns to depend on what the real one doesn't give—. How to fix it: a double's fidelity isn't measured by how much it preserves, but by how much it matches the real one's contract. A fake that gives away guarantees the real one doesn't fulfill isn't more faithful: it's more deceptive. What you want is for the fake to promise exactly what the real one promises, no more and no less, and only a shared contract ensures that.
Exercises
Exercise 1 — Which gift of the fake is this? For each hidden dependency, say whether it's a guarantee of type or of order that the fake gives away and the real one doesn't promise, and how you'd make it explicit in the contract: (a) the code does booking.start - booking.end expecting to subtract two datetimes; (b) the code takes find_by_room("focus")[0] believing it's "the first booking that was created"; (c) the code does booking.price_cents + 100 expecting an int.
See solution
- (a) Type. Subtracting
booking.start - booking.endonly works if both aredatetime(it gives atimedelta); withstr, it blows up withTypeError. The fake gives away thedatetimes; the real one givesstr. To make it explicit, the contract asserts "getreturns aBookingwithstartandendof typedatetime", and theSqliteBookingRepositoryreconstructs them withdatetime.fromisoformatwhen reading. - (b) Order.
find_by_room(...)[0]as "the first one created" depends on insertion order, which only the fake guarantees. The real one may return a different one at position0. To make it explicit, if you want "the first created", ask for an order that defines it —for exampleORDER BY created_ator by id if it's chronological— and guarantee it in the contract; never trust position0of an unordered query. - (c) Neither of the two gifts fails here.
price_centsis anint, and SQLite has a nativeINTEGERtype, so it goes and comes back asintin both repos.booking.price_cents + 100works the same with the fake and with the real one. It's the useful contrast: not everything diverges —integers cross the seam without changing type—; what diverges is the types the database doesn't model natively (likedatetime) and the guarantees only the fake gives (like the order). Knowing what diverges and what doesn't is half the craft.
Exercise 2 — The index that broke the test. An order test had been green for months against the SqliteBookingRepository. A colleague adds an index (room_id, start) to speed up a screen, without touching find_by_room or the business code, and the test goes red. Explain what happened and why the colleague had no reasonable way to anticipate it.
See solution
What happened: the test asserted a concrete order (insertion order) on the result of a SELECT ... WHERE room_id = ? without ORDER BY. Before the index, SQLite resolved that query with a table scan, which returned the rows in their physical order —which coincided with insertion order—, so the test passed. Adding the (room_id, start) index, the query planner chose to use it to satisfy the WHERE room_id = ?, and traversing the index returned the rows in index order —by start—, which is different from insertion order. The result changed order, and the test that depended on the old order went red.
Why the colleague couldn't reasonably anticipate it: their change was local and correct —adding an index for performance doesn't touch the logic or the query—, and there's nothing in find_by_room that announces "someone depends on the insertion order of this query without ORDER BY". The dependency was implicit, inherited from the fake's behavior, and lived in a test that isn't even in the file they edited. It's the danger of depending on unwritten guarantees: they break with changes that, looked at alone, are impeccable. The lesson: if a test asserts an order, the query must ask for that order with ORDER BY; that way the order is an explicit promise of the code, robust to indexes, and not a physical accident the next CREATE INDEX can flip.
Exercise 3 — Close both cracks in the SqliteBookingRepository. Without writing the contract yet (module 3), fix the two divergences in the real piece: make get return start/end as datetime, and make find_by_room return a guaranteed order. Write the changes and explain why they go in the repository and not in the code that uses it.
See solution
The two changes live in the SqliteBookingRepository, because the divergence is born in how the real piece serializes and queries. For the type, convert the ISO text back to datetime when reading:
from datetime import datetime
def get(self, booking_id):
row = self._conn.execute(...).fetchone()
if row is None:
raise KeyError(booking_id)
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=datetime.fromisoformat(row[3]), # ISO str -> datetime
end=datetime.fromisoformat(row[4]), # ISO str -> datetime
status=row[5], price_cents=row[6],
)
For the order, ask for it explicitly in the query:
def find_by_room(self, room_id):
rows = self._conn.execute(
"SELECT ... FROM bookings WHERE room_id = ? ORDER BY start", # explicit order
(room_id,)).fetchall()
return [Booking(...) for r in rows]
Why they go in the repository and not in the code that uses it: because the repository's job is to be an honest BookingRepository —return Bookings with the types and guarantees the contract promises—, so that all its clients (the screen code, the refund calculations, any future query) receive the same thing without each one having to defend itself on its own. If you "fixed" each client to tolerate a str or an arbitrary order, you'd spread the same fix over dozens of places, forget it in some, and diverge again. The responsibility to fulfill the contract belongs to the piece that implements the interface, not to whoever consumes it. With these changes, the SqliteBookingRepository stops repacking in surprising ways: it delivers datetime and a defined order, just like the fake, and the two cracks are closed in a single place.
Summary and next step
In this lesson you ran two families of divergence that don't change what the piece does, but the shape of what it returns. The types one: the datetime the fake preserves intact comes back from the real SqliteBookingRepository as str, and the screen code that called .strftime() crashes with an AttributeError. The order one: the fake promises insertion order, but the real one —with a production index— returns index order, and a test that depended on the old order goes red because of a CREATE INDEX nobody associated with it. And you saw the common mechanism: the fake gives away guarantees (exact types, insertion order) that aren't part of the real one's contract, and code that's tested only with the fake ends up depending on gifts the real piece doesn't hand out.
Before moving on you should be able to: explain why price_cents crosses the seam without trouble and start doesn't; distinguish "the fake preserves more" from "the fake is more faithful"; and decide where the fix goes (in the repository, so that all its clients inherit the contract).
The deepest divergences remain, the ones the dict can't even fake because it doesn't have the machinery: constraints and transactions. Lesson 5 runs them: a double booking the real one rejects with IntegrityError while the fake accepts it silently, and a batch that fails halfway that the real one undoes with a rollback while the fake leaves it half-written. These are the divergences of uniqueness and atomicity, invisible to an in-memory double.
Resources
sqlite3— SQLite and Python types (Python documentation) — the official table of how SQLite maps (and doesn't map) Python types: why anintsurvives intact and adatetimehas no native equivalent and must be serialized to text. The root of the type divergence.datetime.fromisoformat(Python documentation) — the method that reconstructs adatetimefrom the ISO text we stored, the piece that closes the type divergence in the real repository.- SQLite —
ORDER BYand row order — the documentation that makes clear that, withoutORDER BY, the row order is undefined and subject to the engine's strategy; the basis of why the order the fake gives away isn't guaranteed in the real one. - pytest documentation — parametrization and assertions — how pytest prints the whole object in the traceback (the
bookingline withstart='...'in quotes) that let us see, at a glance, that the type had changed fromdatetimetostr.