SuperTest

HTTP Tests Without A Real Server

SuperTest

SuperTest makes HTTP requests directly against your app, without binding a port. Fast, deterministic integration tests.

3 min read Level 1/5 #express#testing#supertest
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:

  1. Mock the DB layer — fast, tests focus on Express + business logic
  2. 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 →