Test Pyramid: Balance Unit, Integration and E2E Tests
How to structure a test suite using the test pyramid. Covers unit, integration, and E2E test proportions, the testing trophy, and the ice cream cone anti-pattern.
Overview
The test pyramid, coined by Mike Cohn in Succeeding with Agile (2009) and later popularized by Martin Fowler’s bliki, prescribes the proportion of tests at each level of abstraction. Unit tests sit at the wide base: they exercise a single function or class in milliseconds and cost almost nothing to run. Integration tests occupy the narrower middle: they cross a real boundary such as a database, a filesystem, or an HTTP client. End-to-end tests form the tip: they boot the whole application and drive it through a browser or API client, so a handful of them can cost more than thousands of unit tests combined.
The point isn’t the geometry — it’s the economics underneath. Tests that run fast can run on every commit; tests that take minutes only make sense as a final confidence check before a deploy. If you get the proportions right, developers see failures seconds after they introduce them, and the suite stays cheap enough that nobody argues for deleting it. Get them wrong and you inherit a suite that’s slow, flaky, and quietly ignored.
A concrete case: a 400-test suite where 300 are unit tests, 80 are integration tests, and 20 are E2E tests will typically finish in under five minutes. The same coverage expressed as 200 E2E tests can take an hour and break on timing alone.
For a broader view of how the pyramid fits into a complete strategy — coverage goals, flakiness budgets, ownership — see the Software Testing Strategy Guide.
When to Use
| Context | Why it fits |
|---|---|
| CI/CD pipelines | Fast feedback loops: unit tests run in seconds on every commit |
| Teams refactoring frequently | Cheap unit tests give confidence without slowing iteration |
| Microservices | Per-service pyramids keep suite runtime bounded as services grow |
| Long-lived projects | E2E tests are expensive to maintain; the pyramid minimizes them |
| Flaky test problems | A pyramid reduces dependence on timing-sensitive E2E tests |
One caveat worth stating up front: the pyramid is a default for typical business applications, not a universal law. UI-heavy frontends and microservice architectures often do better with the variants described below.
When NOT to Use
- UI-heavy frontends: Component and integration tests often give better ROI than pure unit tests for user interfaces.
- Microservices: The testing honeycomb (more integration tests around service edges) may fit better than a strict pyramid.
- Prototypes: If code will be thrown away next week, a full pyramid is over-investment.
- Simple CRUD applications: With almost no business logic, a small integration suite plus a few E2E smoke tests may be enough.
Problem
Most teams don’t choose an unbalanced suite — they drift into one. E2E tests feel like “real” testing, so every bug report turns into another browser test. Six months later the suite takes 45 minutes, fails twice a week for reasons nobody can reproduce, and developers start pushing without waiting for CI. Coverage numbers look fine while actual confidence collapses.
The test pyramid attacks this drift by making the cost structure of a suite explicit. It gives a team a shared vocabulary — “this belongs at the unit level” — for arguing about where a test should live before it’s written, instead of after it’s already gone flaky.
Solution
The classic test pyramid
Each layer trades speed for confidence. A unit test tells you which function broke, almost instantly. An E2E test tells you something broke, eventually, and leaves you to find out where. The pyramid keeps the fast, precise signal dominant while reserving the slow, high-confidence checks for the critical paths.
Unit tests — the base
Fast, isolated, deterministic. No I/O, no network, no database:
// calculator.test.js — Vitest / Jest
import { describe, it, expect } from 'vitest';
import { parsePrice, applyDiscount } from './pricing.js';
describe('parsePrice', () => {
it('parses a valid decimal price', () => {
expect(parsePrice('19.99')).toBe(19.99);
});
it('rejects non-numeric input', () => {
// toThrow accepts an Error class, an instance, or a message substring
expect(() => parsePrice('abc')).toThrow('Invalid price');
});
it('rejects negative prices', () => {
expect(() => parsePrice('-5')).toThrow('Invalid price');
});
});
describe('applyDiscount', () => {
it('applies a percentage discount', () => {
expect(applyDiscount(100, 0.1)).toBeCloseTo(90);
});
it('never returns a negative total', () => {
expect(applyDiscount(50, 1.5)).toBe(0);
});
});
# test_pricing.py — pytest
import pytest
from pricing import parse_price, apply_discount
class TestParsePrice:
def test_parses_valid_decimal(self):
assert parse_price('19.99') == 19.99
def test_rejects_non_numeric(self):
with pytest.raises(ValueError, match='Invalid price'):
parse_price('abc')
class TestApplyDiscount:
def test_applies_percentage(self):
assert apply_discount(100, 0.1) == pytest.approx(90)
def test_never_negative(self):
assert apply_discount(50, 1.5) == 0
Integration tests — the middle
Test how components work together. Use real dependencies where possible — a real test database instead of a mocked one, a mock server instead of a mocked HTTP client:
// orderRepository.integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { OrderRepository } from './OrderRepository';
import { createTestDatabase } from './testDatabase';
describe('OrderRepository (integration)', () => {
let db: TestDatabase;
let repo: OrderRepository;
beforeEach(async () => {
db = await createTestDatabase(); // real Postgres in Docker
repo = new OrderRepository(db);
});
afterEach(() => db.teardown());
it('persists and retrieves an order', async () => {
const order = await repo.create({ userId: 'u1', total: 99.5 });
const found = await repo.findById(order.id);
expect(found).toMatchObject({ userId: 'u1', total: 99.5 });
});
it('enforces unique order ids', async () => {
await repo.create({ id: 'o1', userId: 'u1', total: 10 });
await expect(
repo.create({ id: 'o1', userId: 'u2', total: 20 }),
).rejects.toThrow(/unique/i);
});
});
# test_order_repository.py — pytest + testcontainers
import pytest
from order_repository import OrderRepository, DuplicateOrderError
@pytest.fixture
def repo(postgres_container):
db = postgres_container.get_connection()
db.run_migrations()
yield OrderRepository(db)
db.teardown()
def test_persists_and_retrieves_order(repo):
order = repo.create(user_id='u1', total=99.5)
found = repo.find_by_id(order.id)
assert found.user_id == 'u1'
assert found.total == 99.5
def test_enforces_unique_order_ids(repo):
repo.create(id='o1', user_id='u1', total=10)
with pytest.raises(DuplicateOrderError):
repo.create(id='o1', user_id='u2', total=20)
E2E tests — the tip
Test complete user flows only. Keep them few and focused on business-critical paths:
// checkout.e2e.spec.ts — Playwright
import { test, expect } from '@playwright/test';
test.describe('Checkout flow', () => {
test('user completes a purchase', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="product-1"]');
await page.click('[data-testid="add-to-cart"]');
await page.click('[data-testid="checkout"]');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="card"]', '4242424242424242');
await page.click('[data-testid="pay"]');
await expect(page.locator('.order-confirmation'))
.toContainText('Order confirmed');
});
});
# test_checkout_e2e.py — Playwright Python
from playwright.sync_api import sync_playwright, expect
def test_user_completes_purchase():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto('http://localhost:3000/products')
page.click('[data-testid="product-1"]')
page.click('[data-testid="add-to-cart"]')
page.click('[data-testid="checkout"]')
page.fill('[name="email"]', 'test@example.com')
page.click('[data-testid="pay"]')
expect(page.locator('.order-confirmation')) \
.to_contain_text('Order confirmed')
browser.close()
The testing trophy
Kent C. Dodds proposed the testing trophy for frontend work, building on Guillermo Rauch’s tweet: “Write tests. Not too many. Mostly integration.” It shifts the emphasis: a static-analysis base, a dominant integration layer, fewer unit tests, and a minimal set of E2E tests.
It fits JavaScript frontends where React Testing Library-style tests exercise components the way users experience them — rendering, clicking, asserting on the DOM — which sits between classical unit and E2E. Dodds details the classification in The Testing Trophy and Testing Classifications.
The ice cream cone anti-pattern
What happens when the proportions invert:
Suites like this look thorough and behave terribly: hours of runtime, failures that appear and disappear with timing, and a maintenance bill that grows with every UI change. Google’s testing blog argued the underlying point in Just Say No to More End-to-End Tests: once you’ve got fast tests underneath, most end-to-end checks add little signal.
Test type decision guide
Some rules of thumb that pair well with the flow:
- Unit: business logic, validation, calculations, state machines, edge cases.
- Integration: API contracts, database queries, auth flows, message queues.
- E2E: registration, checkout, the core revenue path — journeys where a failure means losing money or users.
Measuring your pyramid
Count tests by type and compare the distribution:
# Rough count by directory convention
echo "Unit: $(find tests/unit -name '*.test.*' | wc -l)"
echo "Integration: $(find tests/integration -name '*.test.*' | wc -l)"
echo "E2E: $(find tests/e2e -name '*.spec.*' | wc -l)"
# report_test_distribution.py
from pathlib import Path
import re
def count_tests(directory: str, pattern: str) -> int:
path = Path(directory)
if not path.exists():
return 0
return sum(
len(re.findall(r'\b(it|test|def test_)\s*\(', f.read_text()))
for f in path.rglob(pattern)
)
unit = count_tests('tests/unit', '*.py')
integration = count_tests('tests/integration', '*.py')
e2e = count_tests('tests/e2e', '*.py')
total = unit + integration + e2e or 1
print(f"Unit: {unit:4} ({unit/total:.0%})")
print(f"Integration: {integration:4} ({integration/total:.0%})")
print(f"E2E: {e2e:4} ({e2e/total:.0%})")
if e2e / total > 0.15:
print("\nWARNING: E2E tests exceed 15% — check for ice cream cone")
CI/CD integration
Run layers at different pipeline stages:
# .github/workflows/tests.yml
name: Test Pyramid
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:unit # < 30 seconds
integration:
runs-on: ubuntu-latest
needs: unit
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration # < 3 minutes
e2e:
runs-on: ubuntu-latest
needs: integration
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build && npm run start &
# wait-on polls the URL; no fixed sleep that flakes on slow runners
- run: npx wait-on http://localhost:3000 --timeout 30000
- run: npm run test:e2e # < 10 minutes
Explanation
Why the proportions exist
The pyramid’s shape follows from two costs that scale in opposite directions. Execution cost rises as you climb: a unit test finishes in microseconds, an integration test takes tens to hundreds of milliseconds while it talks to a real database, and an E2E test takes seconds to minutes because it boots a browser, a server, and real data. Diagnostic cost does the reverse: a failing unit test names the function that broke; a failing E2E test tells you the purchase flow is broken somewhere across a dozen services, and you still have to go find it.
When the base is thick, most defects get caught by tests that are both fast to run and precise to diagnose. When the tip is heavy, the same defects get caught late, slowly, and vaguely — and every UI refactor breaks a pile of tests that were checking implementation incidentals rather than behavior.
The ratios are a starting point, not a quota
Cohn never meant 70/20/10 as a contract. The right distribution follows risk, not geometry. A billing service with heavy calculation logic wants a deep unit layer; a thin API gateway has almost no logic to unit-test and earns its keep in contract and integration tests. The useful discipline is asking “where can this failure be caught cheapest?”, not auditing percentages.
Two signals matter more than the ratio itself: total suite runtime (if developers wait more than a few minutes, they stop waiting) and the flake rate (if the same test fails intermittently, it’s teaching the team to ignore failures). A suite that scores well on those two is healthy whatever its shape.
Comparing the shapes
| Shape | Emphasis | Best fit |
|---|---|---|
| Pyramid | Unit ≫ integration ≫ E2E | Typical backend and full-stack apps |
| Trophy | Integration first, static base, thin E2E | JavaScript frontends, component-heavy UIs |
| Honeycomb | Integration around service edges | Microservices with real dependencies in test |
| Ice cream cone | Manual + E2E dominant | Anti-pattern — what drift produces |
None of them dispute the underlying economics; they disagree about where the confidence-per-second is highest for a given kind of system.
Migrating away from an ice cream cone
If your suite is already inverted, rewriting is rarely viable — migrate incrementally:
- Inventory: tag every test by layer and runtime. Most teams discover a handful of E2E tests produce most of the wall-clock time.
- Quarantine flakes: move intermittent tests to a non-blocking job with a deadline to fix or delete. Don’t let them keep poisoning CI.
- Characterize the critical paths: for the flows the cone actually protects, write the missing unit and integration tests first — so coverage exists before you delete anything.
- Delete downward: remove E2E tests whose scenarios are now covered at lower layers. Keep only the journeys that genuinely need the full stack.
- Ratchet: enforce budgets in CI — e.g., E2E count and runtime caps — so the suite can’t silently invert again.
Variants
Testing honeycomb
Proposed by André Schaffer and Rickard Dybeck for Spotify’s microservices (Testing of Microservices). In a service whose meaningful complexity lives in its interactions, the honeycomb inverts the pyramid: many integration tests around the service’s own edges, a few “implementation detail” tests for genuinely isolated logic, and almost no “integrated” tests that depend on other live systems.
Component tests
A middle layer between unit and E2E, common in React, Vue, and Angular applications:
// UserProfile.component.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
it('saves profile changes', async () => {
const onSave = vi.fn();
render(<UserProfile user={mockUser} onSave={onSave} />);
await userEvent.type(screen.getByLabelText('Name'), 'Ada');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith({ ...mockUser, name: 'Ada' });
});
Smoke tests
A minimal E2E layer for deployment verification:
# smoke-test.sh — run after every deploy
curl -sf https://api.example.com/health || exit 1
curl -sf https://api.example.com/api/status | jq -e '.db == "up"' || exit 1
echo "Smoke tests passed"
Best Practices
- Push tests down whenever possible — When a bug slips through, add the cheapest test that would have caught it; that’s almost never an E2E test.
- Keep unit tests hermetic — No I/O, no network, no shared mutable state. A unit test that touches a database is an integration test with a misleading name.
- Use test doubles deliberately — Mocks at layer boundaries, not inside the unit under test. See the Test Double pattern for when stubs, mocks, and fakes each earn their complexity.
- Time-box each layer — Unit under 30 seconds, integration under 3 minutes, E2E under 10 minutes. When a layer blows its budget, that’s signal: tests are accumulating in the wrong place.
- Tag tests by layer —
@pytest.mark.integration,test.describe('e2e'), directory conventions. Tagging is what makes per-layer budgets and reporting possible. - Delete flaky tests immediately — A test that fails randomly trains the team to ignore the whole suite. Quarantine, fix, or remove it within days, not quarters.
Companion code: The test-pyramid-pattern companion repo has runnable unit tests in JavaScript and Python, the Playwright E2E spec, the distribution report script, and the layered CI workflow.
Common Mistakes
- The ice cream cone: Inverting the pyramid — mostly E2E tests and manual QA. Result: slow suites, constant flakes, and releases gated by fear.
- Unit-testing trivial code: Getters, setters, and pass-through methods don’t need dedicated tests; they get covered for free by tests of real behavior.
- The E2E Swiss Army knife: Using browser tests to cover edge cases. Boundary conditions belong in unit tests where they run in microseconds.
- Excessive mocking: Mocking so much of the system that tests only verify the mocks. When a test needs more than two or three doubles, it usually wants to be an integration test instead.
- Frozen distribution: Setting 70/25/5 as a permanent target instead of a starting hypothesis. Re-measure as the architecture changes.
- E2E without the layers below: Running only end-to-end tests produces failures that take hours to diagnose — the worst combination of slow and vague.
See Also
- The Test Pyramid — Martin Fowler’s bliki on the model, with the confidence-versus-speed framing.
- Succeeding with Agile — Mike Cohn’s book where the test automation pyramid was introduced.
- Write tests. Not too many. Mostly integration. — Kent C. Dodds unpacking Guillermo Rauch’s quote that inspired the trophy.
- The Testing Trophy and Testing Classifications — the trophy model for frontend testing.
- Testing of Microservices — the honeycomb at Spotify.
- Just Say No to More End-to-End Tests — Google Testing Blog on why E2E-heavy suites fail.
Frequently Asked Questions
How do I split tests between unit, integration and E2E?
Use the pyramid's default — roughly 60–70% unit, 20–30% integration, 5–10% E2E — as a starting hypothesis, then let risk decide. Put each test in the cheapest layer that can catch the failure it targets: business rules belong at the base, boundary behavior in the middle, and only critical user journeys at the tip.
What's the difference between the pyramid and the testing trophy?
The trophy, from Kent C. Dodds, puts integration tests at the widest layer instead of unit tests and adds a static-analysis base (types, linting). It targets frontend JavaScript, where Testing Library-style tests blur the unit/integration boundary. The pyramid remains the better default for backend services.
How do I fix an inverted test pyramid (ice cream cone)?
Incrementally: inventory tests by layer and runtime, quarantine the flakes, write unit and integration coverage for the critical paths first, then delete the redundant E2E tests — and enforce layer budgets in CI so the suite stays fixed.
Should microservices use the test pyramid?
Usually a per-service variant. Spotify's honeycomb keeps many integration tests around each service's own edges — its API, its database, its message consumers — and almost none that boot other live services. The full-system pyramid still applies, but each service carries its own smaller shape inside it.
How many E2E tests is too many?
A practical ceiling is 5–10% of the suite. The sharper test is intent: if an E2E test checks something a unit or integration test could verify — a validation rule, a calculation — it belongs lower in the pyramid.
Do integration tests need real databases?
Wherever practical, yes — a real Postgres in a container catches SQL bugs that mocks structurally can't see (constraint violations, query semantics, type coercion). Mock at process boundaries you don't control: third-party APIs, payment gateways, email providers.
Related Resources
Test Double: Replace Dependencies with Stubs, Spies, Fakes
How to use test doubles to isolate units under test. Covers stubs, spies, fakes, mocks, and dummy objects with examples in Python, JavaScript, and Java.
PatternContract Testing: Verify Consumer-Producer API Contracts
How to use contract testing to verify that API producers and consumers agree on request and response shapes. Covers Pact consumer-driven contracts and provider verification.
PatternSnapshot Testing: Capture and Compare Serialized Output
How to use snapshot testing to detect unintended changes in serialized output. Covers Jest snapshots, pytest-snapshot, syrupy, and inline vs external snapshots.
PatternGolden Master Testing: Characterize Legacy Code Safely
How to use golden master testing to characterize legacy code behavior before refactoring. Covers capturing output, comparing baselines, and incremental refactoring.
PatternMock Server: Stand Up a Mock Server for Integration Test
How to use mock servers to isolate integration tests from external dependencies. Covers WireMock, nock, MSW, and Mountebank with configuration examples.
GuideSoftware Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.