The Test Shape of a Service
A service's bugs live in three places: in the rules, in the boundaries between the code and the systems it talks to, and in the whole thing running under real conditions. Stagedoor's suite before the spring on-sale covered the first place only. It had 400 tests, every one of them mocked the database and the Payrail client, it ran in 8 seconds, and it was green on the morning after seat 14C was sold twice. It proved nothing about the oversell because the oversell was Postgres's behaviour under two concurrent transactions, and no mock has that behaviour. It proved nothing about the double charge because the mock Payrail answered every call, and the wound was a call that did not answer. It proved nothing about the late emails because the render was mocked out.
The shape that works has three layers, roughly 70 percent, 25 percent and 5 percent of the tests, and one rule that the old suite broke everywhere: a boundary is tested against the real thing on the other side of it, or against a fake that behaves like it, never against a mock that returns what the author expected. This topic is the shape. The four after it are the layers, one at a time, ending with the load test that finds the pool limit before the buyers do.
Three Layers
The domain layer tests the rules of Chapter 4 with the in-memory repository of Topic 20, the frozen clock and the fake stream. Nothing leaves the process. Stagedoor has 1,050 of these after the rebuild, and the whole layer runs in 4 seconds, about 4 milliseconds each, most of that pytest's own overhead. The integration layer tests each boundary against its real counterpart: the storage layer against a PostgreSQL 18 container, the Payrail client against a scripted HTTP transport, the handlers through a real HTTP client speaking to the real FastAPI app. There are 370 of them and they run in 55 seconds, 150 milliseconds each, most of it the database. The end-to-end layer starts the whole service with a real Postgres, a real Redis and the fake Payrail, and drives it from outside as a buyer would. There are 14, they take 90 seconds between them, and each one costs seconds because each one does everything.
The counts are 73, 26 and 1 percent, and the end-to-end share is deliberately under the 5 percent a textbook would give it. Each layer's speed is a property of what it touches, not of how it is written: a domain test cannot be slow because there is nothing to wait for, and an end-to-end test cannot be fast because it waits for everything. The suite that took 8 seconds was not fast because it was well built. It was fast because it tested nothing that could take time, and the things that take time were where the bugs were.
What Each Catches
The domain layer catches the rule that is wrong. A hold that expires at 9 minutes instead of 10, found by the test that sets the frozen clock to 20:09:59 and asserts the seat is still held. An authorization check that was left out of the refund function, found by the test of Topic 29 that calls it with a buyer's principal and someone else's order and expects a refusal. An order allowed to move from refunded back to paid, found by the test of Topic 56 that walks every illegal transition of the state machine and expects each one refused. These tests are cheap enough that there is one per rule, and they fail with the rule's name in the test's name.
The integration layer catches the code that is right about the rules and wrong about the system. The N+1 of Topic 33, where listing 50 orders runs 51 queries, found by counting statements on a real connection. The FOR UPDATE of Topic 34 that locks the holds row instead of the seats row, found by two real transactions racing on one seat. The Payrail response that the strict model of Topic 53 cannot parse, found by handing the real client a body with amount_cents as a string. No in-memory repository would have caught any of the three, because the in-memory repository does what the author thinks Postgres does, and the author is the person who wrote the bug.
The end-to-end layer catches the checkout that works in pieces and not as a whole. Every domain test passes, every storage test passes, and the buyer who holds a seat, pays and asks for her tickets gets a 404 on the tickets, because the ticket rows are written by the worker, the worker reads the outbox, and the outbox relay was never started in the new deploy's process model. Fourteen tests of that kind exist because fourteen paths through the service matter enough: hold and pay, hold and let it expire, pay and refund, scan a ticket at the door, and the rest. They are the only tests that see the service as a buyer does.
Mocks vs Fakes vs the Real Thing
A mock is an object that records how it was called and lets the test assert on the calls: charge was called once, with this order and this key. It has no behaviour of its own, so a test that uses it is a test of the code's shape, and every refactor that changes the shape without changing the behaviour breaks it. A fake is a working implementation of the same interface with a simpler inside: the in-memory SeatRepository of Topic 20 is a dictionary, the fake Payrail of Topic 65 is a class that charges, declines the test card and remembers its charges by key. A test that uses a fake asserts on outcomes, the seat is held, the order is paid, and survives any refactor that leaves the outcome alone. The real thing is the system itself, the PostgreSQL 18 container of Topic 64, and it is the only one of the three whose behaviour under concurrency, under constraint violation and under a bad query plan is the behaviour production will have.
What follows is short. Fakes for the domain, because the rule is under test and the storage is a detail. The real engine for the storage layer, because the SQL is under test and nothing else can judge it. A fake for the neighbour that cannot run in the suite, kept honest by a scheduled test against the neighbour itself. Mocks for almost nothing; the one place Stagedoor keeps one is a test that a log line was written with the right fields, where the call is the behaviour.
Tests as the Specification
Every rule in the API document of Chapter 3 and in the domain of Chapter 4 has a test whose name is the rule: test_hold_expires_after_ten_minutes, test_refund_requires_order_owner_or_staff, test_paid_order_cannot_return_to_pending. Every wound has a regression test that reproduces the incident and would fail if the fix were removed: test_concurrent_holds_on_one_seat, which needs a real Postgres and two connections and is the subject of Topic 64; test_retried_checkout_charges_once, in Topic 65; test_order_confirms_before_pdf_is_rendered, which asserts the response returns in under 200 milliseconds while the render is still queued. The suite is the book's promises in executable form, and a reader who wants to know what Stagedoor guarantees can read the test names faster than the prose.
async def test_hold_expires_after_ten_minutes(): clock = FrozenClock("2026-10-03T20:00:00Z") svc = build_service(test_config, seats=MemorySeatRepository([make_seat(label="14C")]), holds=MemoryHoldRepository(), clock=clock) hold = await svc.hold_seat(event_id=8812, label="14C", buyer=buyer) clock.set("2026-10-03T20:09:59Z") assert (await svc.seats.get(8812, "14C")).status == "held" # one second short: still held clock.set("2026-10-03T20:10:00Z") await svc.expire_holds() # what the worker's sweep calls assert (await svc.seats.get(8812, "14C")).status == "available"
The test builds a service with the same constructor production uses, handing it an in-memory seat repository holding one seat, an in-memory hold repository and a clock frozen at eight in the evening. It holds the seat, moves the clock to one second before the deadline and checks the seat is still held, then moves it to the deadline, runs the sweep and checks the seat is free. Two milliseconds, no database, no sleeping, and the rule that a hold lasts 10 minutes is now something the suite enforces rather than something the prose claims. The day someone changes the interval to 9 minutes by editing the wrong constant, this test names the rule that broke.
Speed and Where It Comes From
The domain layer runs in seconds because it touches nothing that waits. The integration layer runs in under a minute because the database is one container started once for the whole session, and each test runs inside a transaction that is rolled back at the end, so there is no cleanup and no test can see another's rows. The end-to-end layer runs on merge, in CI, not on every save, because 90 seconds is more than a person will wait between edits. A suite that takes ten minutes is a suite nobody runs before pushing, and a suite nobody runs is the 400 green tests again with a different excuse.
The layers are also selected by marker. pytest -m "not slow" is the command Marek runs 40 times a day and it finishes in 4 seconds; the full integration layer runs on every push; the marked tests that need real commits, sleep through a real timeout or drive the whole service run on merge. Speed is not a property of the test framework. It is a property of which layer a test lives in, and putting a test in the wrong layer costs either the coverage or the time.
What Tests Do Not Replace
A green suite says the code does what the tests say, and the tests say what the author thought of. Four things the author cannot think of in advance have their own instruments. The load test of Topic 67 finds the number at which the pool runs out, which no functional test can, because a functional test sends one request. The contract test of Topic 57 finds the day Payrail renames a field, which no test against the fake can, because the fake was written from the old field name. The reconciliation of Topic 55 finds the charge the webhook never delivered, which no test can, because it happens in production at three in the morning. And the monitoring of Chapter 13 finds everything else, which is the honest name for what the suite did not cover.
The suite's job is the part of correctness that is knowable before the deploy. The rest is the reason the book has two more chapters after this one.
Replacing the repository in a domain test is right. The rule is under test, not the SQL, and the in-memory repository makes the test run in 2 milliseconds with the outcome as the assertion. Stagedoor has 1,050 tests of this kind.
Replacing the database in a storage test is wrong. The SQL is what is under test, and a mock returns whatever the author expected the query to return, so the test passes on the author's belief and never on Postgres's behaviour. The FOR UPDATE on the wrong row passes this test forever.
The layer decides. The same in-memory repository is the right tool in one layer and the lie in the other, and the question to ask of any test is which thing it is testing: the rule, or the boundary.
- One layer, everything mocked — the 400 green tests in 8 seconds, and three wounds that all lived in the boundary the mocks had replaced, so the suite could not have been red on the night.
- Mocks that assert call order — a refactor that swaps two lines with no change in behaviour breaks 50 tests, and after the third such morning the team stops trusting a red suite.
- Storage tests against SQLite "for speed" —
FOR UPDATE,ON CONFLICT,jsonband the planner do not exist there, so the tests pass on behaviour Postgres does not have and skip the behaviour it does. - End-to-end for everything — a ten-minute suite that runs once a day, stays red for a week because nobody can tell which of the 300 failures is the cause, and is finally marked skipped.
- No regression test for the incident — the oversell fixed on the morning after, reintroduced by a refactor of
hold_seatin August, and found again by two buyers in September.
- Build three layers with the boundary rule: fakes for the domain, the real PostgreSQL 18 for storage, and a fake for the neighbour you cannot run, kept honest by a scheduled contract test.
- Write one test per rule in the specification and one regression test per incident, named for the rule or the incident, and refuse the incident's fix until its test exists.
- Keep the domain layer under 5 seconds and the integration layer under a minute, with a session-scoped container and a rolled-back transaction per test.
- Run end-to-end tests on merge and load tests on a schedule, marked so that
pytest -m "not slow"stays fast enough to run before every push. - Assert outcomes, the seat is held and the order is paid, never that a method was called with particular arguments.
Knowledge Check
Stagedoor's old suite had 400 tests, all mocked, green in 8 seconds. Why did it catch none of the three wounds?
- Because 400 tests is too few to cover a service of Stagedoor's size and shape
- Because all three wounds lived in the boundaries the mocks had replaced
- Because a suite that runs in 8 seconds cannot exercise enough code to matter
- Because the suite ran only on merge and the wounds shipped between merges
Which bug can the integration layer catch that the domain layer, with its in-memory repository, cannot?
- A hold that expires after 9 minutes instead of the 10 minutes the specification promises
- A refund function that forgets to check whether the caller owns the order
- A FOR UPDATE that locks the holds row instead of the seats row the race is about
- An order that is allowed to move from refunded back to paid by a code path
What separates a fake from a mock, and why does the book prefer the fake?
- A fake runs in memory and a mock talks to the real system, so the fake is faster
- A fake is used for integration tests and a mock for the domain, so each layer gets one
- A fake records calls in a list while a mock records them in a dictionary keyed by name
- A fake behaves, so tests assert outcomes; a mock only records calls, so tests assert shape
Marek proposes running the storage tests against SQLite because it starts in milliseconds. What is wrong with that?
- SQLite gets slower than a Postgres container once the suite grows past a few hundred tests or so
- The features the tests are for, FOR UPDATE, ON CONFLICT, the planner, are what SQLite lacks
- The Postgres container must be restarted for every test anyway, so nothing would be saved
- The Postgres container starts in under a second anyway, so SQLite would save nothing measurable
You got correct