The testing pyramid is a rule for distributing automated tests: many fast unit tests at the bottom, fewer integration tests in the middle and only a handful of end-to-end tests at the top. The higher the level, the closer a test is to a real user, but the slower, costlier and more prone to failing for no reason it becomes. E2E tests are needed to confirm the key journeys work as a whole, but anything that can be checked a level lower is checked lower.
After this you will be able to
- split one feature's checks across the pyramid levels and write a "check → level → why" test plan
- explain why there should be few e2e tests yet you cannot do without them, and how the pyramid differs from the trophy, diamond and honeycomb
- cover one function with unit and integration tests in Vitest without duplicating checks
- find the cause of a flaky test — isolation, time, async behaviour, an external service — and remove it
Know beforehand
Verified with
Node.js 24 LTS (24.18)Vitest 5.0.3Express 5.3.0TypeScript 7.0.2SQLite 3.53 (node:sqlite)
Checked against docs: October 11, 2026
Contents
Why automated tests, and how many
An automated test is code that checks other code and answers "works / doesn't work" in seconds instead of half an hour of clicking around. The value of tests is not their number but the feedback they give. In the Google Testing Blog post "Just Say No to More End-to-End Tests", the ideal feedback loop has three properties: it is fast, reliable (a test does not fail for no reason) and isolates failures — it shows exactly where things broke.
No single kind of test has all three. A unit test is fast and pinpoints the failure, but does not know whether the parts work together. An E2E test walks the user's path in a real browser, but takes minutes, fails because of animations and network delays, and when it fails it only says "something broke". So tests of different levels are combined — and the question "how many" really means "in what proportion".
In his note on testing "shapes", Martin Fowler quotes Justin Searls: debating what percentage of which type of tests to write is a distraction; what matters is writing expressive tests that establish clear boundaries, run quickly and reliably, and only fail for useful reasons. The pyramid and its alternatives are tools for getting there.
The pyramid: unit, integration, e2e
Mike Cohn introduced the test pyramid in his book "Succeeding with Agile"; Martin Fowler's note "Test Pyramid" and Ham Vocke's article "The Practical Test Pyramid" made it popular. Fowler puts the point this way: you should have many more unit tests than broad tests through the GUI, because UI tests are brittle, expensive to write and time-consuming to run. Vocke suggests remembering two things from the original pyramid: write tests with different granularity, and the higher the level, the fewer tests you should have.
- 01E2E — a handfulThe whole product in a browser or on a device: the key user journeys
- 02Integration — dozensSeveral parts together: HTTP route, database, JSON parsing
- 03Unit — hundredsOne function or class in memory, dependencies replaced
Unit
- What it checks
- Logic of one function or class: branches, edge values, calculations
- Speed
- Milliseconds
- What it costs
- Blind to errors at the seams
- Example for a lead form
normalizePhoneturns "8 (707) 123-45-67" into+77071234567
Integration
- What it checks
- That the parts work together: route, validation, SQL, status codes
- Speed
- Tens of milliseconds to seconds
- What it costs
- Needs a database or its container, data setup
- Example for a lead form
POST /api/leadsstores a row and returns201; a duplicate returns409
E2E
- What it checks
- The whole user journey: browser, frontend, API, database
- Speed
- Seconds to minutes per test
- What it costs
- Slow, unstable, hard to locate the cause
- Example for a lead form
- A user fills in the form on the page and sees "Thanks, we'll call you back"
Level names are fuzzy. Fowler recalls a testing expert who covered 24 different definitions of unit test on the first morning of his course. Agree on terms within your team, and when reading advice, check what the author means by them. A useful distinction from the same Fowler note: a solitary unit test replaces all collaborators, a sociable one uses the real ones. Many "unit versus integration" debates are really about these two styles.
The Google Testing Blog post suggests a 70/20/10 split — unit, integration, e2e — as a first guess, noting that the exact mix differs for every team. It also describes two anti-patterns: the ice-cream cone (an inverted pyramid that relies mostly on e2e and manual testing) and the hourglass (many unit and e2e tests but few integration tests — e2e tests doing work a smaller test could do).
Trophy, diamond and honeycomb: alternative views
The pyramid is often criticised and has several well-known alternatives. The web.dev article "Pyramid or Crab? Find a testing strategy that fits" brings them together; the common thread in almost all of them is shifting the main weight to integration tests.
Pyramid
- Author / source
- Mike Cohn; Fowler, Vocke
- Key idea
- Most tests are unit tests, e2e kept to a minimum
- Where it fits
- Complex logic inside the code, libraries, calculations
Testing trophy
- Author / source
- Kent C. Dodds
- Key idea
- Static analysis (TypeScript, ESLint) at the base, the bulk is integration tests; "the more your tests resemble the way your software is used, the more confidence they can give you"
- Where it fits
- Frontends and monoliths where bugs tend to sit between components
Test diamond
- Author / source
- Described in the web.dev article
- Key idea
- Unit tests only for the most critical cases, because they erode during refactoring; the main layer is integration
- Where it fits
- Apps with simple logic but many connections
Testing honeycomb
- Author / source
- Spotify Engineering, 2018
- Key idea
- For microservices: mostly integration tests of the service, a few implementation detail tests, and integrated tests (that depend on another system's correctness) ideally none
- Where it fits
- Small services whose complexity lies in their interactions
Dodds himself writes that the trophy is a view of tests within a single codebase, devised for frontend work; he never considered microservices. And Fowler, in "On the Diverse And Fantastical Shapes of Testing", shows that the "integration tests" of honeycomb advocates often match what he calls sociable unit tests — so the debate is largely about terminology.
In our experience, a typical web application with its own database does best with a hybrid: unit tests for pure logic (validation, pricing, permissions), integration tests for every endpoint and database query, and a few e2e tests for the journeys where the business loses money if they break: a lead, a payment, a sign-in.
One feature at two levels: a lead form
Take a lead form: name, phone and consent to data processing. The rules: the phone is normalised to +7XXXXXXXXXX, a repeat lead from the same number within 24 hours is a duplicate, and if the manager notification fails, the lead is still saved. We cover the feature with unit and integration tests in Vitest. To keep the example self-contained, the database is in-memory SQLite from the built-in node:sqlite module; the same approach with PostgreSQL in Docker is covered in the backend testing lesson.
npm init -y && npm pkg set type=module
npm install --save-exact express@5.3.0
npm install -D --save-exact vitest@5.0.3 typescript@7.0.2 @types/express@5.0.6 @types/node@24export type LeadInput = { name?: unknown; phone?: unknown; consent?: unknown };
export type Lead = { name: string; phone: string };
export type Validation =
| { ok: true; lead: Lead }
| { ok: false; errors: Record<string, string> };
// "8 (707) 123-45-67", "+7 707 123 45 67", "7071234567" -> "+77071234567"
export function normalizePhone(raw: string): string | null {
const digits = raw.replace(/\D/g, "");
if (digits.length === 10) return `+7${digits}`;
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) return `+7${digits.slice(1)}`;
return null;
}
export function validateLead(input: LeadInput): Validation {
const errors: Record<string, string> = {};
const name = typeof input.name === "string" ? input.name.trim() : "";
const phone = typeof input.phone === "string" ? normalizePhone(input.phone) : null;
if (name.length < 2) errors.name = "name_too_short";
if (!phone) errors.phone = "phone_invalid";
if (input.consent !== true) errors.consent = "consent_required";
if (!phone || Object.keys(errors).length > 0) return { ok: false, errors };
return { ok: true, lead: { name, phone } };
}import { validateLead, type Lead, type LeadInput } from "./lead";
export interface LeadRepo {
findRecentByPhone(phone: string, since: Date): Promise<{ id: number } | null>;
insert(lead: Lead, createdAt: Date): Promise<number>;
}
export type Notify = (lead: Lead & { id: number }) => Promise<void>;
export type SubmitResult =
| { status: "created"; id: number }
| { status: "duplicate"; id: number }
| { status: "invalid"; errors: Record<string, string> };
const DAY_MS = 24 * 60 * 60 * 1000;
export function createLeadService(deps: { repo: LeadRepo; notify: Notify; now?: () => Date }) {
const now = deps.now ?? (() => new Date());
return {
async submit(input: LeadInput): Promise<SubmitResult> {
const checked = validateLead(input);
if (!checked.ok) return { status: "invalid", errors: checked.errors };
const at = now();
const since = new Date(at.getTime() - DAY_MS);
const recent = await deps.repo.findRecentByPhone(checked.lead.phone, since);
if (recent) return { status: "duplicate", id: recent.id };
const id = await deps.repo.insert(checked.lead, at);
try {
await deps.notify({ ...checked.lead, id });
} catch {
// the lead is already saved: a failed notification must not lose it
}
return { status: "created", id };
},
};
}
export type LeadService = ReturnType<typeof createLeadService>;import { DatabaseSync } from "node:sqlite";
import type { LeadRepo } from "./lead-service";
export function openDb(path = ":memory:"): DatabaseSync {
const db = new DatabaseSync(path);
db.exec(`CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
created_at TEXT NOT NULL
)`);
return db;
}
export function createSqliteRepo(db: DatabaseSync): LeadRepo {
const findRecent = db.prepare(
"SELECT id FROM leads WHERE phone = ? AND created_at >= ? ORDER BY id DESC LIMIT 1",
);
const insert = db.prepare("INSERT INTO leads (name, phone, created_at) VALUES (?, ?, ?)");
return {
async findRecentByPhone(phone, since) {
const row = findRecent.get(phone, since.toISOString());
return row ? { id: Number(row.id) } : null;
},
async insert(lead, createdAt) {
const result = insert.run(lead.name, lead.phone, createdAt.toISOString());
return Number(result.lastInsertRowid);
},
};
}import express from "express";
import type { LeadService } from "./lead-service";
export function createApp(leads: LeadService) {
const app = express();
app.use(express.json());
app.post("/api/leads", async (req, res) => {
const result = await leads.submit(req.body ?? {});
if (result.status === "invalid") return res.status(422).json({ errors: result.errors });
if (result.status === "duplicate") return res.status(409).json({ error: "duplicate", id: result.id });
res.status(201).json({ id: result.id });
});
return app;
}The service receives its dependencies as parameters: the repository, the notify function and a now clock. That is what makes it testable: in a unit test we pass an array instead of a database and a fixed time; in an integration test, real SQL. The tests are split into two Vitest projects so they can be run separately.
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
{ test: { name: "unit", include: ["tests/unit/**/*.test.ts"] } },
{ test: { name: "integration", include: ["tests/integration/**/*.test.ts"] } },
],
},
});import { describe, expect, it } from "vitest";
import { normalizePhone, validateLead } from "../../src/lead";
describe("normalizePhone", () => {
it.each([
["8 (707) 123-45-67", "+77071234567"],
["+7 707 123 45 67", "+77071234567"],
["7071234567", "+77071234567"],
["87071234567", "+77071234567"],
])("%s -> %s", (raw, expected) => {
expect(normalizePhone(raw)).toBe(expected);
});
it.each(["", "12345", "9 707 123 45 67", "+1 202 555 0100 1"])("rejects %j", (raw) => {
expect(normalizePhone(raw)).toBeNull();
});
});
describe("validateLead", () => {
it("returns a normalized lead", () => {
expect(validateLead({ name: " Aigerim ", phone: "8 707 123 45 67", consent: true })).toEqual({
ok: true,
lead: { name: "Aigerim", phone: "+77071234567" },
});
});
it("collects every error at once", () => {
expect(validateLead({ name: "A", phone: "123", consent: false })).toEqual({
ok: false,
errors: { name: "name_too_short", phone: "phone_invalid", consent: "consent_required" },
});
});
});import { describe, expect, it, vi } from "vitest";
import { createLeadService, type LeadRepo } from "../../src/lead-service";
type Row = { id: number; phone: string; createdAt: Date };
// a fake repository: a plain array instead of a database
function fakeRepo(): LeadRepo & { rows: Row[] } {
const rows: Row[] = [];
return {
rows,
async findRecentByPhone(phone, since) {
const row = rows.findLast((r) => r.phone === phone && r.createdAt >= since);
return row ? { id: row.id } : null;
},
async insert(lead, createdAt) {
rows.push({ id: rows.length + 1, phone: lead.phone, createdAt });
return rows.length;
},
};
}
const valid = { name: "Aigerim", phone: "+7 707 123 45 67", consent: true };
describe("lead service", () => {
it("rejects a repeat within 24 hours and accepts it after", async () => {
let now = new Date("2026-10-11T09:00:00Z");
const service = createLeadService({ repo: fakeRepo(), notify: vi.fn(), now: () => now });
expect(await service.submit(valid)).toEqual({ status: "created", id: 1 });
now = new Date("2026-10-12T08:59:00Z"); // 23 h 59 min later
expect(await service.submit(valid)).toEqual({ status: "duplicate", id: 1 });
now = new Date("2026-10-12T09:01:00Z"); // 24 h 1 min later
expect(await service.submit(valid)).toEqual({ status: "created", id: 2 });
});
it("keeps the lead when the notification fails", async () => {
const repo = fakeRepo();
const notify = vi.fn().mockRejectedValue(new Error("telegram is down"));
const service = createLeadService({ repo, notify });
expect(await service.submit(valid)).toEqual({ status: "created", id: 1 });
expect(repo.rows).toHaveLength(1);
expect(notify).toHaveBeenCalledOnce();
});
it("does not touch the repository for invalid input", async () => {
const repo = fakeRepo();
const insert = vi.spyOn(repo, "insert");
const service = createLeadService({ repo, notify: vi.fn() });
const result = await service.submit({ name: "Aigerim", phone: "123", consent: true });
expect(result.status).toBe("invalid");
expect(insert).not.toHaveBeenCalled();
});
});import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../src/app";
import { createLeadService } from "../../src/lead-service";
import { createSqliteRepo, openDb } from "../../src/sqlite-repo";
// real HTTP, routing, JSON parsing and SQL; only the external notifier is a double
const db = openDb(":memory:");
const notify = vi.fn().mockResolvedValue(undefined);
let now = new Date("2026-10-11T09:00:00Z");
const app = createApp(createLeadService({ repo: createSqliteRepo(db), notify, now: () => now }));
let server: Server;
let base: string;
beforeAll(async () => {
server = app.listen(0); // port 0: the OS picks a free port
await new Promise((resolve) => server.once("listening", resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise((resolve) => server.close(resolve)));
beforeEach(() => {
db.exec("DELETE FROM leads"); // every test starts from an empty table
notify.mockClear();
now = new Date("2026-10-11T09:00:00Z");
});
const post = (body: unknown) =>
fetch(`${base}/api/leads`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
describe("POST /api/leads", () => {
it("201: saves a normalized phone and notifies once", async () => {
const res = await post({ name: "Aigerim", phone: "8 (707) 123-45-67", consent: true });
expect(res.status).toBe(201);
const { id } = await res.json();
expect(db.prepare("SELECT name, phone FROM leads WHERE id = ?").get(id)).toEqual({
name: "Aigerim",
phone: "+77071234567",
});
expect(notify).toHaveBeenCalledOnce();
});
it("422: returns field errors and writes nothing", async () => {
const res = await post({ name: "Aigerim", phone: "123" });
expect(res.status).toBe(422);
expect(await res.json()).toEqual({ errors: { phone: "phone_invalid", consent: "consent_required" } });
expect(db.prepare("SELECT count(*) AS n FROM leads").get()).toEqual({ n: 0 });
});
it("409: the same phone in another format within 24 hours", async () => {
await post({ name: "Aigerim", phone: "+7 707 123 45 67", consent: true });
now = new Date("2026-10-11T20:00:00Z");
const res = await post({ name: "Aigerim", phone: "87071234567", consent: true });
expect(res.status).toBe(409);
expect(db.prepare("SELECT count(*) AS n FROM leads").get()).toEqual({ n: 1 });
});
});$ npx tsc -p .
$ npx vitest run --project unit
Test Files 2 passed (2)
Tests 13 passed (13)
Duration 183ms (transform 54%, import 27%, worker 9%, tests 9%)
$ npx vitest run --project integration --reporter verbose
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 201: saves a normalized phone and notifies once 32ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 422: returns field errors and writes nothing 3ms
✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 409: the same phone in another format within 24 hours 18ms
Test Files 1 passed (1)
Tests 3 passed (3)
Duration 315ms (import 46%, tests 30%, transform 22%, worker 2%)tsc finished with no errors and no output.There are more tests at the lower level — 13 versus 3 — and they enumerate the variants: phone formats, the "23 h 59 min" and "24 h 1 min" boundaries, a failed notification. The integration tests do not repeat that enumeration; they check one representative per HTTP outcome and what a unit test cannot see. In this example the levels differ little in speed because SQLite lives in memory; with PostgreSQL in a container, the integration run in our backend testing lesson took about 6 seconds versus 0.2 seconds for the unit tests.
Now let's see why the second level is needed. Flip the comparison operator in the SQL — a mistake that is easy to make and hard to spot in review:
--- a/src/sqlite-repo.ts
+++ b/src/sqlite-repo.ts
const findRecent = db.prepare(
- "SELECT id FROM leads WHERE phone = ? AND created_at >= ? ORDER BY id DESC LIMIT 1",
+ "SELECT id FROM leads WHERE phone = ? AND created_at <= ? ORDER BY id DESC LIMIT 1",
);$ npx vitest run
❯ |integration| tests/integration/leads.api.test.ts (3 tests | 1 failed) 72ms
❯ POST /api/leads (3)
× 409: the same phone in another format within 24 hours 31ms
FAIL |integration| tests/integration/leads.api.test.ts > POST /api/leads > 409: the same phone in another format within 24 hours
AssertionError: expected 201 to be 409 // Object.is equality
❯ tests/integration/leads.api.test.ts:63:24
Test Files 1 failed | 2 passed (3)
Tests 1 failed | 15 passed (16)All 13 unit tests passed: the fake repository is written correctly and they never execute SQL. Only the integration test for 409 caught the bug. The same happens if you remove app.use(express.json()) from app.ts: the unit tests stay green, while all three integration tests fail — the request body is not parsed and the server answers 422 to a valid lead. The reverse happens too: break parsing of the "8 (707)…" format and the normalizePhone unit test points at the exact line, while the integration test only says it got 422 instead of 201.
Where to mock and how to prepare test data
Mocks, stubs and fakes are kinds of test double — an object that stands in for a real dependency. The higher the test level, the fewer doubles: every double is a place where the test stops checking reality.
Unit
- What we replace
- Database, network, time, random generators, other modules
- What stays real
- The function or class under test
Integration
- What we replace
- Only what is outside your responsibility: payment gateway, messenger, third-party API
- What stays real
- HTTP layer, routes, validation, your own database and migrations
E2E
- What we replace
- As little as possible; paid external services in their sandbox modes
- What stays real
- The whole application: frontend, API, database
Test data is a common source of brittle tests. Working rules:
- Each test creates the data it needs and does not rely on what the previous one left behind: in the example
beforeEachclears the table and resets the clock - Data is minimal and self-explanatory: the duplicate test makes it obvious the number is the same and only the format differs
- Time, randomness and ids do not come from the environment: the clock is passed as a parameter or frozen with
vi.useFakeTimers()andvi.setSystemTime() - Complex objects get a factory with sensible defaults, overriding only the field that matters for the test
- No real personal data and no copies of the production database: phones and names are made up, keys come from the test environment's variables
- Tests sharing a database either run sequentially or each works in its own schema or transaction
Flaky tests: causes and cures
A flaky test is one that both passes and fails on the same code. The Google Testing Blog reports that across Google's whole corpus about 1.5% of test runs produce such a result, and 84% of observed transitions from pass to fail involve a flaky test. The main damage is lost trust: the team gets used to re-running a red build and misses a real bug.
Lack of isolation
- How it shows up
- A test passes alone but fails in the full run or in a different order
- Cure
- Fresh data in every test; run in random order to expose dependencies
Asynchronous behaviour
- How it shows up
sleep(500)"to give it time"; on a slow machine it isn't enough- Cure
- Wait for an event or condition (callback, polling with a timeout,
await), not a fixed time
Remote services
- How it shows up
- The test fails when someone else's API or test environment is down
- Cure
- A double at integration level; the real service only in separate contract or e2e checks
Time
- How it shows up
- The test fails around midnight, at month end or in another time zone
- Cure
- A clock wrapper the test can replace:
nowin the example,vi.setSystemTime()
Resource leaks
- How it shows up
- The longer the run, the more failures: connections, ports or memory run out
- Cure
- Close servers and connections in
afterAll; listen on port0, not a fixed one
$ npx vitest run --sequence.shuffle
Test Files 3 passed (3)
Tests 16 passed (16)In "Eradicating Non-Determinism in Tests" Fowler advises moving a flaky test into quarantine straight away — a separate suite that does not block the build — and fixing it quickly, limiting the size of the quarantine or how long a test may stay there. Automatic retries (retry in Vitest, retries in Playwright, which labels such tests "flaky") reduce noise, but the same Google post warns that retries teach people to ignore flakiness. Use them to collect a list of problem tests, not to hide it.
A test plan for a feature, tests in CI and exercises
Before writing tests it helps to lay out a feature's checks by level — that gives you a short test plan. For the lead form it looks like this:
Phone formats, required fields, error codes
- Level
- Unit
- Why here
- Many variants, pure logic
Duplicate within 24 hours, the time boundary
- Level
- Unit + one integration
- Why here
- The rule lives in the service but works through an SQL query
A failed notification does not lose the lead
- Level
- Unit
- Why here
- Replacing the external service is easiest here
Status codes 201, 409, 422, JSON parsing, database write
- Level
- Integration
- Why here
- Only testable with the real route and database together
Form behaviour: labels, field errors, disabled button
- Level
- Component (frontend unit/integration)
- Why here
- Testing Library without a browser is faster than e2e
A user submits the form and sees a confirmation
- Level
- E2E, one or two tests
- Why here
- The key journey that brings in money
How to write each level is covered separately: component tests in the frontend testing lesson, unit and integration API tests with PostgreSQL in the backend testing lesson, and the specifics of phones and app stores in the mobile testing lesson. A lesson on e2e tests with Playwright is in the library plan.
On every commit and pull request
Linter, type check and unit tests: seconds, and a failure blocks the merge.
Same pipeline, next step
Integration tests with a database in a container; GitHub Actions has service containers for this.
Before a deploy or on a schedule
A small e2e set for the key journeys in an environment as close to production as possible.
After a deploy
A smoke check of the live address and monitoring: tests do not replace alerts on real user errors.
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.
Sources
- Martin Fowler — Test Pyramid
- Ham Vocke — The Practical Test Pyramid
- Kent C. Dodds — The Testing Trophy and Testing Classifications
- web.dev — Pyramid or Crab? Find a testing strategy that fits
- Google Testing Blog — Flaky Tests at Google and How We Mitigate Them
- Martin Fowler — Eradicating Non-Determinism in Tests