Software / Testing
The Testing Pyramid in 2026: Unit, Integration, and E2E Tests
The testing pyramid says keep many fast unit tests, a solid layer of integration tests, and a small set of end-to-end tests. Sixteen years on, the ratios have been argued over — trophy, honeycomb, and “just use E2E” alternatives all exist — but the underlying rule survives: most of your tests should be fast and close to your logic, and the slow, flaky end-to-end layer should cover only the handful of journeys your business cannot afford to break.
The Pyramid and Its Critics
Mike Cohn's pyramid (2009) was a rebalance, not a dogma: against a world of test suites dominated by slow browser tests, it asked for breadth at the base. The modern critiques are all variations on one insight — that unit tests alone can pass while production is broken, because the riskiest bugs live at the seams: wiring, configuration, serialization, and third-party contracts.
Kent C. Dodds's Testing Trophydrops the pyramid's emphasis on count and emphasizes placement: integration tests — component plus real dependencies, with test doubles only at the process boundary — are the trophy's bulk, unit tests cover pure logic, and a few E2E tests sit on top. Google's engineering literature describes a roughly 70/20/10 mix of small (unit), medium (integration), and large (E2E) tests — close enough to the pyramid to show the shape held, with the middle layer doing the heavy lifting.
What Belongs at Each Level
Unit: pure logic, no I/O
A unit test exercises one function or class with in-memory inputs and no database, network, clock, or filesystem. Price calculation, permission rules, parsers, state reducers, date arithmetic — anything deterministic. If your unit test mocks five modules to run, the unit is too large; if it mocks a fetch call, you are writing an integration test with extra steps. These are the tests you can run thousands of in under a second.
Integration: the seam, with real dependencies
An integration test renders the real component or handler against real routing, real serialization, and a real database — substituting test doubles only at the process boundary (a service worker like MSW for HTTP, a container via Testcontainers for Postgres, an in-memory broker). It answers the question unit tests cannot: “do these pieces actually connect?” This is where most regressions are caught cheaply, and why the trophy makes it the bulk of the suite.
E2E: critical journeys only
An end-to-end test drives a real browser through the deployed application: sign up, add to cart, pay, receive confirmation. Every additional E2E test multiplies maintenance and flake risk, so the budget is deliberately tiny — the four to six flows whose failure is a page-one incident. If your E2E suite has more than a few dozen specs, most of them belong one layer down.
| Level | Scope | Speed | Flake risk | Belongs here |
|---|---|---|---|---|
| Unit | One function/class | <10ms each | Near zero | Pure logic, rules, parsers |
| Integration | Module + real deps | 50–500ms each | Low | API handlers, DB, component wiring |
| E2E | Full stack in browser | 5–30s each | Highest | 4–6 business-critical journeys |
Verdict:weight tests by risk, not by ritual — a pyramid-shaped suite with an oversized integration middle (the trophy's proportions) is the pragmatic 2026 default: unit for logic, integration everywhere a seam exists, E2E for the journeys revenue depends on.
Practical Tooling
For frontend work the standard stack is now Vitest (or Jest in legacy repos) for unit and integration, Testing Library so tests interact the way users do — roles and accessible names, not class names — MSW to intercept HTTP at the boundary, and Playwrightfor E2E. Playwright effectively won the browser-test category after Cypress's decline: parallel workers, auto-waiting, API testing alongside UI, trace viewers, and first-class CI story from the same vendor that runs browsers in Docker.
import { test, expect } from "@playwright/test";
test("checkout completes with a saved card", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("correct horse battery staple");
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByTestId("sku-tee").click();
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByRole("link", { name: "Checkout" }).click();
await expect(
page.getByRole("heading", { name: "Order confirmed" })
).toBeVisible();
});
// Four to six journeys like this — never four hundred.
// CI config: retries: 1, trace: "retain-on-failure", video: off.Component-level integration tests with MSW cover the middle of the stack — no test IDs in the DOM of production code beyond the few the E2E journey genuinely needs:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "@/test/server";
import { PricingTable } from "./PricingTable";
test("annual billing shows the discounted total", async () => {
server.use(
http.get("/api/plans", () =>
HttpResponse.json({
plans: [{ id: "pro", monthly: 20, annual: 192 }],
})
)
);
const user = userEvent.setup();
render(<PricingTable />);
await user.click(screen.getByRole("radio", { name: "Annual" }));
expect(screen.getByText("$192/year")).toBeInTheDocument();
});For backend services, the mirror applies: milliseconds-fast unit tests for domain logic, and integration tests that boot the real database in a container (Testcontainers) instead of mocking the ORM. Mocked ORMs test your mock, not your queries — the single most common source of “all green, broken in production” suites.
Flaky Tests Are Worse Than Fewer Tests
A test that fails sometimes trains the team to ignore red — at which point the entire suite is decoration. Management is a policy, not a tool: quarantine a flaky test into a separate, non-blocking lane within one day; fix or delete it within one sprint; root-cause every repeat offender (sleeps instead of waits, shared state between tests, unseeded data, real clock dependency, uncontrolled network). Playwright's auto-waiting and one retry on CI contain most browser flake; everything else is test hygiene. Track flake rate as a first-class metric — a suite at 99% reliability is a suite nobody trusts for release gates.
Coverage: A Number to Distrust
Line coverage measures whether a line executed, not whether anything was asserted. 90% coverage with weak assertions is weaker than 60% with sharp ones, and coverage percentages silently reward the easy test. Use it as a gap detector — “which branches have no test at all?” — never as a gate. When you need a real signal of test quality, mutation testing (does breaking the logic actually fail a test?) is informative but expensive; running it on changed modules only keeps the cost sane. The better question for any test is: if this broke, would it fail?
CI Strategy: Where Each Layer Runs
Speed determines placement. Everything under a minute runs on every pull request; the expensive layers run where their latency is acceptable — E2E on merge, nightly, or both:
| Suite | Trigger | Budget | Blocks merge? |
|---|---|---|---|
| Unit | Every PR, every commit | <60s | Yes |
| Integration | Every PR | <5 min | Yes |
| E2E | Merge to main + nightly | <15 min | Yes, on main only |
| Quarantined flaky | Nightly | Report only | No — but ticketed |
What to Write for Each Kind of Change
| Change | Unit | Integration | E2E |
|---|---|---|---|
| Pricing / rule logic | Yes | One | — |
| UI component behavior | Only pure parts | Yes (RTL) | — |
| API endpoint / schema | Validation | Yes (real DB) | — |
| New critical journey | — | Steps | Yes, one spec |
| Third-party integration | Adapter logic | Contract test | Smoke only |
Conclusion
The pyramid never actually died — it was refined. What survives from 2009 is the structural advice (many fast tests, few slow ones); what the last decade added is placement discipline: integration tests at the seams are the highest-yield layer, browser tests belong only on the journeys that justify their cost, and trust — measured in flake rate, not line coverage — is the real health metric of a test suite. Write for the failure you are afraid of, put that test where it will actually catch the break, and delete the rest.
Key Takeaways
- The pyramid's shape holds; its proportions evolved into a trophy-style hybrid — unit for pure logic, integration as the bulk, a handful of E2E journeys.
- Integration tests replace doubles only at the process boundary (MSW for HTTP, Testcontainers for databases) — mocked ORMs test the mock.
- Playwright is the default E2E tool in 2026; cap the suite at the 4–6 flows whose breakage is a page-one incident.
- Flaky tests are worse than missing ones: quarantine within a day, fix or delete within a sprint, track flake rate as a metric.
- Coverage is a gap detector, not a gate — the only question that matters is whether a test would fail if the code broke.
FAQ
Is the testing pyramid still relevant?
Yes, as a principle rather than a ratio: fast, numerous tests close to your logic; fewer, slower tests over the whole stack. Modern alternatives like the Testing Trophy keep the pyramid's economics while weighting integration tests more heavily — the disagreement is about the middle layer, not the shape.
What is the difference between unit and integration tests?
A unit test exercises one function or class with in-memory inputs and no I/O. An integration test exercises a unit with its real dependencies — real routing, serialization, and usually a real database — substituting test doubles only at the process boundary. The dividing question is whether the test proves internal logic or proves wiring.
How many end-to-end tests should I have?
Few: typically 4–12 specs covering sign-up, core purchase or conversion flow, and the top admin operations. E2E tests are slow and the flakiest layer, so each one must justify maintenance cost against a journey whose failure would actually page someone.
Should I mock the database in integration tests?
No — use a real database in a container (Testcontainers is the standard approach) against a throwaway schema. Database mocks bypass query planning, constraints, and migration behavior, which are exactly the things integration tests exist to verify. Reserve mocks for external HTTP services, which you do not want hitting the network in CI.
Is 100% code coverage a good goal?
No. Coverage counts executed lines, not asserted behavior, so chasing 100% produces tests that run code without checking meaning — and teams that delete tests to keep a gate green. Use coverage to find untested branches, review the risky ones, and judge suite quality by whether failures are trustworthy and flake is rare.
References
- Fowler, M. "Test Pyramid." martinfowler.com bliki, 2018. martinfowler.com/bliki/TestPyramid.html
- Vocke, H. "The Practical Test Pyramid." martinfowler.com, 2018. martinfowler.com/articles/practical-test-pyramid.html
- Dodds, K. C. "Write tests. Not too many. Mostly integration." Kent C. Dodds, 2020. kentcdodds.com/blog/write-tests
- Microsoft. "Playwright — Fast and reliable end-to-end testing." playwright.dev/docs/intro
- Testcontainers. "Testcontainers — Boost your tests with real dependencies." testcontainers.com
- Google Testing Blog. "Posts on flaky tests and test sizes." testing.googleblog.com