---
title: "17 — Testing — The Pyramid, and What Each Layer Catches"
uid: backend-testing
tags: ["quality", "roadmap:backend", "integration", "qa", "testing", "unit", "functional"]
excerpt: "Unit, integration, functional: not redundant options. A pyramid where each layer catches a different class of bug — many unit tests, fewer integration, fewest end-to-end."
date: 2026-08-13T03:28:24+0000
source: https://www.aveshina.my.id/en/blog/backend-testing
---

"Write tests, any tests" was my testing policy until the first suite that passed while the app was still broken. The structure that separated them: **the levels aren't interchangeable, they're a pyramid where each layer catches a different class of bug, and the shape — many unit, fewer integration, fewest end-to-end — optimizes for fast feedback at the bottom and high confidence at the top.** [1]

The frame that helped is *what can fail here, and how fast do I find out*. A unit test isolates one function and runs in milliseconds; an integration test checks that two modules talk correctly and runs in tens of milliseconds; an end-to-end test drives the whole system through the browser and runs in seconds. Each layer's cost (writing time, run time, flakiness) rises as it broadens, and each layer's confidence rises with it. The pyramid isn't a rule about counting tests; it's a rule about where to spend each kind of effort.

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="A classic testing pyramid with three horizontal bands. Bottom band (widest): Unit, many tests, milliseconds each, catches logic bugs in isolation. Middle band: Integration, fewer tests, tens of ms, catches wiring/contract bugs between modules. Top band (narrowest): Functional / E2E, fewest tests, seconds each, catches whole-flow bugs. A side label reads 'each layer catches a different class of bug'.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- pyramid bands -->
    <polygon points="250,80 490,80 470,160 270,160" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="370" y="110" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Functional / E2E</text>
    <text x="370" y="128" font-size="10" fill="#500724" text-anchor="middle">fewest tests · seconds each</text>
    <text x="370" y="146" font-size="9" fill="#500724" text-anchor="middle">catches whole-flow bugs</text>

    <polygon points="270,160 470,160 440,220 300,220" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="370" y="183" font-size="13" font-weight="700" fill="#422006" text-anchor="middle">Integration</text>
    <text x="370" y="200" font-size="10" fill="#422006" text-anchor="middle">fewer tests · tens of ms</text>
    <text x="370" y="215" font-size="9" fill="#422006" text-anchor="middle">catches wiring / contract bugs</text>

    <polygon points="300,220 440,220 390,280 350,280" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="370" y="246" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle">Unit</text>
    <text x="370" y="263" font-size="10" fill="#052e16" text-anchor="middle">many · milliseconds</text>

    <!-- side labels with bug classes -->
    <line x1="490" y1="120" x2="560" y2="120" stroke="#cbd5e1" stroke-width="1"/>
    <text x="565" y="115" font-size="10" fill="#500724">whole-flow</text>
    <text x="565" y="128" font-size="10" fill="#500724">rendering, real HTTP</text>

    <line x1="470" y1="190" x2="560" y2="190" stroke="#cbd5e1" stroke-width="1"/>
    <text x="565" y="185" font-size="10" fill="#422006">module boundaries,</text>
    <text x="565" y="198" font-size="10" fill="#422006">API contracts, DB schema</text>

    <line x1="440" y1="250" x2="560" y2="250" stroke="#cbd5e1" stroke-width="1"/>
    <text x="565" y="246" font-size="10" fill="#052e16">pure logic, edge cases,</text>
    <text x="565" y="259" font-size="10" fill="#052e16">single-function behavior</text>

    <!-- left axis -->
    <text x="180" y="200" font-size="10" font-weight="700" fill="#64748b">confidence ▲</text>
    <text x="180" y="215" font-size="10" font-weight="700" fill="#64748b">cost / flakiness ▲</text>
  </g>
