API Testing with Supertest: A Practical Guide

I explain how Supertest sends HTTP requests to an Express app in tests, covering endpoint organization, auth header mocking, and database test strategies.

API Testing with Supertest: A Practical Guide

Unit tests cover your service functions well. What they can’t tell you is whether your PUT /users/:id route returns a 400 when the request body is missing a required field, or whether your auth middleware correctly rejects a token that has expired. Those behaviors only exist once a request has traveled through the full Express middleware stack.

That gap is exactly where integration tests belong. Supertest fills it for Express APIs by sending real HTTP requests to your app in-process — no separate server to start, no port to manage — and giving you a chainable API to assert on status codes, response headers, and body shape.

Quick answer: Export your Express app without calling .listen(). In your test files, pass app to request() from supertest and chain any HTTP method — .get(), .post(), .put(), .delete() — followed by .expect() for assertions. Supertest binds the app to an ephemeral port for each request and tears it down immediately. No server management required.

Series: Part 3 of 4 in the Node.js testing series.

On this page

What Supertest does without a running server

Most HTTP testing tools require a live server on a known port before they can send anything. Supertest takes a different approach. It accepts an http.Server or an Express app function directly. When you pass the app, Supertest binds it to an ephemeral port for the duration of that single request and closes the connection once the assertion runs. You never call app.listen() in a test file.

This removes a common problem in test suites: port conflicts. When multiple test files run in parallel and each file starts a real server on a fixed port, they collide. Supertest sidesteps this entirely because each request gets its own short-lived port.

The change this requires in your application code is separating the app definition from the server startup:

// src/app.ts — define and export the app, but do not listen
import express from "express";
import { usersRouter } from "./routes/users";

export const app = express();

app.use(express.json());
app.use("/users", usersRouter);
// src/server.ts — the only file that calls listen()
import { app } from "./app";

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));

Your test files import app directly and never touch server.ts. If you’re already following the layered folder structure from Structuring a Production-Ready Express Project, this separation is already there.

Setting up Supertest with TypeScript

Install Supertest and its type definitions:

npm install --save-dev supertest @types/supertest

Supertest works with any test runner — Jest, Vitest, Mocha. Here’s the minimal setup with Vitest (the pattern is identical with Jest):

// src/routes/users.test.ts
import request from "supertest";
import { app } from "../app";

describe("GET /users", () => {
  it("returns 200 with an array", async () => {
    const res = await request(app).get("/users");

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });
});

The await matters. Supertest requests are asynchronous, and async/await is the cleanest way to write them. It also integrates naturally with beforeAll, afterAll, and beforeEach hooks for setup and teardown.

Check this before moving on

  • app is exported from a file that does not call app.listen()
  • @types/supertest is installed alongside supertest
  • Your test runner is configured to handle TypeScript (via ts-jest, Vitest, or @babel/preset-typescript)

Try this

  1. Create a users.test.ts file with the import and one test block shown above.
  2. Run your test suite — npx jest or npx vitest.
  3. Check whether the test passes or fails.

Expected result: The test passes if the route exists and returns 200 with an array. If you see Cannot find module '../app', check that your app.ts exports the app and your file path is correct. If the status is wrong, check that express.json() and your router are registered in app.ts.

Organizing tests by endpoint group

The pattern that keeps integration test files readable is one describe block per resource, with nested describe blocks for each HTTP method. Each it block covers one behavior:

describe("Users API", () => {
  describe("POST /users", () => {
    it("creates a user and returns 201", async () => {
      const res = await request(app)
        .post("/users")
        .send({ email: "alex@example.com", name: "Alex" });

      expect(res.status).toBe(201);
      expect(res.body.id).toBeDefined();
      expect(res.body.email).toBe("alex@example.com");
    });

    it("returns 400 when email is missing", async () => {
      const res = await request(app)
        .post("/users")
        .send({ name: "Alex" });

      expect(res.status).toBe(400);
      expect(res.body.error).toBeDefined();
    });
  });

  describe("PUT /users/:id", () => {
    it("returns 404 for an unknown id", async () => {
      const res = await request(app)
        .put("/users/nonexistent-id")
        .send({ name: "New Name" });

      expect(res.status).toBe(404);
    });
  });

  describe("DELETE /users/:id", () => {
    it("returns 204 on successful deletion", async () => {
      const res = await request(app).delete("/users/known-id");

      expect(res.status).toBe(204);
    });
  });
});

Naming the it block after the behavior — not the HTTP method — pays off when the suite fails. “returns 400 when email is missing” tells you exactly what broke. “POST test 2” doesn’t.

For query parameters, append them directly to the URL: request(app).get("/users?role=admin"). For the request body, .send({}) with an object sets Content-Type: application/json automatically. If you need a specific header, chain .set("Content-Type", "text/plain") before .send().

Keeping error responses consistent across your API — as covered in Centralized Error Handling in Node.js APIs — makes the body assertions easier to write. When every error follows the same { error: string } shape, you always know what field to check.

Testing routes that require authentication

Most endpoints in a real API are protected. Supertest makes it simple to set request headers:

