The testing pyramid explained: unit, integration and end-to-end tests
Mosaddik Billah 5 min read

Automated tests let you change code without fear. Without them, every change means clicking through the site to check nothing broke, and eventually someone skips a step. But not all tests are equal: some run in milliseconds and pinpoint the exact bug, others take minutes and only tell you “something is wrong somewhere”. The testing pyramid is a simple model for getting the balance right.
The three levels#
/\
/E2E\ few: slow, but test what users actually do
/------\
/integra-\ some: real database, real HTTP
/ tion \
/------------\
/ unit \ many: fast, focused, no outside world
/----------------\Unit tests: one piece of logic, alone#
A unit test checks one function or module with no database, network or browser. Here’s a function that calculates an order total, and its tests, using Node’s built-in test runner (no packages needed):
// price.js
export function orderTotal(items, discountPercent = 0) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round(subtotal * (1 - discountPercent / 100));
}// price.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { orderTotal } from "./price.js";
test("adds up price times quantity", () => {
assert.equal(orderTotal([{ price: 1200, quantity: 2 }, { price: 800, quantity: 1 }]), 3200);
});
test("applies a percentage discount", () => {
assert.equal(orderTotal([{ price: 1000, quantity: 1 }], 10), 900);
});
test("an empty cart costs nothing", () => {
assert.equal(orderTotal([]), 0);
});Run node --test and all three pass in a fraction of a second. When one fails, it tells you exactly which rule broke.
Good for: calculations, validation, business rules, edge cases (empty lists, zero, very large numbers). Can’t tell you: whether the pieces work together, or whether the SQL is right.
Integration tests: pieces working together#
An integration test runs several parts together for real: your API with a real (test) database, your code calling a real HTTP endpoint. A typical one starts the app, sends a request, and checks the response and the database:
test("POST /orders saves the order", async () => {
const res = await fetch(`${baseUrl}/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerId: 7, items: [{ priceCents: 1200, quantity: 2 }] }),
});
assert.equal(res.status, 201);
const order = await res.json();
assert.equal(order.total_cents, 2400);
});Good for: SQL queries, constraints, routes, error handling, and how your code talks to other systems. Costs: slower (seconds, not milliseconds), and you need a test database that starts clean for each run. SQLite in memory, or a PostgreSQL container, both work well.
End-to-end tests: like a real user#
An end-to-end (E2E) test drives a real browser through a real flow: open the site, add to cart, check out, see the confirmation. Tools such as Playwright and Cypress do this.
Good for: proving the most important journeys work, from the button to the database and back. Costs: the slowest to run, and the most fragile, because a changed button label, a slow page or an animation can make them fail even when nothing is really broken. Keep them few and focused on the flows that make money.
Why a pyramid?#
Because the cost and speed of tests change with each level:
| Unit | Integration | End-to-end | |
|---|---|---|---|
| Speed | Milliseconds | Seconds | Seconds to minutes |
| When one fails, you know | Exactly which rule | Roughly which area | That a journey is broken |
| Breaks for unrelated reasons | Rarely | Sometimes | Often |
| How many | Many | Some | Few |
A suite that’s mostly E2E tests (an “ice-cream cone”) is slow, flaky and hard to debug, so people stop running it. A suite of only unit tests can pass while the app is broken, because nothing checked that the parts connect. The pyramid gives you fast feedback from many unit tests and confidence from a few big ones.
The proportions are a guide, not a rule. A thin API that mostly reads and writes a database gets more value from integration tests; a pricing engine full of rules gets more from unit tests. Some teams draw it as a “trophy” with integration tests as the widest band, and that’s a reasonable choice for web apps too.
What to test first#
On a project with no tests, don’t try to cover everything. Start where a bug would hurt most:
- Money and data. Prices, totals, discounts, taxes, and anything that writes important data. Unit tests.
- The main user journey. Sign-up, checkout, booking: one E2E test each.
- Every bug you fix. Write a test that fails because of the bug, then fix it. It can never come back unnoticed.
- Code you’re about to change. Tests first, then the change, so you know you didn’t break what was there.
Make code easy to test by keeping business rules out of route handlers and away from the database; how to structure a backend project shows how.
Run them automatically#
Tests only help if they run. Set them to run on every push or pull request in a CI service such as GitHub Actions, and don’t merge when they fail. Run the fast unit tests constantly while you work, and the slower ones before merging.
Untested code is one of the main sources of technical debt. If you need tests added to an existing project so it can change safely, I can help as part of Wix apps with the Wix CLI and Wix Velo development.
Comments