Automated API Testing with Jest and Supertest

Intermediate
13 min

Automated API Testing with Jest and Supertest

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.

Layers of an API Test Suite

| 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.

Setup

Supertest sends requests to an Express app object directly, so export the app without listen():

javascript
// 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);
bash
npm install --save-dev jest supertest # package.json: "scripts": { "test": "jest" }, "jest": { "testEnvironment": "node" } npm test

Writing Integration Tests

Each test describes one behaviour of one endpoint. Assert the status, content type, body shape and promised headers:

javascript
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).

Test Data and Isolation

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.

Contract Tests Against OpenAPI

If you maintain an OpenAPI document, one matcher checks that responses conform to it:

javascript
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.

Common Mistakes

  • Asserting only the status code. A 200 with the wrong body is still a bug.
  • Starting a real server on a fixed port in tests; it breaks parallel runs.
  • Sharing mutable fixtures across tests, so failures depend on test order.
Quick Quiz
Question 1 of 2

Why should the Express app be exported separately from the call to `listen()`?

Key Takeaways

  • Integration tests through Supertest cover routing, middleware, validation and status codes together.
  • Export app without listen() so tests run in-process with no port.
  • Assert status, content type, body shape and promised headers, and test the error paths on purpose.
  • Isolate tests with a test database, beforeEach resets and mocked external services.
  • Contract tests with 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.

Automated API Testing with Jest and Supertest - REST APIs | CodeYourCraft | CodeYourCraft