describe("GET /profile", () => {
  it("returns 401 without a token", async () => {
    const res = await request(app).get("/profile");

    expect(res.status).toBe(401);
  });

  it("returns 200 with a valid token", async () => {
    const res = await request(app)
      .get("/profile")
      .set("Authorization", "Bearer valid-test-token");

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

For JWT-based auth, the cleanest approach is signing a fresh token in beforeAll using the same secret your middleware reads:

import jwt from "jsonwebtoken";

let authHeader: string;

beforeAll(() => {
  const token = jwt.sign(
    { userId: "test-user-1", role: "user" },
    process.env.JWT_SECRET!,
    { expiresIn: "1h" }
  );
  authHeader = `Bearer ${token}`;
});

This token passes your actual middleware — not a mock — so the test is genuine. Set JWT_SECRET in a .env.test file and load it before the test run. Using a static hardcoded token works too, but you’ll need to remember it’s expired when it eventually does.

For cookie-based sessions, request.agent(app) persists cookies across requests. This lets you test a full login → protected resource sequence without resetting the session between calls:

const agent = request.agent(app);

it("accesses a protected route after login", async () => {
  await agent
    .post("/auth/login")
    .send({ email: "user@example.com", password: "correct-password" });

  const res = await agent.get("/dashboard");
  expect(res.status).toBe(200);
});

Handling the database in integration tests

How you handle the database is the decision with the most trade-offs. Three approaches are common:

Test database. Point your app at a separate database — via DATABASE_URL or an equivalent environment variable — run migrations before the suite starts, seed test data in beforeEach, and clean up in afterEach. This is the most realistic option: tests run against real queries, real constraints, and actual index behavior. The cost is setup time and test speed. If a test fails mid-run and leaves data behind, later tests may fail for an unrelated reason.

Mock the database layer. Replace your repository functions or ORM calls with stubs using jest.mock() or vi.mock(). Tests run in milliseconds with no external dependencies, but you’re asserting against what the mock returns — not what the database actually does. When the mock returns a shape that doesn’t match real query results, the tests pass and the route is broken in production.

In-memory database. SQLite via better-sqlite3 or the native node:sqlite module (Node 22+) runs actual SQL queries with no server. Faster than a full database instance, and it catches real SQL logic bugs. The limitation is dialect differences: queries that work in SQLite don’t always work in PostgreSQL.

My practical take: use a test database for the routes where data correctness matters most — handlers that create, update, or delete records. For routes that mainly test HTTP behavior — 401 on missing auth, 400 on invalid input — mock the database layer and keep those tests fast. Mixing both approaches in one suite is reasonable as long as you’re clear about which tests cover which layer.

Running the test suite in CI

Supertest doesn’t need any special CI configuration. Your normal test command runs the same way in a CI environment. What you do need is the correct environment variables — the same ones your app reads locally.

A minimal step in a GitHub Actions workflow looks like this:

- name: Run integration tests
  env:
    NODE_ENV: test
    JWT_SECRET: ${{ secrets.TEST_JWT_SECRET }}
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
  run: npm test

If your tests hit a real database, add a migration step before them so the schema is current:

- name: Run database migrations
  env:
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
  run: npm run migrate

- name: Run integration tests
  env:
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
    JWT_SECRET: ${{ secrets.TEST_JWT_SECRET }}
  run: npm test

One startup check worth adding: throw at boot if a required variable is missing rather than letting the app fail silently at the first request that needs it. An if (!process.env.JWT_SECRET) throw new Error("JWT_SECRET is required") at the top of app.ts turns a confusing 401 test failure into a clear startup error.

Where integration tests mislead you

The most common failure is testing the mock instead of the behavior. If your test replaces the entire service layer with stubs, and those stubs return exactly the shape you assert against, the test passes regardless of whether the route actually works. The test measures what the mock returns, not what the route does.

A subtler issue is missing middleware. If app.use(express.json()) isn’t registered, req.body is undefined in every POST and PUT handler. Unit tests don’t catch this because they never touch the Express middleware stack. A Supertest test that sends a body and asserts on fields that depend on it will catch it — but only if you’re testing an input that forces the body to be parsed. If your test only checks a 201 status without asserting on the created resource, the missing middleware slips through.

State pollution is worth planning for from the start. When tests share a database and one test creates data that a later test reads, execution order starts to matter. Tests that pass in isolation begin failing when run in a full suite. Clean up state in afterEach, not just afterAll, and treat test isolation as a design constraint.

Three panels connected by bidirectional arrows: test assertion blocks on the left, a teal request arrow and dark response arrow in the center, and API route endpoint rows on the right.

The integration test loop — a test sends a request through the Express app and asserts on whatever comes back.

Integration tests cover what function calls cannot

The HTTP behaviors that matter most in an API — status codes on different inputs, auth enforcement, middleware ordering, error response shapes — only exist at the HTTP layer. Calling a service function directly in a unit test bypasses all of it.

Supertest’s value scales with what you actually test. A suite that only checks 200 on the happy path covers very little. One that also covers 400 on invalid input, 401 without a token, 403 for wrong permissions, 404 for missing records, and 204 on successful deletion — that suite catches the bugs that reach production when you rely only on unit tests.

Start with one describe block for your most important resource. Add the unhappy paths alongside the happy ones. Writing those tests often reveals cases the implementation didn’t handle yet — which is exactly what the tests are for.

Sources