Explain the test pyramid, the ~70/20/10 ratio, and the ice-cream-cone anti-pattern.
- The pyramid is roughly 70% unit, 20% integration and system, 10% E2E — cost, runtime and flakiness all rise as you go up.
- But you don't pick that ratio. It falls out, because each level counts a different thing.
- Unit counts input variations, integration counts boundaries between components, system counts distinct whole-stack outcomes, E2E counts business-critical journeys.
- Inverting it — few unit tests, many slow E2E tests — is the ice-cream-cone anti-pattern: slow pipelines, flaky failures, and you can't tell which layer broke.
The long answer
The ratio. Roughly 70% unit, 20% integration + system combined, 10% E2E. Originally from Mike Cohn’s Succeeding with Agile, popularised by Martin Fowler. It’s a widely-cited rule of thumb, not a standard — the exact split varies by project.
Why the shape, per Fowler. High-level tests are a second line of defence, not a coverage mechanism. If a high-level test catches a bug, that means a unit test was missing — so you add the unit test rather than growing the high-level suite. Google’s engineering blog frames the same idea as small/medium/large tests, sized by scope, with medium and large deliberately kept few.
The mechanism — you never trim a big list down to 20%. You count three different things:
LevelWhat you countWhy the number lands where it does UnitInput variations — positive, negative, boundary, data-drivenOne service can easily have 40+; ×10 services = hundreds IntegrationBoundaries between componentsA feature usually has ~9 boundaries, ~3 tests each SystemDistinct whole-stack outcomesUsually ~3: succeeds, legitimately refused, dependency down E2EBusiness-critical journeys1–2 only
Nobody chose 70/20/10. Counting input variations gives a large number; counting boundaries and outcomes gives a small one. The ratio is emergent.
Worked example — Uber “request a ride.” Components: rider app, API gateway, ride service, pricing service, matching service, driver-location store, payment service, notification service, rides DB, maps provider.
-
Unit (inside pricing alone): base fare per city, distance tier boundaries, surge at 1.0/1.4/2.5/cap, minimum fare floor, night-rate window edges, rounding, currency → 40+ tests, mocked, milliseconds.
-
Integration (one per boundary): ride→DB writes the row; ride→pricing sends coords and parses the fare; ride→matching hands off and receives candidates; matching→location store geo-query respects radius; ride→payment pre-auth approved; ride→payment card declined; notification→push provider; gateway→ride rejects expired token; ride→maps parses ETA and handles timeout → ~11 tests.
-
System (everything up): ride requested and driver assigned; no drivers in radius; card declined → 3 tests.
-
E2E: request → accept → trip starts → completes → payment captured → receipt. 1 test, nightly.
Component discovery (needed before you can count boundaries). A component is anything that owns data or behaviour the caller can’t see inside — if you have to ask it, it’s separate. In a real job you read them off the architecture diagram, the repo list, the deploy manifests, and the API specs, then confirm with devs because diagrams rot. In an interview you trace the request as a story: what’s the user action, what data does it need that the entry point doesn’t own, what side effects must happen, and what crosses an org boundary into someone else’s uptime.
Three tests per boundary. Per pair of talking components: (1) happy handoff — valid request, accepted, reply correctly parsed; (2) legitimate refusal — the receiver says no for a real reason (expired token, no drivers found, card declined) and the caller handles that reply; (3) unavailable — receiver down or timing out, caller degrades instead of crashing. (This count is a practical heuristic, not a cited standard.)
Shape vs value — why integration tests don’t multiply. A new integration test is justified when the shape of the message changes, not the value inside it. Shape means structure and format: field names, data types, which fields are present or absent, format conventions (date format, money in rupees vs paise). Surge 1.4 vs 2.5 crosses the boundary identically — same field, same type, same parsing path — so it exercises zero new code at that level. It belongs in pricing’s unit tests, where it runs in milliseconds.
What integration tests deliberately do NOT check. Whether the receiver computed the right answer. That’s the receiver’s own unit tests. Integration tests the conversation, not the participants. The classic bug it exists to catch: the sender returns money in cents, the caller reads it as rupees — both components individually correct, feature broken.
Levels vs activities. Functional testing splits on two axes, not one. Levels (scope): unit, integration, system, E2E. Activities layered across those levels: smoke, sanity, regression, retesting, verification, validation, and input dimensioning. Non-functional splits differently — by quality attribute (performance, security, usability, reliability), not by scope.
Integration vs system, the dividing line. Count how many components must be running. A couple up → integration. Everything up → system. Not about browser vs API, not about UI vs backend — an integration test can be two front-end components with no backend at all. The browser correlation is a tooling habit, not the definition.
Ice-cream-cone anti-pattern. The inverted shape: few unit tests, many slow E2E tests. Pipelines take hours, failures are flaky because everything depends on the whole stack being up, and diagnosis is hard because a failing E2E test could be any layer underneath.
CI mapping — why speed justifies the shape. Unit runs on every push, seconds. Integration and system run on PR/merge, gated. E2E runs nightly or pre-release. If E2E had hundreds of tests like unit does, nobody could ship.
How to actually decide what to write, at each level
Where do positive / negative / boundary / data-driven belong? Overwhelmingly at unit level. Because unit tests are cheap and isolated, you throw the full input-dimensioning treatment there — dozens of positive/negative/boundary/data-driven variations per function. Integration and system tests do not repeat this. Those boundary values were already proven correct at unit level; integration and system tests exist to prove something unit tests structurally cannot — that real components are wired together correctly, and that the whole stack behaves correctly end-to-end. Re-running every input combination through a real database or a real browser would be redundant and slow: it re-tests logic that’s already covered, not wiring.
So how many test cases do you actually write at integration level? Not “however many input combinations exist” — one small set per boundary, not per input. The practical count is 3 to 7 per boundary: happy handoff (valid request, correctly parsed), legitimate refusal (receiver says no for a real reason — expired token, no drivers, card declined), and unavailable (receiver down or timing out, caller degrades gracefully instead of crashing). A high-stakes boundary like a payment gateway might extend to 4–7: happy, declined, credentials-rejected (401), unavailable, slow/timeout, duplicate (idempotency). A low-stakes boundary might only need the 3 core ones. This count is a practical heuristic, not a cited industry standard — the cited anchor is Fowler’s definition of an integration test: proving separately-developed modules work together, not re-verifying their internal logic.
How do you actually write a system test case? Three criteria, in order:
-
List the endings. Where can this feature finish, with the whole stack running? One test per distinct ending.
-
Split an ending further only if a different component or mechanism caused it. Two endings that look the same to the user but were rejected by different parts of the system each earn their own test — because they exercise different code paths.
-
For each test, assert two things: what the user sees on screen, AND what state got left behind in the database or storage — the post-conditions.
Worked example — PDF uploader, system level:
#EndingScreen showsState check (post-condition) 1AcceptedSuccessFile retrievable, row created 2Wrong typeType errorNo row left behind 3Too bigSize errorNo row left behind — different mechanism (gateway-level size limit, not the parser) 4Storage failedErrorNo orphan row
Four tests, not four input variations. You never listed inputs at this level — you listed outcomes, and for each one you checked both the screen and the database.
Post-conditions — the term itself. In a formal test case, post-conditions are the state the system should be in after the test finishes. This shows up two ways: as assertions (“no orphan row exists,” “the file is retrievable” — the sense used above), and as cleanup (“delete the uploaded file,” “release the payment hold” — also called teardown, so the next test isn’t polluted). The full formal test-case field list, worth knowing cold: Test ID, Title, Preconditions, Test Data, Steps, Expected Result, Post-conditions, Actual Result, Status.
Notice the asymmetry across levels: unit tests barely have post-conditions, because nothing real was touched — nothing to verify or clean up. System tests are where post-conditions get heavy, because real state got written. That’s a second, independent reason system tests cost more and stay few — not just that they’re slow, but that they require checking and resetting real state.
The one-paragraph version to say out loud when asked “how do you decide what to test at each level”: “At unit level, I mock everything the function doesn’t own, and I throw the full positive/negative/boundary/data-driven treatment at it, because it’s cheap. At integration level, I stop testing input variety — that’s already proven — and instead I test each real boundary between components, roughly three to seven cases per boundary, covering the handoff succeeding, being legitimately refused, and being unavailable. A new integration test only gets added when the message shape changes, not the value. At system level, I stop testing boundaries too, and instead I list the distinct endings the whole stack can produce, split further only when a different component caused the same-looking ending, and for each one I check both what the user sees and what got left in the database.”
Justifying the ratio with only "unit tests are cheaper." The interviewer wants to hear that integration and system tests exist to prove *boundaries and whole-stack behaviour*, not to re-verify logic already covered below — and that the ratio is emergent from counting different things, not a quota you enforce.