StackPractices
intermediate By Mathias Paulenko

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.

Topics: testing

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

ContextWhy it fits
CI/CD pipelinesFast feedback loops: unit tests run in seconds on every commit
Teams refactoring frequentlyCheap unit tests give confidence without slowing iteration
MicroservicesPer-service pyramids keep suite runtime bounded as services grow
Long-lived projectsE2E tests are expensive to maintain; the pyramid minimizes them
Flaky test problemsA 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

Test pyramid with three layers: a wide base of unit tests at roughly 60 to 70 percent, a narrower middle of integration tests at 20 to 30 percent, and a small tip of end-to-end tests at 5 to 10 percent.

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.

Testing trophy with four layers: a small end-to-end tip, a large integration layer in the middle, a smaller unit layer, and a wide static-analysis base of types and linting.

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:

Inverted ice cream cone shape: a large scoop of manual testing on top, then a wide layer of end-to-end tests, and small amounts of integration and unit tests at the bottom.

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

Decision flowchart for choosing a test type. Pure logic without dependencies leads to unit tests. Crossing a database or network boundary leads to integration tests. A critical user journey leads to an end-to-end test. Slow or flaky tests should be pushed down to a lower layer.

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

ShapeEmphasisBest fit
PyramidUnit ≫ integration ≫ E2ETypical backend and full-stack apps
TrophyIntegration first, static base, thin E2EJavaScript frontends, component-heavy UIs
HoneycombIntegration around service edgesMicroservices with real dependencies in test
Ice cream coneManual + E2E dominantAnti-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:

  1. Inventory: tag every test by layer and runtime. Most teams discover a handful of E2E tests produce most of the wall-clock time.
  2. Quarantine flakes: move intermittent tests to a non-blocking job with a deadline to fix or delete. Don’t let them keep poisoning CI.
  3. 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.
  4. Delete downward: remove E2E tests whose scenarios are now covered at lower layers. Keep only the journeys that genuinely need the full stack.
  5. 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.

Testing honeycomb distribution: a large layer of integration tests in the middle, a smaller amount of implementation-detail tests, and very few or no integrated tests that depend on other live services.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Tag tests by layer — @pytest.mark.integration, test.describe('e2e'), directory conventions. Tagging is what makes per-layer budgets and reporting possible.
  6. 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

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.