The Test Pyramid: Where E2E Tests Really Belong
AI generated
PASS
expect()
Testing · Test Pyramid · E2E · CI/CD
The Test Pyramid: Where E2E Tests Really Belong
Cost, confidence, and the right split across unit, integration, and E2E

Teams that lean on end-to-end tests with Cypress or Playwright as their main safety net pay for it with slow test runs, high flakiness, and expensive maintenance. The test pyramid explains why most test coverage should come from fast, cheap unit and integration tests, while E2E tests deliberately secure only the most critical user paths end to end, building confidence without dragging down the continuous integration pipeline.

12 min read Test Pyramid · Unit · Integration · E2E Cypress · Playwright · CI/CD

1. The test pyramid at a glance

The test pyramid was introduced by Mike Cohn in the late 2000s as a simple mental model, and it remains the central point of reference for structuring a test suite today. The core idea: a broad base of many fast unit tests, a smaller number of integration tests in the middle, and just a few end-to-end tests at the top that verify the system as a whole through the user interface. This shape is not arbitrary, it reflects a direct relationship between test tier, execution speed, and maintenance effort. The closer a test sits to the real user interface and the full system environment, the more expensive each individual run becomes, both in compute time and in the maintenance hours needed whenever the interface or an external dependency changes. For teams introducing Cypress or Playwright, the pyramid is therefore not an academic concept but a practical tool for deciding where a new test idea actually belongs, before it gets added as just another Cypress test without a second thought.

In practice, the opposite of the pyramid emerges surprisingly often: the so-called ice-cream-cone anti-pattern, with many E2E tests, few integration tests, and barely any unit tests. The cause is usually organizational, not technical. E2E tests with Cypress or Playwright feel safer to teams under time pressure because they exercise the complete user path through the real application, which intuitively inspires more confidence than an isolated functional test. If every new requirement is secured with yet another browser test, the suite's coverage grows, but pipeline runtime explodes, and pinpointing the cause of a failing test becomes nearly impossible. The test pyramid is the correction to exactly this pattern: it demands that confidence be built as far down the pyramid as possible, where tests are cheap and fast, and that E2E tests are used only where they are genuinely irreplaceable.

2. The three tiers: unit, integration, E2E

A unit test verifies a single function, method, or class in complete isolation from its environment. All external dependencies, such as the database, file system, network, or other modules, are replaced with mocks, stubs, or fakes. With Jest or Vitest, a test like this runs in milliseconds without spinning up any process outside the test runner. An integration test, by contrast, verifies how several real components work together, for example whether a service correctly writes to a real, isolated test database, or whether an HTTP endpoint returns the expected response when called with Supertest. Here, mocking is deliberately kept to a minimum, because the integration between the parts is exactly what's under test.

An E2E test, finally, simulates a real user interacting with the complete application through the actual interface, usually via a real browser driven by Cypress or Playwright. It walks through the same clicks, form entries, and page transitions a human would, testing not just individual components but the interplay of frontend, backend, database, third-party services, and infrastructure in a single run. In practice, the boundaries between these tiers aren't always sharp. Component tests in the browser, contract tests between services, and API tests without a UI often sit somewhere between integration and E2E. What matters is not the exact label, but how many real dependencies a test actually needs to achieve its goal.

3. Cost and execution speed per tier

The cost difference between the tiers is enormous in practice and usually underestimated. A suite of a thousand unit tests often runs in under ten seconds with Vitest or Jest, because no process outside the test runner is started and each test exercises only a handful of code paths. A hundred integration tests against a real test database or a containerized service, on the other hand, already take one to three minutes, because connection setup, transactions, and cleanup cost real time per test. Ten E2E tests with Cypress or Playwright, each launching a browser, loading a page, and waiting on asynchronous network responses, can easily consume several minutes on their own, even when run in parallel across multiple CI runners.

These time costs multiply with how often the tests run. Unit tests run on every save in watch mode and on every commit, integration tests usually on every push, and E2E tests often only before a deployment or overnight, because their runtime and infrastructure cost make running them on every code change impractical. Teams that ignore this reality and try to secure every requirement with an E2E test slow the development feedback loop so much that developers start avoiding the CI pipeline or stop running tests locally altogether. A deliberate time budget per tier, made visible in the script below, makes these costs explicit and forces a prioritization that would otherwise happen implicitly and by accident.