</svg>
```

## Unit testing: the wide base

**Unit testing** tests individual components — the smallest testable units, usually functions — in isolation [3]. I give the function inputs and assert the outputs; dependencies (other functions, the database, the network) are replaced with test doubles (mocks, stubs) so the test exercises only the function's logic.

The properties of a good unit test:

- **Fast** — milliseconds. A unit test suite of hundreds of tests should run in under a second.
- **Isolated** — no dependency on the database, the filesystem, or the network. If it needs those, it's an integration test.
- **Specific** — one assertion focus per test, named for the behavior it verifies ("returns 0 for empty input," not "test1").

Unit tests catch the class of bugs that live inside a single function: wrong calculation, off-by-one, unhandled branch, edge case (empty, null, max). They don't catch bugs at module boundaries — those are integration tests' job. The strength of unit tests is that they're cheap enough to run on every save, so feedback is immediate, and there can be many of them covering edge cases exhaustively. The weakness is that a function passing its unit tests in isolation tells me nothing about whether it's *called correctly* by the rest of the system.

## Integration testing: the middle band

**Integration testing** verifies that components work together — module communication via APIs, database reads and writes, third-party service calls [2]. Where the unit test stubs the database, the integration test uses a real (often test-instance) database. Where the unit test mocks the payment provider, the integration test checks that the code constructs the right request shape.

The bugs integration tests catch are exactly the ones unit tests miss:

- **Contract mismatches** — module A calls module B with the wrong argument shape.
- **Schema drift** — the code expects a column the database doesn't have (the migration wasn't run).
- **Wiring bugs** — the route is wired to the wrong controller, the dependency injection is misconfigured.
- **Real I/O issues** — the query returns rows in an order the code didn't expect.

Integration tests are slower than unit tests (real I/O, real setup) and more brittle (they depend on more state), so there are fewer of them. The common tool for HTTP integration testing in Node is **Supertest** — it boots the app and makes real HTTP requests against it, asserting on the response, without a browser.

## Functional / end-to-end: the narrow top

**Functional testing** validates the system from the outside — typically by driving the application through its public interface (an HTTP endpoint, a browser) and asserting on the observable behavior [4]. End-to-end (E2E) tests go further, driving a real browser through the full stack — frontend, API, database — to simulate a user flow.

The bugs functional/E2E tests catch:

- **Whole-flow failures** — the login form submits, the session is set, the redirect happens, the dashboard renders. Any break anywhere in the chain fails the test.
- **Render-time issues** — the right data is in the DOM, the button is clickable, the error message appears.
- **Real user paths** — the things users actually do, end to end, that no lower-layer test reproduces.

The cost: E2E tests are slow (seconds each), flaky (browser timing, async races), and expensive to maintain. A suite of thousands of E2E tests is a well-known anti-pattern — slow CI, constant flake-triage. The pyramid says: few E2E tests, covering only the critical user paths; rely on integration and unit tests for breadth. The dominant tool for E2E in the modern stack is **Playwright**, which automates real browsers across Chromium, Firefox, and WebKit.

## The pyramid as a spending guide

The pyramid isn't dogma ("exactly 70% unit, 20% integration, 10% E2E"); it's a heuristic about where each kind of test pays off. The principle:

- **Many unit tests** — cheap, fast, exhaustive on edge cases. The bulk of the suite.
- **Fewer integration tests** — one per important module boundary, covering the contract. The middle.
- **Fewest E2E tests** — one per critical user flow (login, checkout, core feature). The tip.

The reason for the shape is economics. A bug found by a unit test is found in milliseconds, on the line that caused it. The same bug found by an E2E test is found in seconds, somewhere in a multi-step flow, and I have to debug which layer caused it. Cheaper tests catch more bugs per unit of effort, so I spend the bulk there; expensive tests are reserved for what only they can verify.

A common modern counter-pattern is the "testing trophy" — more integration tests, fewer unit tests — on the grounds that integration tests catch the bugs that actually ship (the wiring ones) and aren't much slower than unit tests for typical web apps. The trophy is a reasonable adjustment, but the underlying principle is the same: spend test effort where each kind of bug is cheapest to catch, and don't rely on a single layer.

## How I use this

Three rules capture the practice:

- **Write the unit test first when the logic is pure.** Functions with clear inputs and outputs get unit tests; edge cases get exhaustively covered. This is the bulk of the suite.
- **Write an integration test for every API endpoint and every module boundary.** The contract between layers is where the worst bugs hide; one integration test per boundary catches them.
- **Reserve E2E for the critical user paths.** Login, checkout, the one feature the product depends on. A handful, not a mountain.

The discipline I apply across all three: every bug I ship becomes a test that would have caught it, at the lowest layer that could have caught it. The pyramid isn't about test counts; it's about building a net where each hole size is appropriate to its layer — fine at the bottom (unit, many small checks), coarse at the top (E2E, few whole-flow checks). The combination is what lets me ship with confidence that the obvious bugs are caught and the critical paths actually work.

## References

[1] "Testing Pyramid," BrowserStack. [Online]. Available: [https://www.browserstack.com/guide/testing-pyramid-for-test-automation](https://www.browserstack.com/guide/testing-pyramid-for-test-automation)

[2] "Integration Testing," guru99. [Online]. Available: [https://www.guru99.com/integration-testing.html](https://www.guru99.com/integration-testing.html)

[3] "What is Unit Testing?," guru99. [Online]. Available: [https://www.guru99.com/unit-testing-guide.html](https://www.guru99.com/unit-testing-guide.html)

[4] "Functional Testing: What It Is and How to Do It Right," Atlassian. [Online]. Available: [https://www.atlassian.com/continuous-delivery/software-testing/functional-testing](https://www.atlassian.com/continuous-delivery/software-testing/functional-testing)

```quiz
Q: Why is the testing pyramid shaped with many unit tests at the bottom and few E2E tests at the top?
- Economics — unit tests are cheap and fast so the bulk of bugs can be caught there; E2E tests are slow and flaky, so they're reserved for the critical paths only they can verify
- E2E tests are easier to write than unit tests, so you need fewer
correct: 0
explain: A bug found by a unit test is found in milliseconds at the causing line; the same bug found by E2E is found in seconds somewhere in a multi-step flow requiring debug. Cheap tests catch more bugs per effort, so the bulk is at the bottom; expensive tests are reserved for whole-flow verification.

