Testing Express Apps with Jest and Supertest

Advanced
14 min

Testing Express Apps with Jest and Supertest

Manual testing with a REST client catches the bug you are looking for and misses the three you are not. Automated tests run the whole request pipeline in milliseconds and tell you immediately when a refactor breaks a contract. After this lesson you will be able to configure Jest for an ES-module Express project, drive routes with Supertest without opening a port, isolate the database per test, and test middleware on its own.

Setup for ES modules

bash
npm install --save-dev jest supertest

Jest runs ESM natively when Node's VM modules flag is enabled, so the test script passes it explicitly:

json
{ "type": "module", "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand" }, "jest": { "testEnvironment": "node", "transform": {} } }

transform: {} tells Jest not to look for Babel, and --runInBand runs files sequentially, which matters once tests share a database. Jest sets NODE_ENV=test automatically, so the config module can pick a test database and the error handler can hide stack traces the same way it does in production.

Testing routes with Supertest

Supertest takes the Express app exported from app.js, binds it to an ephemeral port for the duration of one request, and returns a promise with the response. Nothing in server.js runs, which is exactly why the two files are separate.

javascript
// tests/todos.test.js import request from "supertest"; import app from "../src/app.js"; describe("todos API", () => { it("lists todos", async () => { const res = await request(app).get("/api/todos").expect(200); expect(res.body.data).toEqual([]); }); it("creates a todo", async () => { const res = await request(app).post("/api/todos").send({ title: "Write tests" }).expect(201); expect(res.body.data).toMatchObject({ title: "Write tests", done: false }); }); it("returns 404 for an unknown id", async () => { const res = await request(app).get("/api/todos/000000000000000000000000").expect(404); expect(res.body.error).toMatch(/not found/i); }); });

.send() serialises objects as JSON, .set() adds headers, .query() adds a query string and .expect(status) fails the test with a readable diff if the code differs. Assert on the body shape with toMatchObject or expect.objectContaining so unrelated fields such as timestamps do not break tests.

Isolating the database

Integration tests must not depend on a developer's local data. mongodb-memory-server downloads a real MongoDB binary once and starts a throw-away instance per test run:

bash
npm install --save-dev mongodb-memory-server
javascript
// tests/setup.js import mongoose from "mongoose"; import { MongoMemoryServer } from "mongodb-memory-server"; let mongod; beforeAll(async () => { mongod = await MongoMemoryServer.create(); await mongoose.connect(mongod.getUri()); }); afterEach(async () => { for (const c of Object.values(mongoose.connection.collections)) await c.deleteMany({}); }); afterAll(async () => { await mongoose.disconnect(); await mongod.stop(); });

Add "setupFilesAfterEnv": ["./tests/setup.js"] to the Jest config so every test file gets a clean connection. For Prisma, point DATABASE_URL at a dedicated test database and truncate tables in afterEach. Either way, each test starts from a known state and test order never matters.

Authenticated requests and middleware units

Sign a token with the test secret instead of walking through the login flow in every test:

javascript
import jwt from "jsonwebtoken"; const authHeader = (user) => `Bearer ${jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET)}`; it("forbids deleting another user's todo", async () => { await request(app) .delete(`/api/todos/${todo.id}`) .set("Authorization", authHeader(otherUser)) .expect(404); });

Middleware can also be tested as a plain function with fake req, res and next:

javascript
import { requireRole } from "../src/middleware/authorize.js"; it("requireRole rejects the wrong role", () => { const req = { user: { role: "viewer" } }; const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; const next = jest.fn(); requireRole("admin")(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(next).not.toHaveBeenCalled(); });

Tips

  • Keep tests/ mirroring src/ so coverage gaps are obvious, and run --coverage in CI.
  • Test the error middleware once with a route that throws, then trust it everywhere else.
  • Vitest and Node's built-in node:test runner also work with Supertest and need no ESM flag.
Quick Quiz
Question 1 of 3

Why does Supertest need the `app` rather than the running server?

Key Takeaways

  • Export app separately from server.js; Supertest drives it without opening a fixed port.
  • Run Jest with --experimental-vm-modules and transform: {} for ES-module projects; Jest sets NODE_ENV=test.
  • Use .send(), .set(), .query() and .expect() to build requests, and flexible matchers for bodies.
  • Isolate data with mongodb-memory-server (or a dedicated test database) and clean up in afterEach.
  • Sign tokens directly for authenticated tests and unit-test middleware with jest.fn() doubles.

Next lesson: API Documentation with Swagger and OpenAPI — describe your endpoints in an OpenAPI document and serve interactive docs from Express.

Testing Express Apps with Jest and Supertest - Express.js | CodeYourCraft | CodeYourCraft