Clicking through Postman proves an endpoint works today. Automated tests prove it still works after every refactor and dependency upgrade. In this lesson you will learn the layers of an API test suite, how to drive an Express app in-process with Supertest, how to verify status codes, headers, bodies and error shapes, and how to check responses against your OpenAPI contract.
| Layer | What it exercises | Tooling |
|---|---|---|
| Unit | Pure functions: validation, pricing, serializers | Jest alone |
| Integration | Real HTTP requests through the app with a test database | Jest + Supertest |
| Contract | Responses match the OpenAPI document | jest-openapi |
| End-to-end | The deployed API from outside | Postman collections via Newman, or Playwright |
Integration tests give the best return: they run in milliseconds, need no server process, and catch routing, middleware, validation and status-code bugs together.
Supertest sends requests to an Express app object directly, so export the app without listen():
// src/app.js
const express = require("express");
const app = express();
app.use(express.json());
app.use("/books", require("./routes/books"));
module.exports = app;
// src/server.js
const app = require("./app");
app.listen(process.env.PORT || 3000);npm install --save-dev jest supertest
# package.json: "scripts": { "test": "jest" }, "jest": { "testEnvironment": "node" }
npm testEach test describes one behaviour of one endpoint. Assert the status, content type, body shape and promised headers:
const request = require("supertest");
const app = require("../src/app");
describe("POST /books", () => {
const token = process.env.TEST_TOKEN;
it("creates a book and returns 201 with a Location header", async () => {
const res = await request(app)
.post("/books")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Dune", author: "Frank Herbert" })
.expect(201)
.expect("Content-Type", /json/);
expect(res.body).toMatchObject({ title: "Dune", author: "Frank Herbert" });
expect(res.headers.location).toBe(`/books/${res.body.id}`);
});
it("rejects a body without a title with 422", async () => {
const res = await request(app).post("/books").set("Authorization", `Bearer ${token}`).send({ author: "X" });
expect(res.status).toBe(422);
expect(res.body.errors).toEqual(expect.arrayContaining([expect.objectContaining({ pointer: "/title" })]));
});
it("requires authentication", () => request(app).post("/books").send({ title: "Dune" }).expect(401));
});Cover the unhappy paths deliberately: missing token (401), wrong owner (403 or 404), unknown id (404), invalid body (422), duplicate (409).
Tests must not depend on each other or on leftovers from a previous run. Point the app at a dedicated test database through an environment variable, and reset state in beforeEach by truncating tables or re-seeding fixtures. Replace external services such as payment providers with jest.mock so tests stay fast and deterministic, and generate the test token in a setup file rather than committing a real credential.
If you maintain an OpenAPI document, one matcher checks that responses conform to it:
const jestOpenAPI = require("jest-openapi").default;
jestOpenAPI(__dirname + "/../openapi.yaml");
it("GET /books matches the contract", async () => {
const res = await request(app).get("/books?limit=2").expect(200);
expect(res).toSatisfyApiSpec();
});A field added to the code but not the document, or a documented field that stopped appearing, now fails the build instead of surprising a client.
200 with the wrong body is still a bug.Why should the Express app be exported separately from the call to `listen()`?
app without listen() so tests run in-process with no port.beforeEach resets and mocked external services.jest-openapi keep the documentation and the implementation in sync.Next lesson: REST API Security Best Practices — harden every layer of the API you can now build, document and test.