When the double must actually work
Stubs answer with canned values, and that is enough when the unit asks its dependency one question. Many units instead hold a conversation with a dependency, saving something, reading it back, updating it, and reading again. A stub cannot keep that story consistent, because if load always returns the same canned answer there is no way to test renaming a user and then reading back the new name.
This is the job of the third double from lesson 5-1, the fake, a genuinely working implementation of the dependency that is far lighter than the real one.
The classic fake is an in-memory dictionary standing in for a database. save writes to the dict, load reads from it, and the whole database vanishes when the test ends.
Working teams lean on fakes constantly, because swapping a production database for an in-memory store turns a seconds-long test that needs setup and cleanup into a millisecond test that needs neither. The tradeoff is that a fake is code you have to write and keep honest, which is why it is the last resort rather than the first.
A fake user store
FakeUserStore is a small class, with classes coming from Advanced Python, whose save and load are backed by a plain dictionary. Because the fake genuinely works, the test can tell a full story.
class FakeUserStore: def __init__(self): self.rows = {} def save(self, user_id, name): self.rows[user_id] = name def load(self, user_id): return self.rows.get(user_id) def rename_user(store, user_id, new_name): if store.load(user_id) is None: raise ValueError("no such user") store.save(user_id, new_name) store = FakeUserStore() store.save(1, "Ada") rename_user(store, 1, "Ada Lovelace") assert store.load(1) == "Ada Lovelace" try: rename_user(store, 99, "Ghost") assert False except ValueError: pass print("2 tests passed against the fake store")
Output
2 tests passed against the fake storeThe story spans four steps: save Ada, rename her, read back the new name, and confirm that renaming a missing user raises ValueError. Only the third step needs genuine state, and it is the step a stub cannot support, since load's answer has to change after save runs.
rename_user never knows it is talking to a fake. It calls load and save exactly as it would on the real database class, which is the same substitutability that made injection work in lesson 5-2.
The try block uses the assert False idiom from lesson 2-1. If rename_user fails to raise, execution reaches that line and the test fails loudly, which is the only way to assert that something was supposed to blow up.
Note that load returns None for a missing row because of dict.get, and rename_user checks is None rather than falsiness. That distinction matters if a name could ever legitimately be an empty string, and choosing is None is what keeps the guard precise.
Choosing among the three
The selector question is what your test needs from the dependency:
- needs a fixed answer, use a stub
- needs its interactions recorded, use a mock
- needs to keep working across several calls, use a fake
They combine freely. One test may stub the clock, mock the email sender, and fake the database all at once, and choosing per dependency rather than per test is what keeps each double as simple as it can be.
What never changes is the goal from lesson 5-1. The unit's real logic runs, and every neighbor is under your control.
A useful tiebreaker when two options seem to fit: prefer the simplest double that can express the claim. A stub is a line, a mock is a line plus an assertion, and a fake is a class you now own and must keep faithful to the real thing. Reaching for a fake when a stub would do adds code that can itself be wrong.
Why a stub fails a shopping cart
For code that adds three items to cart storage and then computes a total by reading the cart back, a stub is the wrong double because the reads must reflect the earlier writes, and canned answers cannot stay consistent with what the test wrote.
The test's story spans several calls, so what reading the cart returns depends on what adding items stored. A fixed answer breaks that thread, and any attempt to patch around it ends with you hand-simulating state inside the stub, which is precisely what a fake gives you cleanly.
The failure is worse than merely inconvenient. A stub returning three canned items would make the total look right even if add_item never stored anything, so the test would pass against thoroughly broken code. That is a false green, which is the most expensive kind of test to own.
The rule to carry forward is short. Fixed answer means stub, and living state means fake.
Counting visits against a fake
FakeCounterStore keeps its counts in a dictionary, and three asserts tell one continuous story about record_visit.
class FakeCounterStore: def __init__(self): self.counts = {} def increment(self, key): self.counts[key] = self.counts.get(key, 0) + 1 def get(self, key): return self.counts.get(key, 0) def record_visit(store, page): store.increment(page) return store.get(page) store = FakeCounterStore() assert record_visit(store, "/home") == 1 assert record_visit(store, "/home") == 2 assert record_visit(store, "/about") == 1 print("3 tests passed against the fake")
Output
3 tests passed against the fakeAll three asserts share one store, which is the point of the exercise. The second /home visit returning 2 only works if the state from the first visit survives, and that persistence across calls is exactly what makes this a fake rather than a stub.
The third assert checks a different claim, that pages count independently, so /about starts at 1 even though /home has already reached 2. A buggy increment that kept a single global counter would pass the first two asserts and fail this one.
counts.get(key, 0) appears in both methods and supplies the default that makes a first visit work. Without it, increment would raise KeyError on a page it had never seen, which is the same missing-default bug that empty-input tests catch in lesson 3-1.
Sharing state across asserts is a deliberate exception to the usual advice about independent tests. It is safe here because the three asserts form one scenario, and the moment you want them to be independent tests, each one builds its own FakeCounterStore in its own arrange phase.