Integration Testing with Supertest
Why Integration Tests
Unit tests verify a single function in isolation. Integration tests go one level up: they send a real HTTP request through your Express app — routing, JSON parsing, validation, handlers — and assert on the actual response. Supertest drives the app in memory, so no server ever binds to a port and the tests stay fast enough to run on every save.
Everything between the test and the assertion is real production code:
A unit test that mocks the router would never catch a missing app.use(express.json()) or a route registered under the wrong path. This setup does.
Setup
Install Express plus the dev dependencies:
npm install express
npm install --save-dev jest supertest
The one structural rule that makes an Express app testable: export the app, start the server in a separate file. Supertest needs the app object, not a running server.
const express = require("express");
const booksRouter = require("./books.router");
const app = express();
app.use(express.json());
app.use("/api/books", booksRouter);
app.use((req, res) => {
res.status(404).json({ error: "Route not found" });
});
module.exports = app;
const app = require("./app");
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server listening on port ${PORT}`));
npm start runs server.js; Jest only ever imports app.js.
An In-Memory Data Layer
Hitting a real database makes integration tests slow and order-dependent. A small in-memory store keeps the router code identical to production code while giving tests a reset() method for a clean slate:
let books = [];
let nextId = 1;
module.exports = {
reset() {
books = [];
nextId = 1;
},
findAll() {
return books;
},
findById(id) {
return books.find((b) => b.id === id) || null;
},
create({ title, author }) {
const book = { id: nextId++, title, author };
books.push(book);
return book;
},
update(id, fields) {
const book = this.findById(id);
if (!book) return null;
Object.assign(book, fields);
return book;
},
remove(id) {
const index = books.findIndex((b) => b.id === id);
if (index === -1) return false;
books.splice(index, 1);
return true;
},
};
The CRUD Router Under Test
const { Router } = require("express");
const store = require("./books.store");
const router = Router();
router.get("/", (req, res) => {
res.json(store.findAll());
});
router.get("/:id", (req, res) => {
const book = store.findById(Number(req.params.id));
if (!book) return res.status(404).json({ error: "Book not found" });
res.json(book);
});
router.post("/", (req, res) => {
const { title, author } = req.body;
if (!title || !author) {
return res.status(400).json({ error: "title and author are required" });
}
res.status(201).json(store.create({ title, author }));
});
router.patch("/:id", (req, res) => {
const book = store.update(Number(req.params.id), req.body);
if (!book) return res.status(404).json({ error: "Book not found" });
res.json(book);
});
router.delete("/:id", (req, res) => {
const removed = store.remove(Number(req.params.id));
if (!removed) return res.status(404).json({ error: "Book not found" });
res.status(204).end();
});
module.exports = router;
Writing the Tests
Each test gets a fresh store via beforeEach, so no test depends on what another test created:
const request = require("supertest");
const app = require("./app");
const store = require("./books.store");
beforeEach(() => {
store.reset();
});
describe("POST /api/books", () => {
test("creates a book and returns 201 with the new resource", async () => {
const res = await request(app)
.post("/api/books")
.send({ title: "Clean Code", author: "Robert C. Martin" });
expect(res.status).toBe(201);
expect(res.body).toEqual({
id: 1,
title: "Clean Code",
author: "Robert C. Martin",
});
});
test("returns 400 when required fields are missing", async () => {
const res = await request(app).post("/api/books").send({ title: "Nameless" });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/);
});
});
describe("GET /api/books", () => {
test("returns previously created books", async () => {
await request(app).post("/api/books").send({ title: "Refactoring", author: "Fowler" });
const res = await request(app).get("/api/books");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({ title: "Refactoring" });
});
});
describe("GET /api/books/:id", () => {
test("returns 404 for a book that does not exist", async () => {
const res = await request(app).get("/api/books/999");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Book not found" });
});
});
describe("PATCH /api/books/:id", () => {
test("updates fields and returns the updated book", async () => {
const created = await request(app)
.post("/api/books")
.send({ title: "Draft", author: "Unknown" });
const res = await request(app)
.patch(`/api/books/${created.body.id}`)
.send({ title: "Final Title" });
expect(res.status).toBe(200);
expect(res.body.title).toBe("Final Title");
expect(res.body.author).toBe("Unknown");
});
});
describe("DELETE /api/books/:id", () => {
test("deletes a book and returns 204", async () => {
const created = await request(app)
.post("/api/books")
.send({ title: "Temp", author: "Nobody" });
const del = await request(app).delete(`/api/books/${created.body.id}`);
expect(del.status).toBe(204);
const check = await request(app).get(`/api/books/${created.body.id}`);
expect(check.status).toBe(404);
});
});
Testing Protected Routes
Most real APIs sit behind authentication. Supertest sets headers with .set(), so testing an auth guard is the same pattern — cover the 401 as deliberately as the 200:
function requireApiKey(req, res, next) {
const key = req.header("x-api-key");
if (key !== process.env.API_KEY) {
return res.status(401).json({ error: "Invalid or missing API key" });
}
next();
}
module.exports = { requireApiKey };
Mount it in app.js with app.use("/api/books", requireApiKey, booksRouter), then test both sides of the guard:
const request = require("supertest");
const app = require("./app");
beforeAll(() => {
process.env.API_KEY = "test-key-for-rizwan";
});
test("returns 401 without an API key", async () => {
const res = await request(app).get("/api/books");
expect(res.status).toBe(401);
expect(res.body.error).toMatch(/API key/);
});
test("returns 401 with a wrong API key", async () => {
const res = await request(app)
.get("/api/books")
.set("x-api-key", "guessed-key");
expect(res.status).toBe(401);
});
test("returns 200 with a valid API key", async () => {
const res = await request(app)
.get("/api/books")
.set("x-api-key", "test-key-for-rizwan");
expect(res.status).toBe(200);
});
The same .set() call handles bearer tokens: .set("Authorization", "Bearer " + token). For JWT-based apps I keep a small test helper that signs a token for a fixture user, so every test can request as, say, Rizwan Ashiq with one line.
Key Takeaways
- Export the app and keep
listenout of it — that is what makes Supertest possible. request(app).post(...).send(payload)exercises the full middleware chain, exactly like a real client.- Reset shared state in
beforeEachso every test starts from zero and can run in any order. - Assert on both the status code and the response body; a correct body with a wrong status is still a broken API contract.
- Cover the sad paths — 404 for missing resources and 400 for invalid input — with the same care as the happy path.