The Testing Pyramid: Unit, Integration, and E2E Tests
Unit, integration, and end-to-end tests differ by scope: a unit test checks one function in isolation, an integration test checks that several real modules work together, and an end-to-end (E2E) test drives the fully assembled application the way a user would. The testing pyramid is the guideline for balancing them: many fast unit tests at the base, fewer integration tests in the middle, and a small set of E2E tests at the top for your most critical journeys.
What is the difference between unit, integration, and end-to-end tests?
The clearest way to tell the three apart is to ask how much of the system is real when the test runs.
- Unit test — Exercises a single function, method, or class with its dependencies replaced by mocks or stubs. It answers: "Does this piece of logic behave correctly in isolation?" A price-rounding function, a date parser, or a reducer are classic unit-test targets.
- Integration test — Exercises two or more real components together, with no mock at the seam you care about. It answers: "Do these parts agree on the contract between them?" Examples: your repository layer against a real (or in-memory) database, or an API handler calling a real validation module.
- End-to-end test — Drives the whole running app — frontend, backend, and data store — through the same interface a user or client uses, typically a real browser. It answers: "Can a person actually complete this task?" Sign-up, checkout, and login flows are the usual candidates.
A useful rule of thumb: the more of the real system a test touches, the more confidence it gives you when it passes, and the more places it can break for reasons unrelated to your change.
What is the testing pyramid?
The testing pyramid, popularized by Mike Cohn, is a shape that describes a healthy distribution of tests. Unit tests form the wide base because they are cheap, fast, and precise. Integration tests sit in a narrower middle band. E2E tests form the small tip because each one is slow and brittle relative to what it verifies.
The pyramid is a guideline, not a law — but the ordering is what matters. Fast, isolated tests should dominate because they give you feedback in milliseconds and point directly at the broken line of code. Slow, whole-system tests should be rare because a failure tells you "something in this journey broke" without saying where.
The ice-cream cone anti-pattern
When teams invert the pyramid — a handful of unit tests and a heavy top layer of E2E and manual tests — you get the "ice-cream cone." The symptoms are familiar: slow CI, tests that fail intermittently, and a suite people re-run instead of trust. If your E2E tests outnumber your unit tests, that is the pattern to unwind.
When should you use each type of test?
Match the test type to the kind of bug you are trying to catch, because each layer catches something the others cannot.
- Reach for a unit test when the risk is in the logic. Branching rules, calculations, parsing, and edge cases (empty input, negative numbers, timezone boundaries) belong here. These are the tests you write the most of because logic bugs are the most common and the cheapest to pin down.
- Reach for an integration test when the risk is at a seam. Mismatched API contracts, wrong SQL, serialization errors, and misconfigured clients slip past unit tests precisely because unit tests mock those seams away. An integration test uses the real collaborator so the contract is actually verified.
- Reach for an E2E test when the risk is a broken user journey. A route that no longer renders, a form that submits to the wrong endpoint, or a login that silently fails are things only the assembled app reveals. Keep this list short: the flows where a break costs you revenue or trust.
How many of each test should you have?
There is no correct universal ratio, and chasing a specific number is a distraction. What holds across teams is the shape: unit tests most numerous, integration tests fewer, E2E tests fewest. Instead of a target count, use these signals:
- Speed of feedback — If your test suite takes so long that people stop running it locally, you are too heavy at the top. Push coverage down into faster layers.
- Where failures point — If most failures require debugging to locate the cause, you are relying too much on E2E tests. A good unit test fails with the exact function named.
- Flakiness — A test that fails and passes without a code change is worse than no test, because it trains people to ignore red. Flakiness clusters in the E2E layer, which is another reason to keep it small.
For a deeper comparison of the frameworks that run the E2E layer, see our guide on Playwright vs. Cypress.
Why do end-to-end tests break so often — and what to do about it?
E2E tests are fragile by nature: they depend on selectors, network timing, and shared test data, any of which can change without your logic being wrong. A renamed button, a slow API response, or a leftover record from a previous run can turn a passing suite red. That is the tax you pay for testing the whole system at once.
Three practices keep the E2E layer maintainable:
- Test journeys, not pages. Write E2E tests around complete user goals — "a new user can sign up and reach the dashboard" — rather than asserting on every element of every screen. Fewer, higher-value tests break less and mean more.
- Wait on state, not on time. Replace fixed sleeps with waits for a specific element or network response. Timing-based waits are the single largest source of E2E flakiness.
- Let selectors self-heal. When your UI changes, a renamed or restructured element shouldn't fail an otherwise-correct test. Self-healing tests re-identify the element and keep going, which is exactly what Klavity AutoSim does for the E2E layer so a UI refactor doesn't turn your whole suite red.
Putting it together
Think of the three layers as a division of labor, not competitors. Unit tests prove your logic is correct, integration tests prove your modules agree, and E2E tests prove a real person can get through the door. Weight your suite toward the fast, precise base; keep the slow, whole-system tip small and focused on the journeys you cannot afford to break. When an E2E test does fail, the goal is to know within minutes whether it caught a real regression or just a moved selector — and to keep the ones that matter green.
Key takeaways
- Keep unit tests fast and numerous — they are your first line of defense
- Use integration tests to catch wiring and contract bugs between modules
- Reserve E2E tests for critical user journeys, not every feature
- If E2E tests outnumber unit tests, you have an ice-cream cone to fix
FAQ
What is the difference between unit, integration, and end-to-end tests?
A unit test checks a single function or class in isolation, usually with its dependencies mocked. An integration test checks that two or more real components work together — for example, that your service layer talks to the database correctly. An end-to-end (E2E) test drives the fully assembled application through a real browser or client, exercising the same path a user would.
How many of each type of test should I have?
There is no universal ratio, but the pyramid shape holds: the largest number of tests should be fast unit tests, a smaller layer of integration tests, and the fewest E2E tests — reserved for critical journeys like sign-up, checkout, and login. If E2E tests outnumber your unit tests, you likely have an 'ice-cream cone' anti-pattern that will be slow and flaky.
Why do end-to-end tests break so often?
E2E tests touch the most moving parts — the UI, network, backend, and real timing — so any change to a selector, a slow response, or shared test data can fail them. That fragility is why teams keep the E2E layer small and increasingly use self-healing tests that repair broken selectors automatically instead of failing the whole suite.
Catch bugs the moment a human sees them
Klavity: right-click bug reports, AI personas that review your product, and self-healing tests.
Get started free