#Unit, integration, contract and end-to-end tests
AddedTests differ in how much of the real system they include. A unit test runs one function or class with everything else absent or faked. An integration test runs several real pieces together: your route handler, your validation, your SQL against a real database. A contract test checks that two separately deployed things (the web UI and the API, the API and a device) agree on the shape of what they exchange, without running both at once. An end-to-end (E2E) test drives the whole deployed system the way a user does: a browser clicking through the web UI, against a running API and database.
The more real system a test includes, the more kinds of bug it can catch, and the more it costs: it's slower, it needs more setup, it fails for more reasons that aren't bugs (a slow container, a network blip), and when it fails it points less precisely at the cause. A failing unit test says "this function returns the wrong thing for this input"; a failing E2E test says "something between the browser and the database is wrong".
The mental model: checking a car. Unit tests are each part on a workbench: the brake caliper squeezes with the right force. Integration tests are the engine on a test stand with real fuel lines and real sensors. A contract test is the agreed drawing of the bolt pattern between the wheel supplier and the axle supplier: each checks its own part against the drawing, and they never have to meet. The E2E test is the test drive. You need a few test drives, but you wouldn't find a faulty caliper by driving around the block a thousand times.
| Level | Exercises | Catches | Misses | Speed | Example for a web UI + device API |
|---|---|---|---|---|---|
| Unit | one function or class, collaborators faked | logic errors, edge cases, boundaries | wiring, SQL, serialisation, config | milliseconds | validateDeviceName, a reducer, a date formatter |
| Integration | real pieces together: HTTP + handler + database, or a UI component with its real children | wrong SQL, missing migrations, status codes, JSON shape, middleware order | other services, the real browser, deployment config | tens to hundreds of ms | PATCH /api/v1/devices/:id against PostgreSQL in a container |
| Contract | each side against a recorded agreement | breaking API changes before they're deployed | behaviour behind the fields | milliseconds | "the device page reads firmware.version as a string" |
| End-to-end | the deployed system through its real UI | broken journeys, environment and config errors, things nobody thought to test | precise cause; anything off the happy path (too slow to test it all) | seconds per test | sign in, open a room, rename its device, see the new name |
Here's one feature, renaming a device, tested at the unit level and the integration level with Node's built-in test runner:
// One feature, "rename a device", tested at two levels with node:test. The file runs its own tests through
// node:test's run() and prints a short report (no timings), so the output is the same on every run.
import { test, run } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
export function validateDeviceName(name) {
if (typeof name !== "string") return "name must be a string";
const trimmed = name.trim();
if (trimmed.length < 1 || trimmed.length > 32) return "name must be 1-32 characters";
if (!/^[\p{L}\p{N} ._-]+$/u.test(trimmed)) return "name has characters a device can't display";
return null;
}
export function createApp(devices) {
return createServer(async (req, res) => {
const match = req.method === "PATCH" && req.url.match(/^\/api\/v1\/devices\/([\w-]+)$/);
const send = (status, body) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
if (!match) return send(404, { title: "Not found" });
const device = devices.get(match[1]);
if (!device) return send(404, { title: "No such device" });
let body = "";
for await (const chunk of req) body += chunk;
const { name } = JSON.parse(body || "{}");
const problem = validateDeviceName(name);
if (problem) return send(422, { title: "Invalid name", detail: problem });
device.name = name.trim();
send(200, device);
});
}
if (process.env.NODE_TEST_CONTEXT) {
test("unit: accepts a normal room name", () => assert.equal(validateDeviceName(" Boardroom 4 "), null));
test("unit: rejects an empty name", () => assert.equal(validateDeviceName(" "), "name must be 1-32 characters"));
test("unit: rejects emoji the panel can't render", () => assert.match(validateDeviceName("War room 🚀"), /characters/));
test("integration: PATCH renames the device and returns it", async (t) => {
const devices = new Map([["dev-1", { id: "dev-1", name: "Room 1", model: "VC-200" }]]);
const server = createApp(devices).listen(0, "127.0.0.1");
t.after(() => server.close());
await new Promise((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${server.address().port}/api/v1/devices/dev-1`;
const res = await fetch(url, { method: "PATCH", body: JSON.stringify({ name: " Boardroom 4 " }) });
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { id: "dev-1", name: "Boardroom 4", model: "VC-200" });
assert.equal(devices.get("dev-1").name, "Boardroom 4");
});
test("integration: invalid name is a 422 problem and nothing changes", async (t) => {
const devices = new Map([["dev-1", { id: "dev-1", name: "Room 1" }]]);
const server = createApp(devices).listen(0, "127.0.0.1");
t.after(() => server.close());
await new Promise((resolve) => server.once("listening", resolve));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/devices/dev-1`, { method: "PATCH", body: "{}" });
assert.equal(res.status, 422);
assert.equal((await res.json()).detail, "name must be a string");
assert.equal(devices.get("dev-1").name, "Room 1");
});
} else {
for await (const { type, data } of run({ files: [import.meta.filename] })) {
if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n ${data.details.error.cause?.message}`);
}
}✔ unit: accepts a normal room name
✔ unit: rejects an empty name
✔ unit: rejects emoji the panel can't render
✔ integration: PATCH renames the device and returns it
✔ integration: invalid name is a 422 problem and nothing changes- How the file runs. Run directly,
NODE_TEST_CONTEXTisn't set, so theelsebranch callsnode:test'srun()on this same file.run()starts a child process for it in test mode, where Node setsNODE_TEST_CONTEXT, so the child takes the first branch and registers the tests. The parent prints one line per top-level result and no timings, which is what makes the output identical on every run. In a real project you'd keep the tests indevices.test.mjsand runnode --test. - Unit tests call
validateDeviceNamedirectly: no server, no network, microseconds each. They pin the rules (trim, 1–32 characters, only letters, digits, spaces and._-) including the edge cases that are cheap to list here and expensive to reach through a browser: an all-spaces name, an emoji the device's panel can't render. - Integration tests start the real HTTP server on port 0 of
127.0.0.1(the OS picks a free port, so parallel test files never collide), wait for itslisteningevent before readingserver.address().port, send a realPATCHwithfetchto that same address, and check three things: the status code, the JSON body, and the state behind the API (devices.get("dev-1").name).t.after(() => server.close())closes the server even if an assertion fails, so a failing test can't hang the run. The explicit host matters: a barelisten(0)binds::, and another program on the machine can already hold the same port number on127.0.0.1, so a request to127.0.0.1(or tolocalhost, if it resolves there) could reach the wrong server. - The second integration test sends
{}and expects a 422 with a problem body, and checks that nothing changed. That's the kind of bug only this level catches: a handler that validates but writes first, or returns 200 with an error inside. - What neither level covers: the real database (the
Mapstands in for it; in CI the integration job uses PostgreSQL from a service container, see A GitHub Actions pipeline for a web UI and API), the web UI, and whether the deployed services agree. That's what contract and E2E tests are for.
When the web UI and the API are deployed separately (or the API and the software on thousands of devices), the dangerous change is one that's fine for the provider and breaks a consumer. E2E tests catch it late and expensively; contract tests catch it on the provider's own pipeline. Pact, the best-known tool, describes contract testing as "a technique for testing an integration point by checking each application in isolation", and is consumer-driven: the contract is generated by running the consumer's tests, so it lists only what consumers actually use (docs.pact.io, checked 27 October 2026). This model shows the idea without the tool:
// Consumer-driven contracts in miniature: each consumer records the fields it actually reads from an API response,
// and the provider checks every candidate build against all recorded contracts before it deploys.
const contracts = {
"web UI device page": {
request: "GET /api/v1/devices/dev-1",
expects: { id: "string", name: "string", status: ["online", "offline"], "firmware.version": "string" },
},
"room-booking service": {
request: "GET /api/v1/devices/dev-1",
expects: { id: "string", status: ["online", "offline"] },
},
};
const get = (obj, path) => path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
function verify(contract, response) {
const problems = [];
for (const [path, rule] of Object.entries(contract.expects)) {
const value = get(response, path);
if (value === undefined) problems.push(`missing ${path}`);
else if (Array.isArray(rule) && !rule.includes(value)) problems.push(`${path} is "${value}", expected one of ${rule.join("|")}`);
else if (!Array.isArray(rule) && typeof value !== rule) problems.push(`${path} is a ${typeof value}, expected ${rule}`);
}
return problems;
}
const providerBuilds = {
"api v1.8 (current)": { id: "dev-1", name: "Boardroom 4", status: "online", firmware: { version: "10.2.1" } },
"api v1.9 adds lastSeen": { id: "dev-1", name: "Boardroom 4", status: "online", firmware: { version: "10.2.1" }, lastSeen: "2026-10-27T09:00:00Z" },
"api v2.0 flattens firmware": { id: "dev-1", name: "Boardroom 4", status: "online", firmwareVersion: "10.2.1" },
"api v2.1 adds status 'updating'": { id: "dev-1", name: "Boardroom 4", status: "updating", firmware: { version: "10.3.0" } },
};
for (const [build, response] of Object.entries(providerBuilds)) {
const results = Object.entries(contracts).map(([consumer, c]) => [consumer, verify(c, response)]);
const ok = results.every(([, p]) => p.length === 0);
console.log(`${build}: ${ok ? "safe to deploy" : "BLOCKED"}`);
for (const [consumer, p] of results) if (p.length) console.log(` breaks ${consumer}: ${p.join("; ")}`);
}api v1.8 (current): safe to deploy
api v1.9 adds lastSeen: safe to deploy
api v2.0 flattens firmware: BLOCKED
breaks web UI device page: missing firmware.version
api v2.1 adds status 'updating': BLOCKED
breaks web UI device page: status is "updating", expected one of online|offline
breaks room-booking service: status is "updating", expected one of online|offline- Each consumer records the fields it reads and what it expects of them. The web UI's device page reads four fields, including
firmware.version; the room-booking service only readsidandstatus. In Pact these come from the consumers' own tests, so they can't drift from what the code does. verifychecks one provider response against one contract: a missing field, an unexpected enum value, or a wrong type is a problem. Extra fields are ignored, because no consumer reads them.- v1.9 adds
lastSeen: safe for everyone. Adding fields is backwards compatible, and the contracts prove it rather than leaving it to hope. - v2.0 flattens
firmware.versionintofirmwareVersion: blocked, and the report names exactly who breaks (the device page) and who doesn't (room booking). The provider team can now add the new field alongside the old one, migrate the UI, then remove the old field: expand and contract. - v2.1 adds the status
"updating": blocked for both consumers, even though no field was removed. A new enum value is a breaking change for a consumer that switches on the value, the most commonly missed kind of API break. In Pact's workflow the provider runs this verification in CI, and a broker answers "can I deploy this version?" against the versions of the consumers actually in production.
When a unit or integration test replaces a collaborator, the replacement is a test double, and the kind matters, because it decides what the test can prove. The vocabulary is Gerard Meszaros's (xUnit Test Patterns, 2007), popularised by Fowler's article "Mocks Aren't Stubs": a dummy is passed but never used; a stub returns canned answers; a spy is a stub that also records how it was called, for the test to check afterwards; a fake is a working but simplified implementation (an in-memory store); a mock is pre-programmed with the calls it expects and verifies them itself. node:test has mock.fn() and mock.method() built in; despite the name, by these definitions what mock.fn() gives you is a spy:
// Test doubles in one node:test suite: a fake works, a stub answers, a spy records and the test checks its calls afterwards.
import { test, mock, run } from "node:test";
import assert from "node:assert/strict";
// Code under test: tell every online device on an old firmware version that an update is available.
export async function announceUpdate({ devices, getLatestVersion, push }) {
const latest = await getLatestVersion();
const outdated = (await devices.list()).filter((d) => d.online && d.firmware !== latest);
for (const d of outdated) await push(d.id, { type: "update-available", version: latest });
return outdated.length;
}
class FakeDeviceStore { // a real, working implementation, just in memory
#rows = new Map();
async add(device) { this.#rows.set(device.id, { ...device }); }
async list() { return [...this.#rows.values()]; }
}
if (process.env.NODE_TEST_CONTEXT) {
test("pushes only to online devices on old firmware", async () => {
const devices = new FakeDeviceStore(); // fake
await devices.add({ id: "dev-1", online: true, firmware: "10.2.0" });
await devices.add({ id: "dev-2", online: false, firmware: "10.2.0" });
await devices.add({ id: "dev-3", online: true, firmware: "10.3.0" });
const getLatestVersion = async () => "10.3.0"; // stub: canned answer, never checked
const push = mock.fn(async () => {}); // spy: records every call
assert.equal(await announceUpdate({ devices, getLatestVersion, push }), 1);
assert.equal(push.mock.callCount(), 1);
assert.deepEqual(push.mock.calls[0].arguments, ["dev-1", { type: "update-available", version: "10.3.0" }]);
});
test("a failing push stops the run (current behaviour, pinned)", async () => {
const devices = new FakeDeviceStore();
await devices.add({ id: "dev-1", online: true, firmware: "10.2.0" });
await devices.add({ id: "dev-4", online: true, firmware: "10.1.0" });
const push = mock.fn(async (id) => { if (id === "dev-1") throw new Error("push service 503"); });
await assert.rejects(announceUpdate({ devices, getLatestVersion: async () => "10.3.0", push }), /503/);
assert.equal(push.mock.callCount(), 1); // dev-4 was never told: a finding to raise, not to hide
});
} else {
for await (const { type, data } of run({ files: [import.meta.filename] })) {
if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n ${data.details.error.cause?.message}`);
}
}✔ pushes only to online devices on old firmware
✔ a failing push stops the run (current behaviour, pinned)announceUpdateneeds three collaborators: a device store, a way to get the latest firmware version, and a push channel. Each is passed in, so the test can choose what stands in for it.FakeDeviceStoreis a fake: it really stores and lists devices, in aMap. Fakes keep tests readable (you set up data the way production would) and survive refactors, because they implement the real interface rather than a script of calls.getLatestVersionis a stub: it always answers"10.3.0"and the test never asks how it was called.pushis a spy made withmock.fn():push.mock.callCount()andpush.mock.calls[0].argumentsshow it was called once, fordev-1only (dev-2 is offline, dev-3 is already current). Checking the calls afterwards is behaviour verification with a spy; a strict Meszaros mock would have been given those expectations up front and failed by itself on any other call. In everyday speech people call both "mocks", so say which you mean.- The second test makes the spy throw for
dev-1. The function rejects with the 503 anddev-4is never told about the update. The test pins that behaviour, and it's also a finding: one failed push silently skips every device after it. That's worth raising, and it's exactly the kind of thing a fake push channel surfaces and a real one rarely does in a test. - The rule of thumb from Mocking everything: use doubles for the edges you don't own (push services, the clock, other teams' APIs), fakes or the real thing for what you do own, and assert on outcomes before calls.
E2E tests are the only ones that prove the deployed pieces work together through the real UI, so keep a small set and choose them deliberately:
- The critical journeys, the ones that would page someone: sign in; find a device and change a setting; start a meeting from a room; the admin flow a client's IT team uses daily. Ten to thirty tests, not three hundred.
- Not the edge cases. "Name with 33 characters is rejected" belongs in a unit test; the E2E suite checks that a rejection is shown once.
- Run them against a production-like environment with seeded data, in parallel shards, with retries reported (Playwright labels a test that failed and then passed on retry as "flaky", rather than hiding it).
- Device platforms add a level: tests against real or emulated hardware ("hardware in the loop") for the parts that depend on the device. They're slow and scarce, so they run on a schedule or before a release, and the API contract tests keep everyday changes from needing them.
For each bug, which is the cheapest kind of test that would catch it? (a) stepVolume(95, 10) returns 105. (b) The API returns firmware_version but the web UI reads firmwareVersion. (c) A migration wasn't added for a new column, so PATCH /devices/:id fails with a SQL error. (d) The production build points the web UI at the staging API URL.
Show answer
(a) unit, (b) contract, (c) integration, (d) end-to-end (or a smoke test after deploy).
- (a) is pure logic with a boundary: a unit test with the input
(95, 10)catches it in microseconds and names the function. - (b) is a disagreement between two separately built pieces. Each side's own tests pass, because each is self-consistent. A consumer contract ("the device page reads
firmwareVersion") verified against the provider's response catches it in the API's pipeline. An E2E test would catch it too, later and slower. - (c) needs the real schema: a unit test with a mocked repository passes. An integration test that runs the migrations and then calls the endpoint against PostgreSQL fails with the real SQL error.
- (d) is configuration of the deployed artefact. No test inside the codebase sees it; a post-deploy smoke test or an E2E test against production-like config does. That's also why "build once, inject config at deploy time" matters (see Environments, approvals and deployment strategies).