Test Pyramid vs Test Trophy: Choosing a Model
- Shawn West
- Mar 4
- 9 min read
Updated: Aug 10
A team can do everything the pyramid tells it to — thousands of fast unit tests, high coverage, green CI — and still ship bugs its suite was structurally incapable of catching, because its bugs aren't born where its tests are looking. Pyramid versus trophy isn't an aesthetic preference. It's a bet about where your bugs come from. Here's how to stop copying a diagram and read the answer off your own defect data.
The team had done it right. Four thousand unit tests, most running in single-digit milliseconds, coverage in the high eighties, a pipeline that went red the moment a pure function misbehaved. By every metric the pyramid celebrates, this was a healthy suite. And every couple of weeks, something reached production that the suite never flinched at: an order that charged the wrong tax because a currency field arrived as a string instead of a number; a webhook that silently dropped events after a library upgrade changed a default timeout; a report that double-counted refunds because two services disagreed about what "settled" meant.
Look at the escaped bugs and a pattern jumps out. Almost none of them lived inside a function. They lived between things — at the seam where the service met its database, its queue, its third-party API, its own neighboring service. The unit tests were green because every unit worked exactly as its author imagined. The bugs were in the imagining: the mock returned what the test expected, and production returned something else. The suite wasn't weak. It was aimed at the wrong place.
That is the real question underneath "pyramid or trophy," and neither diagram will answer it for you.
The two shapes, quickly — then the bet underneath them
Mike Cohn's pyramid (2009) is the one most of us were raised on: a wide base of unit tests, a narrower band of integration tests, a thin cap of end-to-end tests. Its logic is economic. Unit tests are the fastest and cheapest thing you can run, so buy the most of them; the expensive, slow tests at the top should be rare.
Kent C. Dodds' trophy (2018) reshuffles the middle. A foundation of static analysis (types, lint), a modest layer of unit tests, a fat integration layer where most of the investment goes, and a small end-to-end cap. Its logic is also economic, but it prices things differently: modern integration tooling is fast enough to lean on, heavily-mocked unit tests miss real bugs, and static analysis deletes a whole class of defect at compile time for nearly nothing.
Argue the diagrams against each other and you'll go in circles, because both are internally coherent. The disagreement isn't about geometry. It's this:
The pyramid bets that most of your bugs are born in logic — inside a unit, where a wide base of cheap unit tests will catch them. The trophy bets that most of your bugs are born in wiring — at the seams between units, where only a test that exercises the real integration will catch them.
Both bets can be right. They're just right about different codebases. And here is where the ShiftQuality thesis does real work: a testing shape is a claim about where your defects come from, and that is a discovery question, not a style question. The pyramid and the trophy are two people's answers to "where do bugs live in the systems I work on" — Cohn generalizing from rich-domain systems, Dodds from orchestration-heavy web apps. Copying either one is inheriting a stranger's defect distribution and hoping it matches yours. The move that actually catches bugs is to look at your own.
What the shape is really optimizing
Every test you write is a bet that a bug could appear at a particular altitude, and that this test sits at that altitude to catch it. A unit test lives inside a boundary — it feeds a function inputs and checks outputs, with everything the function talks to replaced by a mock. That's what makes it fast, and it's the same thing that makes it blind to the seam: the mock is your assumption about the collaborator, frozen in place. If the bug is that your assumption is wrong — the API returns cents, not dollars; the DB coerces your enum; the queue redelivers — the unit test can't see it, because you've replaced the truth with your belief about the truth.
An integration test gives up speed to keep the collaborator real. It talks to an actual Postgres, an actual HTTP boundary, an actual message broker, and it catches exactly the class the unit test is structurally blind to: the wiring, the contracts, the coercions, the defaults. That is the whole mechanism. The pyramid-vs-trophy choice is a wager on how many of your bugs survive contact with a mock. In a codebase full of pure calculation, very few do — mock almost nothing, and unit tests catch nearly everything. In a codebase that is mostly moving data across boundaries, most bugs live precisely where the mocks are — and a suite dominated by unit tests will be green while production burns.
So the shape isn't chosen by taste or by which conference talk you watched most recently. It's chosen by a fact about your system that you can measure: what fraction of your escaped defects were born in logic versus at a seam.
A service that told us its shape (Developed example — composite scenario.)
Consider a payments-adjacent service — call it billing-sync. Anonymized composite, but the distribution is the kind we see repeatedly in orchestration-heavy code. Its job is unglamorous and typical of modern backends: pull settlement events off a queue, reconcile them against orders in Postgres, call a tax provider's API, and write invoices. Perhaps fifteen percent of its code is genuine logic (proration math, rounding rules); the other eighty-five percent is orchestration — reading, mapping, calling, writing, handling the cases where a collaborator misbehaves.
The team built it pyramid-style out of habit: hundreds of unit tests, everything external mocked, a handful of integration tests treated as a chore, two E2E smoke tests. Coverage looked great. Escapes kept happening anyway. So they did the one thing that actually settles the argument: they took the last ten bugs that reached production, and for each one asked a single question — what is the lowest-cost test layer that would have actually caught this? Not "could a heroic unit test with a cleverer mock have caught it," but what layer catches this by its nature.
# | Escaped bug | Where it was born | Lowest layer that catches it by nature | Did a unit test exist there? |
1 | Tax field parsed as string "12.50", math silently wrong | Seam (provider API contract) | Integration (real provider stub) | Yes — mock returned a number |
2 | Refund double-counted after "settled" redefined upstream | Seam (cross-service contract) | Integration / contract test | Yes — mock used old shape |
3 | Proration off by a cent on leap-year months | Logic | Unit | No |
4 | Queue redelivery created duplicate invoices | Seam (broker at-least-once semantics) | Integration (real broker) | Yes — mock delivered once |
5 | Null customer tier crashed mapping under real data | Seam (DB nullability) | Integration (real Postgres) | Yes — fixture always set it |
6 | Rounding rule wrong for a specific currency | Logic | Unit | Partial |
7 | Timeout default changed by library upgrade, events dropped | Seam (dependency default) | Integration | Yes — mock never timed out |
8 | Enum coerced by DB, unknown status written | Seam (DB coercion) | Integration | Yes — mock preserved enum |
9 | Retry storm when provider returned 429 | Seam (provider behavior) | Integration | Yes — mock never 429'd |
10 | Invoice total miscalculated on discount stacking | Logic | Unit | No |
Read the "where it was born" column and the service is talking to you. Seven of ten escapes were born at a seam; three were logic. And notice the last column — for six of the seven seam bugs, a unit test existed and passed, because the mock encoded the team's assumption and the bug was that the assumption was wrong. More unit tests would not have moved this number. The suite's shape was inverted relative to the code's actual risk: eighty-five percent orchestration, but a test budget spent as if the risk were in the fifteen percent of logic.
The fix wasn't ideological. Bugs 3, 6, and 10 are real logic defects — keep unit tests there; that's the pyramid earning its keep in the one region where it fits. But the center of gravity had to move to where the bugs actually were: integration tests against a real Postgres, a contract test pinning the "settled" shape against the upstream service, tests that ran the real broker's at-least-once redelivery, and a stub of the tax provider that could return strings, 429s, and timeouts. That is a trophy — not because a diagram said so, but because ten real bugs pointed at the middle layer. (For the mechanics of building that middle layer well, see Integration Testing: When, How, and Why; for keeping the surviving unit tests honest rather than mock-shuffling, Unit Testing Best Practices.)
The diagnostic: let your escaped bugs draw the shape
You don't have to accept a composite. Run the same read on your own system — it takes an hour and it ends the argument with evidence instead of preference. This is testing that catches real bugs in its most literal form: ask the bugs where they came from.
Pull your last 10–20 escaped defects. Escaped means it reached an environment you didn't want it in — staging that blocked a release, or production. Not caught-in-CI; those already worked. Bug tracker, incident log, or "revert" commits all work as a source.
For each, tag where it was born: logic (wrong behavior inside a unit, given correct inputs) or seam (a contract, coercion, default, ordering, or wiring assumption between units that turned out false). When it's genuinely both, tag the layer that would have caught it first and cheapest.
For each, name the lowest test layer that catches it by its nature — static, unit, integration, or E2E — using the "would a mock have hidden this?" test. If a realistic mock would still have let the bug pass green, it's not a unit-catchable bug.
Count. The ratio is your answer, and it's blunt:
Mostly logic → your bugs live where unit tests look. You want a pyramid. Adding integration tests will mostly add slow tests that catch little.
Mostly seams → your bugs live where mocks hide them. You want a trophy. Adding unit tests will raise coverage and change nothing about escapes.
A specific seam dominates (all DB, or all one provider) → you don't need a philosophy, you need integration tests around that boundary. Fix the region, not the whole shape.
The observable signal to watch over time is the gap between coverage and escapes. Coverage climbing while escapes hold steady is the pyramid's failure fingerprint: you are testing more of the code where the bugs aren't. (On why the coverage number reassures more than it should, see Test Coverage: The Honest Version.)
The tradeoff, stated plainly
Shape | Optimizes for | You pay… | Fits when your escaped-bug tag is… | Warning sign you chose wrong |
Pyramid (unit-heavy) | Fast feedback, pinpoint failure localization, cheap runs | Blind to seam bugs; mocks encode assumptions that can be wrong | Mostly logic — rich domain math, parsers, calculation, transformation | Coverage high, seam bugs keep escaping green CI |
Trophy (integration-heavy) | Catching wiring/contract bugs; testing behavior close to reality | Slower suite; coarser failure localization; heavier test-data setup | Mostly seams — orchestration, DB/queue/API wiring, cross-service contracts | Suite creeps past ~10 min, devs stop running it locally, flakiness rises |
The exception that keeps the trophy honest. The trophy's own risk is a suite that grows slow and coarse until people stop trusting it — a 30-minute integration run that developers skip locally is worse than the pyramid it replaced, because an untrusted suite is a suite you route around. So the trophy is not the default when your integration tests can't be made fast and isolated: a legacy system whose only "integration" test needs a shared staging database, six external services, and ninety seconds per case should keep its center of gravity lower and invest in contract tests and a small, ruthless set of end-to-end checks instead (the discipline for that thin top layer is in End-to-End Testing Without the Pain). And whichever shape you land on, static analysis is free foundation under both — in a typed codebase, a whole band of "seam" bugs (the string-where-a-number-belongs class) never becomes a test at all, because the type checker refuses it at compile time.
The discovery move you can make before a single test exists
Everything above is a reaction — you read escaped bugs after they've cost you something. The brand thesis pushes earlier: the shape is discoverable from the code's job before the bugs arrive. You can estimate a new service's bug distribution the way you'd scope any risk — by asking what the code mostly does. Walk the modules and put each in one of two buckets: computes (takes inputs, returns a result, talks to nothing) or coordinates (reads, maps, calls, writes, and must survive a collaborator misbehaving). A service that's mostly "computes" will bleed logic bugs — build it a pyramid. A service that's mostly "coordinates," like billing-sync, will bleed seam bugs no matter how clean each function is — start it as a trophy on day one and don't wait for ten escapes to tell you what its job already told you. The escaped-bug diagnostic then becomes the check on that estimate, not the first time you think about it.
What to run this week
Pick your noisiest service — the one that keeps surprising you in production — and tag its last ten escaped bugs by the two columns that decide everything: where the bug was born (logic or seam) and the lowest layer that catches it by its nature. Then compare that to where your tests actually are. If seven of ten were born at seams and seven of ten of your tests are units mocking those seams, you've found the gap, and you found it with your own data instead of someone else's diagram. Move the next ten tests you write to the layer the bugs are actually escaping through, and re-tag in a month. The shape will have chosen itself — and this time it will match the place your bugs are really born.
Related on ShiftQuality: build the middle layer well — Integration Testing: When, How, and Why; keep the base honest — Unit Testing Best Practices; a ruthless top — End-to-End Testing Without the Pain; what the coverage number hides — Test Coverage: The Honest Version; and the north star — Testing That Catches Real Bugs.


