Node.js · lesson 9 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
Also part of: Software Testing
Testing an HTTP API with node:test and fetch
Test a real running Express server with Node's built-in test runner and fetch, no supertest dependency, including what a real assertion failure prints.
By SkillByExample · Published
Node’s built-in test runner (node --test) plus the built-in fetch is enough to test a real running Express server — no supertest dependency required.
A Testable App and Three Passing Checks
// app.js
import express from "express";
export function createApp() {
const app = express();
app.use(express.json());
const users = [{ id: 1, name: "Alice" }];
app.get("/users", (req, res) => {
res.json(users);
});
app.get("/users/:id", (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
res.status(404).json({ error: "not found" });
return;
}
res.json(user);
});
app.post("/users", (req, res) => {
if (!req.body.name) {
res.status(400).json({ error: "name is required" });
return;
}
const user = { id: users.length + 1, name: req.body.name };
users.push(user);
res.status(201).json(user);
});
return app;
}
// app.test.js
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createApp } from "./app.js";
let server;
let baseUrl;
before(async () => {
server = createApp().listen(0);
await new Promise((resolve) => server.once("listening", resolve));
baseUrl = `http://localhost:${server.address().port}`;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
test("GET /users returns the seeded user", async () => {
const res = await fetch(`${baseUrl}/users`);
const body = await res.json();
assert.equal(res.status, 200);
assert.deepEqual(body, [{ id: 1, name: "Alice" }]);
});
test("GET /users/:id 404s for a missing user", async () => {
const res = await fetch(`${baseUrl}/users/99`);
assert.equal(res.status, 404);
assert.deepEqual(await res.json(), { error: "not found" });
});
test("POST /users creates a user and GET finds it", async () => {
const created = await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Bob" }),
});
assert.equal(created.status, 201);
const user = await created.json();
assert.equal(user.name, "Bob");
const fetched = await fetch(`${baseUrl}/users/${user.id}`);
assert.deepEqual(await fetched.json(), user);
});
test("POST /users without a name is rejected with 400", async () => {
const res = await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(res.status, 400);
});
$ node --test
✔ GET /users returns the seeded user (62.508372ms)
✔ GET /users/:id 404s for a missing user (4.562038ms)
✔ POST /users creates a user and GET finds it (113.056344ms)
✔ POST /users without a name is rejected with 400 (4.965107ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1811.754019
server.listen(0) asks the OS for any free port; server.address().port reads back whichever one it picked, so the test file never hardcodes a port another process might already be using. before/after start and stop that one server exactly once for the whole file — each test() reuses the same running instance. The per-test millisecond timings and the total duration_ms vary run to run; the pass/fail counts and each test’s name don’t.
A Real Failure, Read Correctly
// app.test.broken.js
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createApp } from "./app.js";
let server;
let baseUrl;
before(async () => {
server = createApp().listen(0);
await new Promise((resolve) => server.once("listening", resolve));
baseUrl = `http://localhost:${server.address().port}`;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
test("POST /users without a name is rejected with 422", async () => {
const res = await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(res.status, 422);
});
$ node --test app.test.broken.js
✖ POST /users without a name is rejected with 422 (153.643776ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1755.288844
✖ failing tests:
test at app.test.broken.js:19:1
✖ POST /users without a name is rejected with 422 (153.643776ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
400 !== 422
at TestContext.<anonymous> (file:///root/node-api/app.test.broken.js:26:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 400,
expected: 422,
operator: 'strictEqual',
diff: 'simple'
}
This test expected 422, but the route in app.js actually returns 400 for a missing name. assert.equal’s failure shows both sides — 400 !== 422 — plus actual/expected as separate fields, and a file:line pointing at the exact assert.equal call. The test’s own name ("...rejected with 422") described a wrong expectation, not a bug in app.js; reading the diff before touching the route is what tells you which side to fix.
Practice
1. Add a test for GET /users/:id on a non-numeric id like /users/abc, and predict the status code before running it.
Solution
// app.test.js (added test)
test("GET /users/:id with a non-numeric id behaves like a miss", async () => {
const res = await fetch(`${baseUrl}/users/abc`);
assert.equal(res.status, 404);
assert.deepEqual(await res.json(), { error: "not found" });
});
$ node --test
✔ GET /users returns the seeded user (294.428324ms)
✔ GET /users/:id 404s for a missing user (11.041797ms)
✔ POST /users creates a user and GET finds it (385.167645ms)
✔ POST /users without a name is rejected with 400 (11.364481ms)
✔ GET /users/:id with a non-numeric id behaves like a miss (2.320351ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 4551.089802
Number("abc") is NaN, and no user’s id ever equals NaN (not even by === with itself), so users.find returns undefined just like a real miss — same 404, same body, for a different reason than a missing id. The new test is appended to the same app.test.js, so node --test runs all five.
2. Use test.only with --test-only to run just one test in the file, and compare that to running the same file without the flag.
Solution
// app.test.js (one test marked .only)
test.only("GET /users returns the seeded user", async () => {
const res = await fetch(`${baseUrl}/users`);
assert.equal(res.status, 200);
});
$ node --test --test-only
✔ GET /users returns the seeded user (34.301939ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1916.030088
With --test-only, the other three tests aren’t even counted — tests 1, not tests 4 — because --test-only makes node --test only ever run (and report on) tests marked .only in a file that has at least one. Without the flag, .only has no effect at all:
$ node --test
✔ GET /users returns the seeded user (48.086621ms)
ℹ 'only' and 'runOnly' require the --test-only command-line option.
✔ GET /users/:id 404s for a missing user (7.811404ms)
✔ POST /users creates a user and GET finds it (329.092174ms)
✔ POST /users without a name is rejected with 400 (3.518084ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3600.121553
Node prints a one-line note explaining exactly why .only was ignored, then runs every test in the file anyway — --test-only is required on the command line for .only to take effect at all.
3. Add a beforeEach that resets the app (and its in-memory users array) before every test, instead of sharing one server across the whole file.
Solution
// app.test.js (per-test app instead of per-file)
import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { createApp } from "./app.js";
let server;
let baseUrl;
beforeEach(async () => {
server = createApp().listen(0);
await new Promise((resolve) => server.once("listening", resolve));
baseUrl = `http://localhost:${server.address().port}`;
});
afterEach(async () => {
await new Promise((resolve) => server.close(resolve));
});
test("a fresh app always starts with exactly one user", async () => {
const res = await fetch(`${baseUrl}/users`);
assert.equal((await res.json()).length, 1);
});
test("POSTing a user gives this test's own fresh app two users", async () => {
await fetch(`${baseUrl}/users`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Temp" }),
});
const res = await fetch(`${baseUrl}/users`);
assert.equal((await res.json()).length, 2);
});
$ node --test
✔ a fresh app always starts with exactly one user (45.932821ms)
✔ POSTing a user gives this test's own fresh app two users (238.367102ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3237.736732
Both tests pass regardless of run order, because beforeEach/afterEach give every single test its own createApp() call — a brand new users array, from the const users = [...] literal inside createApp — instead of one shared server whose state could leak between tests depending on which ran first. The second test’s count only ever describes its own fresh app after its own POST, which is exactly what its name says now; nothing here actually proves anything about the next test, because with a fresh array per call there’s no shared state left to leak in the first place.
4. Add a test that checks the content-type response header on GET /users, not just the status and body.
Solution
// app.test.js (added test)
test("GET /users responds with a JSON content-type", async () => {
const res = await fetch(`${baseUrl}/users`);
assert.equal(res.headers.get("content-type"), "application/json; charset=utf-8");
});
$ node --test
✔ GET /users returns the seeded user (38.463586ms)
✔ GET /users/:id 404s for a missing user (5.403058ms)
✔ POST /users creates a user and GET finds it (292.110872ms)
✔ POST /users without a name is rejected with 400 (2.659685ms)
✔ GET /users responds with a JSON content-type (1.450279ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3521.337718
res.json() would have parsed the body successfully even with a wrong content-type header — fetch doesn’t enforce it — so this assertion catches a header regression that a body-only test like the first example never would.