Skip to main content

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.

src/app.js
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;
src/server.js
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:

src/books.store.js
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

src/books.router.js
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:

src/books.router.test.js
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:

src/auth.middleware.js
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:

src/auth.test.js
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 listen out 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 beforeEach so 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.