#!/usr/bin/env bash
# run-tests.sh - Run test suites tier by tier, fail fast on the cheapest tier first
set -euo pipefail

echo "==> Tier 1: Unit tests (Vitest, target: < 10s)"
npx vitest run --coverage || { echo "Unit tests failed, aborting before slower tiers"; exit 1; }

echo "==> Tier 2: Integration tests (Supertest + test DB, target: < 2min)"
npx dotenv -e .env.test -- npx jest --config jest.integration.config.js || {
  echo "Integration tests failed, aborting before E2E tier"; exit 1;
}

echo "==> Tier 3: E2E tests (Playwright, target: < 5min, critical paths only)"
npx playwright test --grep "@critical" || {
  echo "E2E smoke suite failed"; exit 1;
}

echo "All tiers passed"

4. Signal and confidence: what each tier really shows

Each test tier answers a different question, and confusing that difference is the most common source of misplaced confidence in a test suite. A unit test answers the question of whether an isolated function produces the correct result for given inputs. It says nothing about whether that function is correctly wired into the overall system, whether the database query it calls actually exists, or whether the route it serves is even reachable. An integration test answers the question of whether several real components work together correctly, for example whether a service layer generates the right SQL queries and the database returns the expected data.

An E2E test answers a third, different question: can a real user successfully complete a given task from start to finish? This question carries the highest business signal but the lowest diagnostic precision. If a checkout E2E test fails, you know something is broken in the order process, but not whether the cause lies in the frontend, the payment service, the database, or a third-party API. A unit test that fails at the same spot points straight to the exact line of code. The test pyramid optimizes precisely this trade-off: maximum diagnostic precision and speed at the bottom, maximum realism and business relevance at the top, with a deliberate balance in between.

5. Unit tests as the foundation

Unit tests form the foundation of the pyramid because they offer the best ratio of execution speed to diagnostic precision. With Vitest or Jest, pure business logic, such as price calculations, validation rules, or formatting functions, can be tested without any external dependency at all. The watch mode of these test runners re-executes affected tests within milliseconds of every file change, giving a feedback loop tight enough to support test-driven development. What matters most for the quality of this tier is that dependencies are consistently made swappable through dependency injection or interfaces, so mocking doesn't force awkward contortions into production code just to make tests possible.

Good unit tests mainly cover edge cases that integration or E2E tests practically never check systematically: negative numbers, empty arrays, null values, rounding errors, boundary values in discount tiers. These exact cases are frequent sources of real bugs in practice, precisely because they're rarely tested manually during normal development. Tools like mutation testing with Stryker additionally reveal whether the unit test suite actually catches bugs or merely executes code without real assertions, since raw code coverage percentages say nothing about the quality of the checks themselves.


// discount.test.js - Vitest unit test for a pure discount calculation function
import { describe, it, expect } from 'vitest';
import { calculateCartDiscount } from './discount.js';

describe('calculateCartDiscount', () => {
  it('applies a 10% discount above the threshold', () => {
    const result = calculateCartDiscount({ subtotal: 150, threshold: 100, rate: 0.10 });
    expect(result).toBe(135);
  });

  it('applies no discount below the threshold', () => {
    const result = calculateCartDiscount({ subtotal: 80, threshold: 100, rate: 0.10 });
    expect(result).toBe(80);
  });

  it('never returns a negative total for a 100% rate', () => {
    const result = calculateCartDiscount({ subtotal: 50, threshold: 0, rate: 1 });
    expect(result).toBe(0);
  });

  it('throws on a negative subtotal, no silent fallback', () => {
    expect(() => calculateCartDiscount({ subtotal: -10, threshold: 0, rate: 0.1 }))
      .toThrow('subtotal must not be negative');
  });
});

6. Integration tests as the connective layer

Integration tests close the gap that pure unit tests inevitably leave open: they verify that the contracts between real components are actually honored. A typical example is an API endpoint called with Supertest against a real, isolated test database, to verify that a POST request actually creates a record and returns the correct HTTP response. Instead of mocking the database, a real PostgreSQL or MySQL container runs here, often orchestrated through Testcontainers, reset to a defined starting state before every run. This closeness to reality is exactly what makes integration tests valuable for bugs that pure unit tests systematically miss, such as incorrect SQL joins or missing database indexes.

