HTTP Tests Without A Real Server
SuperTest
SuperTest makes HTTP requests directly against your app, without binding a port. Fast, deterministic integration tests.
What you'll learn
- Wire SuperTest into your test suite
- Test route + middleware integrations
- Assert status, headers, body
SuperTest wraps your Express app in an HTTP client without binding a port. Each test makes requests, asserts on the response — fast and deterministic.
Install
npm install --save-dev supertest Why buildApp() Matters
// src/app.js
export function buildApp() {
const app = express();
// ... middleware, routes
return app; // doesn't listen
} Tests import buildApp() and pass the app to SuperTest. No server,
no port, no flaky timing.
A First Test
// tests/users.test.js
import request from "supertest";
import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
const app = buildApp();
test("GET /api/users returns 200 + JSON", async () => {
const res = await request(app).get("/api/users");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(Array.isArray(res.body.data)).toBe(true);
});
test("GET /api/users/missing returns 404", async () => {
const res = await request(app).get("/api/users/missing");
expect(res.status).toBe(404);
expect(res.body.error.code).toBe("user_not_found");
}); POST With a Body
test("POST /api/users creates one", async () => {
const res = await request(app)
.post("/api/users")
.send({ email: "ada@example.com", name: "Ada" })
.set("content-type", "application/json");
expect(res.status).toBe(201);
expect(res.body.data.email).toBe("ada@example.com");
}); Validation Failures
test("POST /api/users rejects missing email", async () => {
const res = await request(app).post("/api/users").send({ name: "Ada" });
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("validation_failed");
expect(res.body.error.issues).toContainEqual(
expect.objectContaining({ path: "email" })
);
}); Auth
For routes that need auth, attach a token or cookie:
test("GET /me — authenticated", async () => {
const token = issueTestToken({ sub: "user-1" });
const res = await request(app)
.get("/me")
.set("authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
});
test("GET /me — unauthenticated returns 401", async () => {
const res = await request(app).get("/me");
expect(res.status).toBe(401);
}); Database — Real or Mocked?
Two strategies:
- Mock the DB layer — fast, tests focus on Express + business logic
- Real test DB — slower, but tests the full stack
Mock for unit-style routing tests. Use a real test DB (Postgres in a Docker container) for the critical paths.
Snapshots
test("snapshot the user response", async () => {
const res = await request(app).get("/api/users/42");
expect(res.body).toMatchSnapshot();
}); Useful when the response is stable. Less useful for fields that change (timestamps, generated IDs).
Logging →