Testing

Write tests with what Node already has, and know which parts of a server are worth testing.

14of 18
≈ 11Min read
7Parts

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.

no dependency, since Node 18
// 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

node:assert/strict
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/);
Always import the strict version

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.

and one thing per test
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.

supertest, or fetch against a real listener
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.

Practice