Skip to main content
Node.js · lesson 9 of 20
Node.js intermediate Lesson 9 of 20

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

nodejs testing express

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.

Frequently Asked Questions

Why test against a real listening server instead of calling the Express app's handlers directly?
fetch needs an actual socket to connect to. Testing through fetch against server.listen(0) exercises the real HTTP layer — status codes, headers, JSON parsing on the wire — rather than calling a route function in isolation, which is closer to what a real client experiences.
Why listen on port 0 instead of a fixed port like 3000?
Port 0 tells the OS to pick any free port, which server.address().port then reports. That avoids a fixed port colliding with another test file, a locally running dev server, or a parallel CI job on the same machine.
Do I need supertest for this?
No — fetch is built into Node (since Node 18) and is enough to make requests, read JSON bodies, and check status codes. supertest adds a chainable assertion style on top, which is convenient but not required for basic route testing.
Why does the test file need its own before/after hooks instead of starting the server once globally?
node:test runs each *.test.js file as its own process by default, so hooks scoped to one file's before/after control exactly that file's server lifecycle — starting it before any test in the file runs and closing it after the last one, without leaking a listening port if the file is run alongside others.