Q: What class of bugs do integration tests catch that unit tests miss?
- Bugs at module boundaries — contract mismatches, schema drift, wiring issues, real I/O problems
- Pure logic errors inside a single function
correct: 0
explain: Unit tests stub dependencies, so they can't catch boundary bugs: a module calling another with the wrong shape, a missing column because a migration didn't run, a route wired to the wrong controller. Integration tests use real dependencies to catch exactly these.

Q: Why is a suite of thousands of end-to-end tests considered an anti-pattern?
- E2E tests are slow (seconds each) and flaky (browser timing, async races); thousands means slow CI and constant flake-triage
- E2E tests can only be written in Python
correct: 0
explain: E2E tests are the most expensive in run time and flakiness. A handful covering critical user paths is valuable; thousands overwhelm CI and demand constant maintenance. The pyramid says few E2E, relying on integration and unit for breadth.

Q: A function with clear inputs and outputs has a calculation bug. At which layer should a test have caught it?
- Unit — pure logic with clear inputs/outputs is exactly what unit tests cover, cheaply and exhaustively
- End-to-end only
correct: 0
explain: Pure-function logic bugs belong to the unit layer — fast, isolated, exhaustive on edge cases. Catching it at E2E would mean seconds of run time and debugging which layer caused it. The pyramid's base exists precisely for this class of bug.

Q: What is the modern 'testing trophy' adjustment to the pyramid, and what principle does it share with the pyramid?
- More integration tests, fewer unit tests, because integration tests catch the wiring bugs that actually ship; both share the principle of spending effort where each bug class is cheapest to catch
- It abandons unit testing entirely in favor of E2E only
correct: 0
explain: The trophy argues integration tests catch the bugs that matter most (contract/wiring) and aren't much slower for typical web apps. It's a re-weighting, not a rejection — the underlying principle (spend effort where each kind of bug is cheapest) is identical to the pyramid's.
```
