What you'll learn
- What unit, integration, and end-to-end tests each cover, and what each one costs
- Why most real bugs live in the seams between pieces, and which tests catch them
- How to read the testing pyramid as cost advice instead of law
- When a mock helps and when it turns a test into theater
- How to split testing effort on a first real project
on this page
The first time you ask how to test your code, you get a vocabulary lesson instead of an answer. Unit tests, integration tests, end-to-end tests, the testing pyramid, mocks, stubs, spies. People argue about these definitions with an intensity usually reserved for tabs versus spaces.
Here’s the calmer version. The labels describe how much of your app a single test touches, and that one variable drives everything else: how fast the test runs, how precisely it points at a bug when it fails, and how often it fails for fake reasons. Treat them as sizes (small, medium, large) and the religion mostly evaporates.
To keep this concrete, we’ll test one feature at all three sizes: a signup flow. A form collects an email and a password, the server validates them, hashes the password, inserts a user row, and sends a welcome email. Every real project has something shaped like this.
Unit tests: one function, alone in a room
A unit test checks one small piece of code in isolation, usually a single function. Input goes in, you assert on what comes out. No database, no network, no other modules pulling strings.
In the signup flow, password validation is a natural unit. It’s pure logic: same input, same output, touches nothing.
// src/lib/validate.js
export function validatePassword(password) {
if (password.length < 8) return "Password must be at least 8 characters";
if (!/[0-9]/.test(password)) return "Password must contain a number";
return null; // null means valid
}
The test file sits right next to it. We’ll use Vitest as the test runner: the program that finds files ending in .test.js, runs them, and reports what passed.
// src/lib/validate.test.js
import { describe, it, expect } from "vitest";
import { validatePassword } from "./validate.js";
describe("validatePassword", () => {
it("rejects passwords under 8 characters", () => {
expect(validatePassword("abc1")).toBe("Password must be at least 8 characters");
});
it("rejects passwords with no number", () => {
expect(validatePassword("abcdefgh")).toBe("Password must contain a number");
});
it("accepts a valid password", () => {
expect(validatePassword("abcdef12")).toBeNull();
});
});
npm install -D vitest A dev dependency. The test runner stays out of your production build.
npx vitest run ✓ src/lib/validate.test.js (3 tests) 4ms
Test Files 1 passed (1)
Tests 3 passed (3)
Duration 310ms Four milliseconds for the tests; the rest is Vitest starting up. Drop the word run and Vitest stays open, watching your files and rerunning on every save.
Two things make unit tests special. First, speed: a whole file of them finishes in single-digit milliseconds, so you can run hundreds after every save without noticing. Second, blame: when rejects passwords with no number goes red, exactly one function can be guilty, and the test names it. No log spelunking, no wondering whether the database was in a weird state.
The limit is just as sharp. This test cannot tell you whether signup works. validatePassword can be flawless while the route that calls it reads the wrong field, or the insert quietly fails, or the response never gets sent. Unit tests prove the bricks are solid. They say nothing about the wall.
Integration tests: the pieces, wired together
An integration test runs several real pieces of your app together and checks the behavior through one entry point. For a backend, the classic shape: load your app in the test process, fire a real HTTP request at it, and assert on the response plus whatever landed in a real test database.
Here’s signup at that size, using Supertest, a small library that takes an Express app and lets tests make requests against it without you starting a server by hand:
// src/routes/signup.test.js
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { app } from "../app.js";
import { db } from "../db.js";
// Start each test from an empty table so tests can't contaminate each other
beforeEach(async () => {
await db.query("DELETE FROM users");
});
describe("POST /signup", () => {
it("creates a user and returns 201", async () => {
const res = await request(app)
.post("/signup")
.send({ email: "[email protected]", password: "abcdef12" });
expect(res.status).toBe(201);
const { rows } = await db.query("SELECT email FROM users");
expect(rows).toHaveLength(1);
expect(rows[0].email).toBe("[email protected]");
});
it("rejects a duplicate email with 409", async () => {
await request(app)
.post("/signup")
.send({ email: "[email protected]", password: "abcdef12" });
const res = await request(app)
.post("/signup")
.send({ email: "[email protected]", password: "abcdef12" });
expect(res.status).toBe(409);
});
});
The one structural requirement: app.js has to export the app without calling listen, so tests can drive it directly.
Look at how much real machinery one test exercises: routing, JSON parsing, your validation, password hashing, the SQL insert, the unique constraint on email. That’s the point, because the seams between those pieces are where most real bugs live. The route reads req.body.username while the form sends userName. The query mentions a column that last week’s migration renamed. The handler forgets an await and returns 201 before the insert finished. Each piece is individually correct; the bug exists only in the wiring. Unit tests can’t see it. This test trips over it immediately.
The bill: integration tests do real input and output, so each one takes tens of milliseconds instead of fractions of one, more once a real database is in the loop. And when one fails, the suspect list is “everything on that request’s path” rather than one function. Still a short list. Much shorter than the next size up.
End-to-end tests: the whole app, in a browser
An end-to-end test (E2E) drives your application the way a user does. A real browser loads the real frontend, which calls the real backend, which writes to the database. Playwright is the usual tool. Here’s the signup flow at full size:
// e2e/signup.spec.js
import { test, expect } from "@playwright/test";
test("a visitor can sign up", async ({ page }) => {
await page.goto("http://localhost:3000/signup");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("abcdef12");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("Welcome, [email protected]")).toBeVisible();
});
When this passes, signup works, full stop. The form is reachable, the fields are wired, the button submits, the API answers, the redirect happens, the greeting renders. No other kind of test gives you that sentence.
You pay for that certainty twice. Speed: each E2E test launches a browser, renders pages, and waits on real network round trips, so it’s measured in seconds, and a suite of them in minutes. Reliability: these are the tests that fail when nothing is wrong, because an animation hadn’t finished or a button wasn’t clickable for another 50 milliseconds. The polite term is flaky. And when one fails honestly, the suspect list is your entire application.
The pyramid is a price list, not a law
You’ll meet the testing pyramid in every talk about this subject: a wide base of unit tests, a middle band of integration tests, a small tip of E2E. It often gets delivered as doctrine, complete with ratios you’re supposed to hit.
Read it as cost advice instead. Bigger tests cost more, in runtime, in debugging, in flake. So buy the expensive ones sparingly and the cheap ones in bulk.
| Unit | Integration | End-to-end | |
|---|---|---|---|
| Touches | one function | several modules plus a database | the whole app in a browser |
| Speed | milliseconds per file | tens of milliseconds per test | seconds per test |
| When it fails | one suspect | one request path | everything is a suspect |
| Fake failures | basically never | rare | often enough to be famous |
What the pyramid is not: a shape your suite must match. A typical web app is mostly glue (routes, queries, a bit of validation around a database), and glue is exactly what integration tests cover, so a middle-heavy suite is normal and healthy for that kind of project. Nobody is grading your geometry. The drawing exists to stop people from writing four hundred E2E tests and then wondering why CI takes an hour and fails at random.
Mocks: stunt doubles for dependencies
One problem with the integration test above: the real signup route sends a welcome email. Twenty test runs means twenty real emails through a real provider, which is slow at best and a suspended account at worst.
A mock fixes this. A mock replaces a dependency with a stand-in you control. Think stunt double: looks enough like the real thing for the scene to work, and nobody actually gets hurt. In Vitest:
// at the top of src/routes/signup.test.js
import { vi } from "vitest";
// Swap the real mail module for a fake before the app loads it
vi.mock("../mail.js", () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
}));
Now the route runs unchanged, calls sendWelcomeEmail, and the fake instantly answers “sure, sent.” You can even assert it was called with the right address. Mocking earns its keep in two situations: the dependency leaves your app (email, payments, any third-party API that costs money or touches the outside world), or you need to force a condition that’s hard to produce for real, like the provider timing out, so you can test your error handling.
The danger is the habit forming. Keep reaching for mocks and you eventually produce this:
// looks like a test, proves close to nothing
it("creates a user", async () => {
const fakeDb = { insertUser: vi.fn() };
const fakeMailer = { sendWelcomeEmail: vi.fn() };
await signup({ email: "[email protected]", password: "abcdef12" }, fakeDb, fakeMailer);
expect(fakeDb.insertUser).toHaveBeenCalled();
});
What did we verify? That signup calls insertUser. You wrote both halves of that sentence five minutes apart; of course it does. Every bug this test should catch (broken SQL, a violated constraint, a misspelled column) lives inside the real insertUser, on the far side of the mock. The test passes whether the feature works or not. Green checkmark, broken signup.
The working rule: mock at the boundary of what you own. Other people’s services get stunt doubles. Your own database, in an integration test, should usually be the real thing. The test-database kind of real.
What to actually write for a first project
For a first real project, something with users and a database and a deploy, here’s an allocation that holds up:
Mostly integration tests, aimed at behavior users depend on. Signup, login, creating the thing your app exists to create, listing it back, and the payment path if there is one. One test per behavior per failure mode that matters: it works, it rejects bad input, it rejects duplicates. These catch the seam bugs, which is to say most bugs, at a price you can afford on every push.
A few unit tests, aimed at tricky pure logic. Validation rules, date math, price calculation, parsing. Anything with real edge cases and no input or output. Don’t unit test glue: a route handler that calls two functions and returns JSON doesn’t need its own unit test, because the integration test already walks straight through it.
Zero or one end-to-end test. If you write one, make it a smoke test of the single most important path: sign up, log in, do the core thing once. Run it before deploys rather than on every save. Skipping E2E entirely on a small project is a legitimate choice, and a better one than babysitting a flaky suite you’ve learned to ignore.
The reason to bother with any of this comes back to shipping. The scary moment in a real project is never writing new code; it’s changing code that strangers already rely on. With a dozen integration tests around signup, login, and the core path, that fear gets a concrete answer: refactor the handler on Tuesday night, run npx vitest run, and know in under a minute whether Wednesday’s deploy would have broken someone’s login. The next guide wires this up for real: Vitest installed, a test database running in Docker, and a first suite around an actual app.
signal check
Can you answer these without scrolling back up?
-
A test loads your Express app, sends it a real POST /signup request, and checks that a row landed in a test database. Which kind of test is that?
Integration. Several real pieces (routing, validation, hashing, the database) run wired together, exercised through one entry point. It is slower than a unit test, but it covers the seams where those pieces meet, which is where most real bugs live.
-
Why can every unit test pass while signup is still broken?
Unit tests check one function in isolation, and most real bugs live in the wiring between functions: a route reading the wrong field name, a query hitting a renamed column, a missing await. Each piece is correct alone, so only a test that runs the real pieces together can trip over the bug.
-
Your test replaces the database with a mock, then asserts the mock was called. What does that prove?
Only that your code calls the function you stubbed a few lines earlier. The bugs a database test should catch (broken SQL, violated constraints, misspelled columns) all live on the far side of the mock, so the test passes whether the feature works or not.
progress is stored in this browser only