Testing Express apps with supertest

Express · Practical · 13 min

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.

Check yourself

  1. Why does structuring code as createApp({ db }) help testing?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/api/application