Testing HTTP Handlers
Start the server on port 0, send a real request to the port the system hands back, and assert on the actual response.
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); });An HTTP handler is only correct in context. The status code, the headers and the body are what a client sees. Calling the handler with hand-made req and res objects tests your stand-ins as much as the handler. Starting the real server and sending a real request tests the handler.
Port 0
A port is the number a client connects to on a machine. If a test file listens on 3000 while another file, or the copy of the service you left running, already holds 3000, the test fails with EADDRINUSE. Pass 0 instead: server.listen(0) asks the operating system for any free port. Read the number back with server.address().port and build the request URL from it. Files running at the same time then never collide, on your machine or in CI.
Bind to 127.0.0.1, the address that means this machine only, so a test server is never reachable from the network while it is up.
Start once, close once
Start the server in a before hook and call server.close() in after. A server still listening keeps the process alive, and the test run never finishes. Keep listen out of the module that builds the server: export the server or a factory, and let the entry point be the only place that binds a port. A test can then start it on a port of its own.
Assert on the status first, then on the parsed body.
Example
When to use it
- Check that a route returns 422 and a usable message for an invalid request body.
- Stop two test files fighting over port 3000 when CI runs them at the same time.
- Assert the exact JSON and headers an API client will parse.
More examples
The same thing as a test file, with hooks
One server for the whole file, started before the first test and closed after the last. fetch is built into Node.js and is the shortest way to send the request; res.json() parses the body. Use this layout for every route test in a service.
import { before, after, test } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './app.js';
let server;
let base; // the base URL, including the port we were given
before(async () => {
server = createApp();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close()); // nothing left listening when the file ends
test('POST /users rejects an empty name', async () => {
const res = await fetch(`${base}/users`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: '' }),
});
assert.equal(res.status, 422);
assert.deepStrictEqual(await res.json(), { error: 'name is required' });
});Keep listen out of the app module
If app.js called listen at import time, importing it from a test would bind port 3000 the moment the test file loaded. Splitting the two lets the test choose port 0 and lets the deployment choose the port through an environment variable. The dependencies argument is also what makes the database replaceable in a test.
// app.js — builds the server and never binds a port
import { createServer } from 'node:http';
export function createApp({ users } = {}) {
return createServer((req, res) => {
// routing and handlers here; dependencies arrive as arguments
});
}
// server.js — the only file in the project that calls listen
import { createApp } from './app.js';
const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => {
console.log(`listening on ${port}`);
});Asserting the headers, not just the status
Clients break on headers as often as on bodies: an HTML 404 crashes a JSON parser, and a cached health response makes a dead instance look alive. assert.match on the content type tolerates '; charset=utf-8' being appended.
import { test } from 'node:test';
import assert from 'node:assert/strict';
// base is set in the before hook shown above.
test('unknown routes answer with JSON, not an HTML error page', async () => {
const res = await fetch(`${base}/nope`);
assert.equal(res.status, 404);
// Header names are lower-cased for you; the value may carry a charset.
assert.match(res.headers.get('content-type'), /application\/json/);
assert.deepStrictEqual(await res.json(), { error: 'not found' });
});
test('the health route is not cached', async () => {
const res = await fetch(`${base}/health`);
assert.equal(res.headers.get('cache-control'), 'no-store');
});
Discussion