A test runner is built in
Vitest and Jest are still worth it for a large suite — watch mode, mocking, coverage and a friendlier diff. For a server, the built-in runner is enough for a long time, and it has no configuration at all.
// doors.test.js
import { test, describe, before, mock } from "node:test";
import assert from "node:assert/strict";
test("totals the days", () => {
assert.equal(total([{ days: 5 }, { days: 10 }]), 15);
});
// node --test run every *.test.js
// node --test --watch re-run on save
// node --test --experimental-test-coverage
The assertions you need
assert.equal(a, b); // ===
assert.deepEqual(obj, { … }); // structural
assert.ok(value); // truthy
assert.throws(() => f(), /required/);
await assert.rejects(() => load(), { code: "ENOENT" });
assert.match(text, /created/);
node:assert without /strict uses ==, so assert.equal("1", 1) passes. That is not a test you want.
Arrange, act, assert
A test name should say what is true, not what the code does: "refuses a door with no name" beats "test createDoor 2". When it fails at midnight, the name is the whole message.
test("refuses a door with no name", async () => {
const api = await startTestServer(); // arrange
const res = await api.post("/doors", { days: 5 }); // act
assert.equal(res.status, 422); // assert
assert.match(res.body.error, /Validation/);
});
What is worth testing on a server
Not worth testing: that Express routes, that your ORM saves, that a getter returns what you set. Mocking a module and then asserting that you called the mock tests nothing but your own wiring.
- Pure logic — validation, formatting, pricing, permissions. Cheap, fast, and where most bugs live.
- Routes, through the real HTTP layer: status, shape, and the failure paths.
- Database access, against a real database (a container or a temporary schema) — not a mock that agrees with you.
- Bugs, once each.
Testing a route without a port
Keeping app and listen in separate files is the single change that makes a server testable.
import request from "supertest";
import { app } from "./app.js";
test("lists doors", async () => {
const res = await request(app).get("/doors");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.items));
});
// export the app without calling listen(), so tests can drive it directly
// app.js: export const app = express(); …
// server.js: app.listen(process.env.PORT);
Fixtures that stay honest
- Every test creates what it needs and cleans up after itself. Tests that depend on each other’s leftovers fail in a different order.
- Reset the database between tests — a transaction rolled back at the end is the fastest way.
- Fix time and randomness (
mock.timers, a seeded generator) instead of asserting loosely. - No network in tests. Intercept it, or the suite fails when someone else’s service is down.
Coverage, honestly
Coverage tells you what was executed, not what was verified — a test with no assertions covers plenty. It is useful as a map of what is not exercised at all; it is a bad target. A suite at 60% on the paths that matter beats 95% of getters.