Test Pyramid & Testing Trophy — Unit vs Integration vs E2E
Unit = fast/narrow; integration = real collaborators; E2E = journeys but slow/flaky. Trophy shifts effort toward integration. Avoid ice-cream cone (mostly E2E).
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Where should the mass of this suite sit?
Prefer
Thin top, real collaborators
A pyramid fits a domain library. A trophy fits an app whose bugs live in wiring. Both keep the browser suite small.
- Unit tests own pure rules and return in milliseconds.
- Integration tests own SQL, HTTP handlers, and module wiring.
- End-to-end owns a few journeys and stays off the save-file loop.
Alternative
Mostly end-to-end
The ice-cream cone looks like user coverage and spends the team on selectors and sleeps.
- Feedback is too slow for test-driven design.
- Failures are noisy, so red CI gets muted.
- The same discount rule is retested in the browser instead of in a function.
Same edit, three clocks
The source diagram fans out from the edit. Here the clocks are a single ladder so labels stay readable.
- 1
Unit, about 10 ms
Pure function. This is the test-driven loop on save. - 2
Integration, 100 ms to a few seconds
Real module plus a database or Testcontainers. This is pull-request confidence. - 3
End-to-end, tens of seconds to minutes
A critical journey in Playwright. This is release smoke, not the matrix.
Overview
Unit tests are fast and narrow. They get brittle when the test mocks the world the function actually depends on. Integration tests exercise real collaborators: a database, a queue, the router. End-to-end tests cover user journeys. They are slow, and they flake.
The pyramid puts most of the effort in units. The testing trophy shifts the mass toward integration. Both reject the ice-cream cone, a suite that is mostly end-to-end.
Use this page when an interviewer asks "how would you test this?" and the honest answer depends on whether the bug is a formula, a query, or a journey.
The three layers
Unit. Pure logic, no I/O. Feedback is milliseconds. A failure points at one function. Pricing, parsers, reducers, and state machines belong here.
Integration. Real modules, plus Testcontainers or a database. This catches SQL and wiring. A handler that talks to an in-process router is integration. A test that boots a framework, a database, and Redis is not a unit test, even if the file is named test_unit.
End-to-end. Critical journeys only, usually Playwright. Treat the set as smoke. Signup, pay, and receive can earn a browser test. Every validation message on the same form should not.
| Dimension | Pyramid | Trophy | Ice-cream |
|---|---|---|---|
| Fast feedback | Excellent | Good | Poor |
| Wiring confidence | Medium if over-mocked | High | Noisy |
| Flake risk | Low | Medium | High |
The trophy's extra mass is integration plus static analysis, not a second end-to-end suite. Kent C. Dodds argues for tests that give confidence the pieces work together, without a pile of shallow units that mock every child. Fowler's pyramid still wins when the code under test is a library and the failure mode is a pure function.
Flow
- 1
1. Edit the code
- next2. Unit, about 10 ms
- 2
2. Unit, about 10 ms
- next3. TDD loop on save
- 3
3. TDD loop on save
- next4. Integration, seconds
- 4
4. Integration, seconds
- next5. PR confidence
- 5
5. PR confidence
- next6. E2E smoke, minutes
- 6
6. E2E smoke, minutes
- next7. Release gate only
- 7
7. Release gate only
Lesson map
Test Pyramid & Testing Trophy — Unit vs Integration vs E2E
Unit = fast/narrow; integration = real collaborators; E2E = journeys but slow/flaky. Trophy shifts effort toward integration. Avoid ice-cream cone (mostly E2E).
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["1. Edit the code"] b["2. Unit, about 10 ms"] c["3. TDD loop on save"] d["4. Integration, seconds"] a -->|1. Edit the code to 2. Unit, about 10 ms| b b -->|2. Unit, about 10 ms| c c -->|3. TDD loop on save| d
On save, run unit tests and the fast integration slice. On the pull request, run the full integration suite and the contracts from the next page. On main, or before a release, run the thin end-to-end smokes. That split is how the pyramid stays a pyramid after the team adopts Testcontainers.
The same discount, three ways
A percent discount is the example. The rule is pure: reject a percent outside 0 to 100, round the reduction, and never return a negative price. That rule is a unit test. Persisting the order through a repository is an integration test. Clicking "Apply" in the browser is an end-to-end smoke, and only if checkout is a critical journey.
Do not copy the arithmetic into a Playwright assertion and delete the unit test. The browser run is slower, and a CSS change fails it for a reason that has nothing to do with rounding.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Press Run. Snippets must be self-contained — no network, files, or native modules.
An integration test would construct an in-memory or SQLite order repository, save a line, apply the same function, and read the row back. That test trusts SQL and the mapping. It should not re-prove every percent boundary the unit test already owns. An end-to-end test would drive one checkout journey. Boundary percents stay in the unit file.
A REST client wants both a unit-level fake of HTTP and a provider contract. The contract page owns the provider verify. This page only says the client unit test is not a substitute for it.
What changed when Testcontainers arrived
Testcontainers moves mass from mocks toward real-database integration. That is a trophy move inside a service that used to mock every repository. It is worth it when the bug you ship is a wrong WHERE clause or a transaction boundary. It is waste when the function never touches I/O and the container only makes the save-file loop slow.
Keep the container tests off the keystroke loop if they take seconds. Run them on the pull request. The unit file still runs on save.
Interview Q&A
How do you explain the pyramid to a junior?
Answer
Many fast unit tests, fewer integration tests, a thin end-to-end tip. The shape exists so feedback stays fast and flakes stay rare. If most of the time goes to the browser, the suite has flipped into an ice-cream cone.
Why do teams reach for the trophy on a React app?
Answer
Shallow unit tests that render a button and mock every hook miss the wiring bugs users hit. Integration with React Testing Library, and a network double such as MSW at the boundary, exercises the module the way the screen uses it. End-to-end stays a few Playwright journeys. The trophy is not "no unit tests." Pure reducers and price rules stay units.
When is a unit test actually an integration test?
Answer
When it boots the framework, a database, or Redis. The name on the file does not decide. If a failure can be a connection timeout, you are paying integration cost and you should be honest about the layer. Those tests belong on the pull request, not on every save.
How do you fix an ice-cream cone?
Answer
Cap the end-to-end suite. Push regressions down into unit, property, and API integration tests. Use Testcontainers where SQL is the risk. Delete UI cases that only repeat a rule a function already asserts. The cone shrinks when new bugs get a cheaper test, not when you add another selector.
Is a coverage percentage a good merge gate?
Answer
Alone, it is weak. A mocked suite can hit 100 percent and still miss the query. Prefer a risk split plus the flake rate of the default suite. Coverage is a flashlight for untested pure logic, not proof the wiring works.
Unit test or contract test for a REST client?
Answer
Both, for different failures. A unit test with a fake HTTP layer checks how the client handles a body it already believes in. A provider contract checks that the provider still produces a body the consumer can use. Neither replaces an integration test of your own handler. The next page is the contract half.
What did Testcontainers change?
Answer
They made real-database integration cheap enough to be the default for repository tests. Teams could stop mocking the cursor and still finish a pull request. The discipline left is runtime: do not put a five-second container test on every keystroke, and do not share one polluted database across tests.
What runs on save, on the pull request, and on main?
Answer
On save: unit tests and the fast integration slice. On the pull request: full integration plus contracts. On main or release: the thin end-to-end smokes. If end-to-end is the only suite anyone runs, the pyramid has already collapsed.
When is end-to-end worth the cost?
Answer
When the bug needs a real browser, a cookie, a redirect, or a journey that no single service test can see. One smoke for checkout can be worth minutes of CI. Forty smokes for forty field combinations are not. Push the combinations down.
What do you say in the first minute?
Answer
Unit is fast and narrow. Integration uses real collaborators and catches SQL and wiring. End-to-end is a few journeys, slow and easier to flake. The pyramid piles effort on units. The trophy piles it on integration. The ice-cream cone, mostly end-to-end, is the shape to leave.
Pitfalls
- Calling a Spring or database boot a unit test.
- Mocking the repository whose SQL is the bug you fear.
- Re-testing every discount boundary in Playwright.
- Gating merges on coverage while the flake rate climbs.
- Running the full container suite on every save and then skipping tests locally.
- Treating the trophy as permission to delete pure unit tests.
Take a "free shipping over 50 dollars" rule. Write the assertion you would keep as a unit test, the one extra assertion an integration test adds about the order row, and the single journey an end-to-end smoke is allowed to cover. Name which layer you delete if CI time doubles.