Testing with Supertest

intermediate express testing supertest jest vitest

We don’t need a running server to test Express routes. Supertest hooks straight into the Express app object, fires fake HTTP requests at it, and gives us the response back. Fast, isolated, perfect for CI.

In simple language: Supertest is a robot client that pokes our routes directly in-process. No network, no port, no flakiness from “address already in use”.

The setup

npm install -D supertest vitest    # or jest

The pattern: export your app separately from your listen() call. This is the most important refactor for testability.

// app.js — just the app, no listen
import express from "express";
import { usersRouter } from "./routes/users.js";

export const app = express();
app.use(express.json());
app.use("/users", usersRouter);
// server.js — only this file calls listen
import { app } from "./app.js";
app.listen(3000, () => console.log("listening"));

Now tests can import app and never bind to a port.

A basic test

import request from "supertest";
import { describe, it, expect } from "vitest";
import { app } from "../app.js";

describe("GET /users", () => {
  it("returns a list of users", async () => {
    const res = await request(app).get("/users");

    expect(res.status).toBe(200);
    expect(res.body).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number) }),
      ])
    );
  });

  it("returns 404 for missing user", async () => {
    const res = await request(app).get("/users/99999");
    expect(res.status).toBe(404);
  });
});

request(app) creates a test client. Chain HTTP method + path, send headers/body, await the response. That’s it.

POST with a body

it("creates a user", async () => {
  const res = await request(app)
    .post("/users")
    .send({ email: "manish@example.com", password: "hunter2" })
    .set("Content-Type", "application/json");

  expect(res.status).toBe(201);
  expect(res.body.email).toBe("manish@example.com");
  expect(res.body.password).toBeUndefined(); // shouldn't leak hashes
});

Authenticated routes

Sign a real JWT in the test, or use a test helper that builds a token for a fixture user.

import jwt from "jsonwebtoken";

function authToken(userId, role = "user") {
  return jwt.sign({ sub: userId, role }, process.env.JWT_SECRET, { expiresIn: "5m" });
}

it("returns the current user", async () => {
  const token = authToken("user_42");
  const res = await request(app)
    .get("/me")
    .set("Authorization", `Bearer ${token}`);

  expect(res.status).toBe(200);
  expect(res.body.userId).toBe("user_42");
});

it("rejects requests without a token", async () => {
  const res = await request(app).get("/me");
  expect(res.status).toBe(401);
});

Mocking the database

We don’t want real DB calls in unit tests — slow, flaky, hard to seed. Two common approaches:

1. Inject the DB (dependency injection)

Pass db into route builders so tests can swap it for a stub.

// users.routes.js
export function makeUsersRouter(db) {
  const router = express.Router();
  router.get("/:id", async (req, res) => {
    const user = await db.users.findById(req.params.id);
    user ? res.json(user) : res.sendStatus(404);
  });
  return router;
}
// users.test.js
import { makeUsersRouter } from "../routes/users.routes.js";

const fakeDb = {
  users: {
    findById: vi.fn(async (id) => id === "1" ? { id: "1", name: "Manish" } : null),
  },
};

const app = express();
app.use("/users", makeUsersRouter(fakeDb));

it("finds a user", async () => {
  const res = await request(app).get("/users/1");
  expect(res.body.name).toBe("Manish");
  expect(fakeDb.users.findById).toHaveBeenCalledWith("1");
});

This is the cleanest pattern. The app code doesn’t import a singleton DB — it accepts one.

2. Module mocking

If refactoring is too painful, mock at the module level.

import { vi } from "vitest";

vi.mock("../db.js", () => ({
  db: {
    users: {
      findById: vi.fn(),
    },
  },
}));

import { db } from "../db.js";

it("finds a user", async () => {
  db.users.findById.mockResolvedValue({ id: "1", name: "Manish" });
  const res = await request(app).get("/users/1");
  expect(res.body.name).toBe("Manish");
});

Works, but tests get coupled to import paths. DI scales better.

Integration tests with a real DB

For higher-confidence tests, spin up a real Postgres in Docker (or use Testcontainers), seed it, and let the app hit it. Slower but catches things mocks never will — like a forgotten index or a SQL typo.

import { beforeAll, afterAll, beforeEach } from "vitest";
import { pool } from "../db.js";

beforeAll(async () => {
  await pool.query("CREATE TABLE IF NOT EXISTS users (...)");
});

beforeEach(async () => {
  await pool.query("TRUNCATE users RESTART IDENTITY");
});

afterAll(async () => {
  await pool.end();
});

it("persists a user", async () => {
  await request(app).post("/users").send({ email: "a@b.com", password: "xxxxxxxx" });
  const { rows } = await pool.query("SELECT * FROM users");
  expect(rows).toHaveLength(1);
});

Useful Supertest tricks

// Cookies persist across requests using an agent
const agent = request.agent(app);
await agent.post("/login").send({ email, password });
const me = await agent.get("/me"); // session cookie auto-sent

// File upload
await request(app)
  .post("/upload")
  .attach("avatar", "test/fixtures/cat.jpg")
  .field("caption", "vacation");

// Assert response headers
const res = await request(app).get("/users");
expect(res.headers["content-type"]).toMatch(/json/);
expect(res.headers["x-ratelimit-limit"]).toBeDefined();

What to test

Layered approach, from cheap to expensive:

  1. Unit tests for pure business logic (validators, helpers). Fastest.
  2. Route tests with mocked DB (Supertest + DI). Cover handler logic + middleware wiring.
  3. Integration tests against a real DB. Cover SQL, migrations, real wiring.
  4. E2E tests against a deployed instance. Slowest, run before release.

Most teams over-invest in level 4 and skip level 2. Route tests with Supertest are the sweet spot — fast enough to run on every save, thorough enough to catch real bugs.