Skip to content

How the Test Pyramid Is Actually Built: Not Ratios, but a Decision Table

  • by

Why 70/20/10 Is the Wrong Question

Every team that asks “what should our unit/integration/E2E ratio be?” has already made the mistake. The pyramid was never a ratio prescription. It is a cost-and-feedback model: tests that are cheap to run and fast to fail belong where they run often, and tests that are expensive, slow and fragile have to earn their place by catching something nothing else can.

When a team targets a ratio, it stops asking what a test catches and starts asking where it gets counted. You end up writing unit tests for getters to push the bottom number up, and you keep a 90-second E2E test that has never failed for a real reason because deleting it would “hurt coverage.” The ratio is an output. If your decisions are right, the shape falls out on its own. If your decisions are wrong, no ratio will save you.

The Pyramid’s Real Axis: Risk Class

A test exists to catch one class of failure. Name the class first, pick the level second. In most web systems there are five that matter:

  • Calculation errors — wrong price, wrong tax, wrong state transition. Your own code, deterministic input, deterministic output.
  • Contract mismatches — the provider renamed a field, changed a nullable, started returning a string where you expected a number.
  • Integration and configuration errors — wrong environment variable, missing migration, misconfigured queue, broken DI wiring.
  • Browser behaviour — focus traps, form submission, redirect chains, things only a real rendering engine does.
  • Data state — behaviour that depends on what already exists in the database: an expired subscription, a half-finished order.

The same bug belongs to exactly one class. Catching one risk class at two levels does not double your coverage; it doubles your runtime and gives you two tests that fail together for one cause, so every red build now costs two investigations. That is the hidden tax of “we’ll test it at the unit level and in E2E to be safe.”

The Decision Table: How to Decide Which Level a Test Belongs To

Five columns. Fill them in order; the level falls out of the last two.

Risk classFailure originSmallest context that detects itAcceptable feedback timeFlakiness costLevel
Calculation errorYour own codeOne function, in-memorySecondsNear zero — no I/O to destabiliseUnit
Contract mismatchExternal provider or another team’s serviceHTTP boundary + schemaMinutes, on merge and on a scheduleLow — fails for a real reason when the provider changesContract
Integration / configurationWiring between your componentsApp process + real DB or real queueMinutes, per PRMedium — needs clean data per testIntegration
Browser behaviourRendering engine, routing, client JSReal browser, one pageMinutesHigh — timing-dependent by natureE2E (narrow)
Data stateYour code + persisted stateRepository/service + real DBMinutesMediumIntegration
Multi-system user journeySeveral systems togetherFull stackTens of minutes, post-mergeVery highE2E (few)

The fourth column is where most teams lie to themselves. “Acceptable feedback time” is not how long the test takes today — it is how long a developer can wait before context-switching. If the answer is “before I open the next ticket,” the test cannot live in a 20-minute suite.

The fifth column is the one people skip entirely. A test with a 3% failure rate that runs on every PR will fail for no reason several times a week, and after the second false alarm the team stops reading the report. Flakiness is not an annoyance; it is a cost you pay in trust. If you are not sure why your E2E tests are unstable, the causes are usually mechanical rather than mysterious — see diagnosing flaky tests in Playwright.

Running the Table on Three Scenarios

Scenario 1 — Discount calculation. Risk class: calculation. Origin: your own code. Smallest context: one pure function. Feedback: seconds. Flakiness cost: zero. → Unit.

// src/pricing.ts
export type Cart = {
  subtotal: number;
  itemCount: number;
  couponCode?: string;
};

const MAX_RATE = 0.25;

export function discountFor(cart: Cart): number {
  let rate = 0;
  if (cart.subtotal >= 100) rate += 0.10;
  if (cart.itemCount >= 5) rate += 0.05;
  if (cart.couponCode === "WELCOME") rate += 0.15;
  const applied = Math.min(rate, MAX_RATE);
  return Math.round(cart.subtotal * applied * 100) / 100;
}
// test/pricing.test.ts
import { describe, expect, it } from "vitest";
import { discountFor } from "../src/pricing";

describe("discountFor", () => {
  it("gives no discount below the threshold", () => {
    expect(discountFor({ subtotal: 99.99, itemCount: 1 })).toBe(0);
  });

  it("stacks volume and basket discounts", () => {
    expect(discountFor({ subtotal: 200, itemCount: 5 })).toBe(30);
  });

  it("caps the total rate at 25 percent", () => {
    expect(
      discountFor({ subtotal: 200, itemCount: 5, couponCode: "WELCOME" })
    ).toBe(50);
  });
});