For frontend-heavy architectures, Playwright isn't just useful for E2E tests, it's also well suited to API integration tests without a browser, testing directly against REST or GraphQL endpoints, which is considerably faster than a full UI run. The rule of thumb for drawing the line against unit tests: as soon as a test needs a network call, a real database connection, or file system access, it belongs at the integration tier, not the unit tier. Contract tests with tools like Pact complement this tier in microservice architectures by verifying that a consumer and a provider agree on the same API shape, without requiring both services to run at the same time.


// orders.integration.test.js - Supertest integration test against a real test database
import request from 'supertest';
import { app } from '../src/app.js';
import { resetTestDatabase } from './helpers/db.js';

describe('POST /api/orders', () => {
  beforeEach(async () => {
    await resetTestDatabase();
  });

  it('creates an order and persists it in the database', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ productId: 'sku-42', quantity: 2 })
      .expect(201);

    expect(response.body).toMatchObject({
      status: 'pending',
      quantity: 2,
    });

    // Verify the row actually exists in the real test database
    const stored = await request(app).get(`/api/orders/${response.body.id}`).expect(200);
    expect(stored.body.productId).toBe('sku-42');
  });

  it('returns 422 when the product does not exist', async () => {
    await request(app)
      .post('/api/orders')
      .send({ productId: 'does-not-exist', quantity: 1 })
      .expect(422);
  });
});

7. E2E tests: scope and limits

E2E tests should be limited to the critical user paths whose failure would immediately put revenue or core functionality at risk: login, product search followed by purchase, checkout with payment processing, registration. Cypress and Playwright both offer built-in waiting mechanisms that automatically wait for elements to appear and network requests to complete, instead of relying on fixed sleep times, which used to be the most common cause of flaky tests in older Selenium suites. Playwright adds auto-waiting for element actionability plus a built-in trace viewer that makes a failed run traceable step by step with screenshots and network logs.

The limits of E2E tests lie in their fragility toward things that have nothing to do with the actual bug: animations, third-party scripts, load times under stress, timezone differences between the CI runner and the application. Any of these factors can turn a test red without a real bug being present, and that's exactly what erodes the team's trust in the whole suite over time. That's why E2E tests deserve a stricter rule than the tiers below them: don't test every piece of functionality, just test each critical path once, and consistently cover edge cases, error messages, and validation logic at the unit and integration tiers, where they can be checked stably and fast.


// checkout.cy.js - Cypress E2E test for the critical checkout path
describe('Checkout flow', () => {
  beforeEach(() => {
    cy.intercept('POST', '/api/orders').as('createOrder');
    cy.visit('/cart');
  });

  it('completes a purchase for a logged-in user', () => {
    cy.get('[data-testid="cart-item"]').should('have.length.at.least', 1);
    cy.get('[data-testid="checkout-button"]').click();

    cy.get('[data-testid="shipping-form"]').within(() => {
      cy.get('input[name="address"]').type('Main Street 1');
      cy.get('input[name="zip"]').type('12345');
      cy.get('button[type="submit"]').click();
    });

    cy.get('[data-testid="payment-method-invoice"]').click();
    cy.get('[data-testid="place-order-button"]').click();

    // Wait for the real network call instead of a fixed sleep
    cy.wait('@createOrder').its('response.statusCode').should('eq', 201);
    cy.get('[data-testid="order-confirmation"]').should('contain', 'Thank you');
  });
});

8. Finding the right balance

The often-cited 70/20/10 split, seventy percent unit, twenty percent integration, ten percent E2E, is a reasonable starting point, but not a fixed rule for every project. What matters more than an exact percentage is the habit of regularly auditing an existing test suite: how many E2E tests are actually verifying pure business logic that could just as well, but a hundred times faster, be written as a unit test? How many integration tests could be replaced by a focused unit test behind a clean interface? These questions systematically reveal where tests sit unnecessarily high in the pyramid, simply because that was the easiest option at the time they were written.

In the CI pipeline, this balance can be enforced through staged jobs, where each stage only starts once the previous one passes: unit and integration tests run on every push and deliver feedback within minutes, a small E2E smoke set covering the most important user paths runs before every deployment, and a larger E2E suite with more edge cases runs nightly or weekly, outside the critical path. Tags like @critical or @smoke in Cypress and Playwright make it possible to run a targeted subset without running the entire suite on every commit.


