HTTP-level tests catch routing, middleware-order and serialisation bugs that unit tests miss.
supertest sends real HTTP requests to your Express app without you starting a server: pass it the app, and it binds to an ephemeral port for each request. This is why exporting app separately from app.listen matters.
const res = await request(app).get("/users/1").set("Authorization", "Bearer t");The returned response has status, body (parsed JSON), headers and text. You can assert with node:assert (or Jest/Vitest), or use supertest's chained .expect(200) / .expect("Content-Type", /json/).
Useful techniques:
- Sending data:
.post("/users").send({ name: "Ada" })sets the JSON content type automatically;.query({ page: 2 })builds the query string. - Cookies/sessions:
request.agent(app)keeps cookies between requests, so you can log in once and call protected routes. - Dependencies: build the app with a factory,
createApp({ db, mailer }), so tests inject fakes or a test database instead of patching modules. This is the single most useful design choice for testability. - Error paths: test the 400, 401, 404 and 500 responses deliberately; they are where APIs most often regress. A test that hits a throwing route verifies that your error handler hides internal details.
What to test at which level:
- Services and pure functions: fast unit tests.
- Routes: supertest tests for each endpoint's contract — status, body shape, headers.
- A handful of full end-to-end tests against a real database and server.
Run them with node --test. Keep tests independent: create fresh app state per test (beforeEach) instead of relying on order.
Example
// app.test.js — run: node --test
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import express from "express";
function createApp({ store }) { // factory: dependencies injected
const app = express();
app.use(express.json());
app.post("/todos", (req, res) => {
if (!req.body?.title) return res.status(400).json({ error: "title required" });
res.status(201).json(store.add(req.body.title));
});
app.get("/todos", (req, res) => res.json(store.all()));
return app;
}
describe("todos API", () => {
let app;
beforeEach(() => {
const items = [];
app = createApp({ store: { add: (t) => (items.push({ title: t }), items.at(-1)), all: () => items } });
});
it("creates a todo", async () => {
const res = await request(app).post("/todos").send({ title: "test it" });
assert.equal(res.status, 201);
assert.deepEqual(res.body, { title: "test it" });
});
it("rejects a missing title", () => request(app).post("/todos").send({}).expect(400));
});Remember
request(app)tests over HTTP without starting a server yourself- Use an app factory to inject fake or test dependencies
request.agent(app)keeps cookies for session flows- Test error paths, not just the happy path
Watch out for
- Importing a module that calls
app.listen()at import time, leaving ports open and tests hanging.