What not to write: an E2E test that adds five items to the cart in a browser to assert the discounted total. It tests the same risk class, takes a thousand times longer, and when it fails you still have to open the pricing function to find out why.

Scenario 2 — Payment provider integration. Risk class: contract mismatch. Origin: someone else’s service. Smallest context: the HTTP boundary plus a schema. Feedback: minutes, and importantly on a schedule — the provider can break you on a day you did not deploy. → Contract test against the sandbox, plus a unit test for your own branching on the response.

// test/payment-contract.test.ts
import { describe, expect, it } from "vitest";
import { z } from "zod";

const chargeResponse = z.object({
  id: z.string(),
  status: z.enum(["succeeded", "pending", "failed"]),
  amount_cents: z.number().int(),
  currency: z.string().length(3),
  failure_code: z.string().nullable(),
});

const BASE_URL = process.env.PAYMENTS_SANDBOX_URL ?? "https://sandbox.example.test";
const API_KEY = process.env.PAYMENTS_SANDBOX_KEY ?? "test-key";

describe("payment provider contract", () => {
  it("returns a charge object in the agreed shape", async () => {
    const response = await fetch(`${BASE_URL}/v1/charges`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${API_KEY}`,
      },
      body: JSON.stringify({
        amount_cents: 1999,
        currency: "EUR",
        source: "tok_test_visa",
      }),
    });

    expect(response.status).toBe(201);

    const parsed = chargeResponse.safeParse(await response.json());
    if (!parsed.success) {
      throw new Error(`Contract drift: ${parsed.error.toString()}`);
    }
    expect(parsed.data.amount_cents).toBe(1999);
  });
});

What not to write: an E2E checkout that drives a browser through the provider’s hosted payment page to assert “payment works.” That test fails when the provider’s sandbox is slow, when their UI changes, and when your own CSS changes — three origins, one red light, no diagnosis. For the general case of pushing assertions down to the API boundary, see API test automation: why and how.

Scenario 3 — Multi-step signup flow. Risk class: multi-system journey with browser behaviour (redirects, email confirmation link, session cookie). Origin: several systems together. Smallest context: a real browser and a real backend. Flakiness cost: high — so write one, not twelve.

// e2e/signup.spec.ts
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";

test("a new user can sign up, confirm their email and reach the dashboard", async ({
  page,
  request,
}) => {
  const email = `signup-${randomUUID()}@example.test`;
  const password = "Str0ng-Passw0rd!";

  await page.goto("/signup");
  await page.getByLabel("Email").fill(email);
  await page.getByLabel("Password").fill(password);
  await page.getByRole("button", { name: "Create account" }).click();

  await expect(page.getByText("Check your inbox")).toBeVisible();

  // Test-only mail inbox exposed by the staging environment.
  const inbox = await request.get(`/test-api/mailbox/${encodeURIComponent(email)}`);
  expect(inbox.ok()).toBeTruthy();
  const { confirmationUrl } = await inbox.json();

  await page.goto(confirmationUrl);
  await expect(page).toHaveURL(/\/dashboard$/);
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});

What not to write: eleven variants of this test for every password validation rule. Validation messages are a calculation risk class — unit-test the validator, and if the messages are rendered server-side, cover one of them in an integration test. Note also that the test generates its own email address; a shared fixture user would make this suite order-dependent, which is a separate and expensive failure mode (why every test must own its data).

The Three Conditions That Earn a Test Its Place in E2E

A test stays at the E2E level only if all three hold at once:

  1. It carries genuine multi-system risk. Two or more independently deployable pieces must work together for the assertion to pass. If everything the test touches ships in one artifact, an integration test covers the same risk.
  2. Failure means lost money or lost data for a real user. Checkout, signup, password reset, data export. A broken tooltip is not in this category, however visible it is.
  3. It cannot be reliably simulated at a lower level. “Reliably” is the operative word. If a contract test plus an integration test reproduce the failure mode faithfully, the E2E test is a duplicate with worse ergonomics.

Fail any one condition and the test moves down. The common objection here is “but E2E tests the real user.” It does — and that is exactly why you keep the handful that represent real user money, and why the other forty, which test calculation rules through a browser, are not testing the real user at all. They are testing your own functions through five layers of indirection and calling the latency a feature.

Reclassifying an Existing Suite With the Table

You do not start from zero. You start from the suite you already have.

Reclassifying an existing suite: a 6-step loop — Label by runtime (Sort every test by wall-clock duration, slowest first), Label by real bugs caught (Which tests failed for a genuine defect in the last 30 days), Build the candidate pool (Long E2E tests that caught nothing go in the pool), Assign a risk class (One class per test, using the five columns), Move down or delete (Rewrite at a lower level, or delete against the criteria) and Measure feedback time (Compare before and after, two weeks apart).

Two columns of raw data are enough to start: wall-clock duration per test, and whether the test failed for a genuine defect in the last 30 days. Your CI reporter already has both. Sort descending by duration, filter to tests that caught nothing, and you have your candidate pool — in most mature suites it is a large fraction of the E2E layer.

Then, per candidate, fill in the five columns. Three outcomes are possible:

  • Move down. The risk class is calculation, data state or contract. Rewrite it at that level and delete the original in the same commit. Not the next sprint — the same commit, or you end up with both.
  • Keep. All three E2E conditions hold. Leave it, and if it is slow, make it faster rather than rarer.
  • Delete outright. Use an explicit criterion so this is not a judgement call: delete if the test has caught no genuine defect in 30 days, AND its risk class is already covered by a test at a lower level, AND no one can name a production incident it would have caught. All three, in writing, in the PR description. If someone can name the incident, the test stays and you write down why.

The failure mode after reclassification is predictable: an escaped defect traced back to a deleted test almost always comes from a wrongly filled third column — “smallest context that detects it.” Someone decided a repository method could be verified with a mocked database, and the real bug was in the SQL. The fix is not to restore the E2E test; it is to correct that column and write the integration test the table actually demanded.

Metrics That Validate the Move

Stop tracking the ratio. Track four things:

  • Feedback time per PR. Minutes from push to a verdict a developer trusts. This is the number the pyramid exists to protect. Measure it before you start, so the comparison two weeks later is not a feeling. If the number barely moves after you delete tests, the bottleneck is elsewhere — environment setup, serialised jobs, no parallelism (performance optimization in test automation).
  • Escaped defects, counted with their level. Not just how many bugs reached production, but which level should have caught each one. Three escapes that all belong to “contract mismatch” tell you your middle layer is missing; the raw count tells you nothing.
  • Flakiness rate per test. Per test, not per suite. A suite-level number hides the fact that four tests are producing nearly all the noise, and those four are usually the ones the table would have moved down.
  • Double coverage. When a real bug appears, count how many tests failed because of it. More than one means you are paying twice for one risk class, and it is the cheapest signal you have for finding duplicates.

Where the Pyramid Breaks: Hourglass and Ice Cream Cone

Two deformations, each caused by ignoring a specific column.

The hourglass — plenty of unit tests, plenty of E2E, nothing in between — comes from ignoring the “smallest context” column. Teams conclude that anything touching a database or a real HTTP call “might as well” be tested through the UI. Contract mismatches and configuration errors then have no home and surface in staging, late, as full-stack failures nobody can localise.

The ice cream cone — E2E-heavy, thin everywhere else — comes from ignoring the “failure origin” column. Instead of asking whose code can break, the team asks “can a user see it?” Everything can be seen by a user, so everything goes to the top, and E2E ends up compensating for missing unit tests. The symptom is a suite where a one-line pricing bug is caught by six browser tests, forty minutes after the push.

Two chains: ignored smallest context → Hourglass → contract bugs; ignored failure origin → Ice cream cone → flaky feedback

Both are repaired the same way: refill the missing column for the affected tests and let the table reassign them.

Your First Step This Week

List your ten slowest E2E tests. Your CI report already ranks them; this takes fifteen minutes.

For each one, fill in the five columns — risk class, failure origin, smallest detecting context, acceptable feedback time, flakiness cost. Write them in a shared document, not in your head, because the argument you will have with your team is about the columns, not the conclusions.

Move or delete at least three. Move means: a lower-level test exists in the same pull request. Delete means: all three criteria from the reclassification section are stated in the PR description.

Record feedback time per PR today, before any of this lands, and compare it two weeks later. If the number dropped and no defect escaped at a level you emptied, repeat with the next ten. If a defect did escape, go back to the row you filled in for that test and find which column was wrong — that correction is worth more than the test you deleted.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.