# .github/workflows/test.yml - Run unit, integration, and E2E tests in staged jobs
name: Test Suite
on: [pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx vitest run --coverage

  integration:
    needs: unit
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:integration

  e2e-smoke:
    needs: integration
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --grep "@critical"

9. The test pyramid, side by side

The following overview summarizes the key differences between the three tiers and shows why the pyramid's shape isn't dogma, but a direct consequence of these properties.

Tier Execution time Maintenance effort Signal Recommended share
Unit Milliseconds Low Isolated logic, high precision ~70%
Integration Seconds Medium Real components working together ~20%
E2E Minutes High, flake-prone Complete user path ~10%

In practice, teams with a stable pipeline confirm this split again and again: once the share of E2E tests climbs past ten to fifteen percent, average pipeline runtime tends to grow disproportionately, while the insight gained per additional test drops, since the critical paths are already covered and new E2E tests increasingly check edge cases that would run cheaper and more reliably at a lower tier.

Mironsoft

Test pyramid audits, test automation, and CI/CD for stable releases

Is your test pyramid under control, and your E2E suite stable?

We analyze your existing test suite, uncover where E2E tests should really be unit or integration tests, and build a CI/CD pipeline that combines fast feedback with reliable coverage, from Cypress and Playwright setup to a fully staged pipeline architecture.

Test pyramid audit

Analysis of your existing suite, prioritized by cost-benefit ratio

Cypress/Playwright setup

A robust E2E suite for critical user paths, without the flakiness

CI/CD integration

Staged pipelines with fast feedback and a reliable deployment gate

10. Summary

The test pyramid solves a core problem of modern test automation: confidence in an application is built most cheaply and quickly at the bottom of the pyramid, not the top. Unit tests with Jest or Vitest check isolated logic in milliseconds and deliver the highest diagnostic precision. Integration tests with Supertest or Playwright API tests verify that real components work together against real test databases. E2E tests with Cypress or Playwright are irreplaceable for answering whether a real user can successfully complete a critical task, but they are expensive, slow, and prone to flakiness once they become the main safety net instead of a targeted complement.

The right balance isn't a rigid percentage formula, it's the outcome of a regular audit: every E2E test that really only checks business logic belongs further down. A staged CI pipeline with clear time budgets per tier makes these costs visible and prevents the test suite from becoming slower than the development process it's supposed to protect.

The Test Pyramid: Where E2E Tests Really Belong - Key Takeaways

Foundation: unit tests

Fast, cheap, high diagnostic precision. Runs in milliseconds with Jest/Vitest, ideal for business logic and edge cases.

Connective layer: integration tests

Verify real components working together against real test databases and APIs, for example with Supertest or Playwright API tests.

Top: E2E tests

Highest realism, but slow and maintenance-heavy. Reserved for critical paths like checkout and login with Cypress or Playwright.

Balance & CI/CD

Around 70/20/10 as a starting point, staged pipeline jobs, and @critical tags for fast feedback without sacrificing coverage.

11. FAQ: The test pyramid and E2E tests

1What is the test pyramid?
A mental model from Mike Cohn: many fast unit tests at the base, a moderate number of integration tests above them, and few, expensive E2E tests at the top.
2Why are E2E tests more expensive than unit tests?
E2E tests launch a real browser and wait on real network responses, costing seconds to minutes. Unit tests run isolated and need only milliseconds.
3How many E2E tests are too many?
Rule of thumb: around ten percent of the overall suite. Beyond that, pipeline runtime tends to grow faster than the insight gained.
4Difference between an integration test and an E2E test?
Integration tests verify real components working together, usually without a browser. E2E tests simulate a complete user path through the real interface in the browser.
5How do I reduce flakiness in Cypress/Playwright?
Replace fixed sleeps with built-in waiting mechanisms, wait on network responses instead of elapsed time, disable animations, use stable test data.
6Should E2E tests replace unit tests?
No. They answer different questions. E2E shows whether a path works, unit tests show the exact cause of a failure.
7How often should the E2E suite run?
A small smoke set before every deployment, a larger suite nightly or weekly outside the critical path.
8What is the ice-cream-cone anti-pattern?
The inverted test pyramid: many E2E, few integration, and barely any unit tests. Leads to slow pipelines and hard-to-localize failures.
9How do I find misplaced tests?
Audit regularly: does the E2E test really check a critical path, or just isolated logic that would run faster as a unit test?
10Does E2E automation replace manual testing?
No. Exploratory manual testing still catches unexpected problems, especially for new features and usability questions.