Skip to content
C Codeloom
Testing

Testing Pyramid: Unit, Integration, E2E

What the testing pyramid actually means in modern apps, when to deviate, and how to keep each layer giving you the value it is supposed to.

·5 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • What unit, integration, and E2E really mean today
  • When the pyramid shape applies and when it does not
  • How to keep tests fast and meaningful
  • Anti-patterns at each layer
  • How to budget tests in CI

Prerequisites

  • You have written some tests

The testing pyramid is a 20-year-old idea that still shows up in onboarding decks. It is a useful shape, but it gets misused as a quota system: “we need 70 percent unit tests.” That misses the point. The pyramid is about feedback speed and cost, not numbers. This post is about getting the actual value.

The pyramid in one paragraph

Many fast unit tests at the bottom. Some integration tests in the middle. A few end-to-end tests at the top. Fast tests catch most bugs cheaply; slow tests catch the bugs only system-level interactions reveal. The shape is about cost: the higher you go, the more flakiness, the more setup, the more brittle the test to UI or data changes.

Mental model

             /\         E2E: few, slow, fragile, high confidence
          /  \
         /----\       Integration: some, medium speed, real wiring
        /      \
       /--------\     Unit: many, fast, isolated, narrow signal
      /__________\
Cost and value at each layer

The shape inverts when the system is mostly glue (think a BFF or a Next.js app). For a service that is mostly wiring third-party APIs, integration tests carry the value and unit tests of trivial mappers are noise. Pyramid first, then adjust.

What each layer actually is

Unit tests exercise a single function or class with all dependencies replaced by fakes. They run in milliseconds. If a unit test reads a database, talks to the network, or starts a web server, it is not a unit test.

Integration tests exercise a slice of the system with real dependencies (a real database, a real cache, real internal modules). They cost hundreds of milliseconds to a few seconds. They catch wiring bugs, SQL mistakes, serialization issues, and config drift.

End-to-end tests drive the whole system through its public interface (UI or API) and assert end-user outcomes. They are seconds or minutes long. They catch full-stack regressions and are the only tests that prove the feature actually works in a browser.

Hands-on: the same feature, three layers

Unit:

// price.ts
export function applyDiscount(amount: number, pct: number) {
  if (pct < 0 || pct > 100) throw new Error('bad pct');
  return Math.round(amount * (1 - pct / 100));
}

// price.test.ts
test('applies a percentage discount', () => {
  expect(applyDiscount(1000, 10)).toBe(900);
});

Integration:

// uses a real Postgres in a container
test('creates an order with a discount', async () => {
  const order = await createOrder({ items: [{ sku: 'a', price: 1000 }], discount: 10 });
  const row = await db.orders.findUnique({ where: { id: order.id } });
  expect(row.total).toBe(900);
});

E2E:

test('user can apply a coupon at checkout', async ({ page }) => {
  await page.goto('/cart');
  await page.getByLabel('Coupon').fill('TEN');
  await page.getByRole('button', { name: 'Apply' }).click();
  await expect(page.getByText('Total: $9.00')).toBeVisible();
});

Each one catches a different failure mode. Drop the integration test and you will not notice the wiring bug where the discount is computed but never written to the DB.

What unit tests are bad at

Unit tests are bad at catching anything that lives between components: SQL, auth, serialization, cache invalidation, retries, idempotency. If most of your bugs come from those, no amount of unit tests will help. Add integration tests.

They are also bad when written against mocks. A unit test that mocks the database and then “verifies it was called with X” is testing your code’s structure, not its behavior. Refactors break the test even when behavior is identical. Test the function with realistic inputs and assert observable outputs.

What E2E tests are bad at

E2E tests are bad at coverage. Five E2E tests already take longer than 500 unit tests. They are also bad at flakiness: networks blip, animations race, selectors drift. The fix is restraint. Pick a handful of critical user flows (sign up, checkout, the one thing your business depends on) and write airtight tests for those. Resist the urge to E2E-test every form.

Common pitfalls

  • “100 percent coverage” as a goal. Coverage measures execution, not correctness. Getter coverage tells you nothing.
  • Mocking everything in unit tests. The tests pass; the system breaks.
  • Sharing state between tests. Order dependence shows up in CI before it shows up locally.
  • E2E selectors based on text or CSS classes. Use data-testid or accessible roles; text changes break tests for the wrong reason.
  • Running E2E in CI against a shared environment. Concurrent runs interfere; spin up an ephemeral environment.
  • Skipping integration tests because “the unit tests will catch it.” They will not.

Practical tips

  • Budget CI time, not test counts. If E2E takes 12 minutes, that is your ceiling; cut tests that no longer pay rent.
  • Run unit tests on every save, integration on every push, E2E on every PR. The faster the feedback at the layer, the more often you run it.
  • Use real databases via testcontainers, not in-memory fakes. SQL behavior differs between engines.
  • Snapshot tests are tempting and usually wrong. They lock in current output without expressing intent. Use them only for stable artifacts.
  • Treat flakes as bugs. A retry hides the problem; investigate and fix.

Wrap-up

The pyramid is a guideline about feedback cost. Use it to lean on fast tests for most coverage, integration for the wiring that matters, and E2E for the handful of flows your business cannot afford to break. Numbers are not the goal; signal is. Tune the shape to your system, not to a slide deck.