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.
npm install --save-dev jest supertestJest runs ESM natively when Node's VM modules flag is enabled, so the test script passes it explicitly:
{
"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.
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.
// 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.
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:
npm install --save-dev mongodb-memory-server// 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.
Sign a token with the test secret instead of walking through the login flow in every test:
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:
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();
});tests/ mirroring src/ so coverage gaps are obvious, and run --coverage in CI.node:test runner also work with Supertest and need no ESM flag.Why does Supertest need the `app` rather than the running server?
app separately from server.js; Supertest drives it without opening a fixed port.--experimental-vm-modules and transform: {} for ES-module projects; Jest sets NODE_ENV=test..send(), .set(), .query() and .expect() to build requests, and flexible matchers for bodies.mongodb-memory-server (or a dedicated test database) and clean up in afterEach.jest.fn() doubles.Next lesson: API Documentation with Swagger and OpenAPI — describe your endpoints in an OpenAPI document and serve interactive docs from Express.