Testing HTTP APIs with Supertest
Unit tests prove a function returns the right value, but a web service lives and dies by what comes back over HTTP — the status code, the headers, and the JSON body. Supertest is the standard tool for driving that surface: it boots your app on an ephemeral port (or talks to its request handler directly), fires real requests at it, and gives you a fluent, promise-aware API for asserting on the response. This page shows how to test Express and Fastify endpoints, assert on status, headers, and bodies, and structure a readable API integration suite.
Why Supertest
Supertest wraps the superagent HTTP client and adds an expect()-style assertion layer aimed at servers. The key trick is that you hand it an http.Server, an Express app, or any request listener, and it starts the server on a random free port for the duration of the request, then tears it down. You never hard-code a port, never fight with EADDRINUSE in CI, and your tests exercise the real routing, middleware, and serialization stack — not a mock of it. That makes these integration tests: closer to production behavior than a unit test, but still fast and in-process.
npm install --save-dev supertest
Supertest is framework-agnostic on the test-runner side. The examples below use the built-in node:test runner, but the same request(app) calls work identically under Jest or Vitest.
Testing an Express app
The most important design rule is to export your app without calling listen(). Keep app construction in one module and the listen() call in a separate entry point. That way tests can import the app object directly and let Supertest manage the server lifecycle.
// app.js
import express from 'express';
export function createApp() {
const app = express();
app.use(express.json());
const users = [{ id: 1, name: 'Ada' }];
app.get('/users/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: 'not found' });
res.json(user);
});
app.post('/users', (req, res) => {
if (!req.body.name) return res.status(400).json({ error: 'name required' });
const user = { id: users.length + 1, name: req.body.name };
users.push(user);
res.status(201).location(`/users/${user.id}`).json(user);
});
return app;
}
Now the test imports createApp() and passes the result straight to request():
// app.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from './app.js';
const app = createApp();
test('GET /users/:id returns the user as JSON', async () => {
const res = await request(app)
.get('/users/1')
.expect('Content-Type', /json/)
.expect(200);
assert.deepEqual(res.body, { id: 1, name: 'Ada' });
});
test('GET unknown user returns 404', async () => {
await request(app).get('/users/999').expect(404);
});
node --test app.test.js
Output:
✔ GET /users/:id returns the user as JSON (14.8ms)
✔ GET unknown user returns 404 (2.1ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
await request(app)resolves the chain into a real promise. If any.expect()fails, the promise rejects with a descriptive error, so theawaititself is your assertion — you do not need an extratry/catch.
Asserting on status, headers, and bodies
The .expect() method is overloaded by argument type, which is what makes the chain read so naturally. You can stack as many as you like, and they run in order.
| Call | Asserts |
|---|---|
.expect(201) | Response status code equals 201 |
.expect('Content-Type', /json/) | Header matches a string or regex |
.expect({ id: 1 }) | Body deep-equals the object |
.expect((res) => { ... }) | Custom function — throw to fail |
For anything beyond an exact match, grab res.body (already JSON-parsed) and assert with your normal library. This is cleaner for partial checks or computed fields:
test('POST /users creates a user', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Grace' })
.set('Accept', 'application/json')
.expect('Location', /\/users\/\d+/)
.expect(201);
assert.equal(res.body.name, 'Grace');
assert.ok(res.body.id > 0);
});
test('POST /users rejects a missing name', async () => {
const res = await request(app).post('/users').send({}).expect(400);
assert.deepEqual(res.body, { error: 'name required' });
});
.send() sets the request body (and the Content-Type for you when you pass an object), .set() adds request headers, and .query() appends a query string. The custom-callback form of .expect() is useful when an assertion spans multiple fields:
await request(app)
.get('/users/1')
.expect((res) => {
assert.ok(res.body.id, 'expected an id');
assert.equal(typeof res.body.name, 'string');
})
.expect(200);
Testing a Fastify app
Fastify can be tested with Supertest too — pass app.server (the underlying http.Server) after the framework is ready. Call await app.ready() so all plugins and routes are registered before the first request.
// fastify-app.test.js
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import Fastify from 'fastify';
function build() {
const app = Fastify();
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
test('GET /health returns ok', async () => {
const app = build();
await app.ready();
after(() => app.close());
const res = await request(app.server).get('/health').expect(200);
assert.deepEqual(res.body, { status: 'ok' });
});
Fastify also ships its own
app.inject()for lightweight in-process testing without a socket. Supertest is the better choice when you want one consistent API across Express, Fastify, and rawhttpservers.
Structuring an integration suite
Group related endpoints under a describe block and share a single app instance with lifecycle hooks. Build the app in before, reset state in beforeEach, and close any open handles (database pools, servers) in after so the test process exits cleanly.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from './app.js';
describe('Users API', () => {
let agent;
before(() => {
agent = request.agent(createApp());
});
it('rejects unauthenticated writes', async () => {
await agent.post('/users').send({ name: 'x' }).expect(201);
});
});
request.agent(app) returns a persistent agent that keeps cookies between requests — essential for flows like logging in once and reusing the session across several authenticated calls.
Best Practices
- Export your app factory separately from
listen()so tests can drive the app in-process without binding a fixed port. - Let Supertest assign an ephemeral port by passing the app/handler directly; never reuse a hard-coded port in tests.
- Prefer chained
.expect(status)and.expect(header, value)for protocol-level checks, andres.bodyassertions for payload details. - Reset shared state (in-memory stores, database rows) in
beforeEachso endpoint tests stay independent and order-free. - Always
await app.ready()for Fastify andawait app.close()(or pool teardown) inafterto avoid hanging test processes. - Use
request.agent(app)to persist cookies across requests when testing authenticated or session-based flows. - Treat these as integration tests: hit real routes and middleware, and mock only true external dependencies like third-party HTTP calls.