Skip to main content
Back to module
Back-End Web APIs · Testing and Shipping

API tests

Reading31mLesson 1 of 3 in this module

Test routes through the real app with a request library, covering success, validation, auth and not-found cases.

Why it matters

An API has many callers, and they all depend on its exact behaviour: the status codes, the field names, who is allowed to do what. A small change in one route can break a mobile app that shipped months ago. Automated tests let you change code with confidence: you run one command, and within seconds you know whether every route still behaves as promised. Teams also review tests to understand what a route is meant to do, so a good test suite doubles as documentation.

Kinds of tests

KindWhat it exercisesSpeed
UnitOne function, such as a validator or a price calculationMilliseconds
Integration / APIA real request through routing, middleware, validation and a test databaseTens of milliseconds
End-to-endA browser or app talking to a deployed stackSeconds

For a back end, API tests give the best value: they check the contract that clients see. Keep pure logic in unit tests, and save end-to-end tests for a few critical journeys such as signup and checkout.

The tools

In Node, a typical setup is:

  • A test runner: Vitest, Jest, or Node's built-in node:test. It finds test files, runs them and reports failures.
  • A request library: supertest is the classic choice. It takes your Express app, sends real HTTP requests to it in memory (no port to open, no server to start) and lets you assert on the response.
  • A test database: an in-memory SQLite database, a throwaway Docker container, or a library such as mongodb-memory-server. Never run tests against production data.

To make the app testable, export it from one file and call listen in another:

// app.js
import express from "express";
export const app = express();
app.use(express.json());
app.get("/courses/:id", (req, res) => { /* ... */ });

// server.js
import { app } from "./app.js";
app.listen(process.env.PORT ?? 3000);

Worked example: testing a route

// courses.test.js
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "./app.js";

describe("GET /courses/:id", () => {
  it("returns the course as JSON", async () => {
    const res = await request(app).get("/courses/1");
    expect(res.status).toBe(200);
    expect(res.headers["content-type"]).toMatch(/json/);
    expect(res.body).toMatchObject({ id: 1, title: "SQL Fundamentals" });
  });

  it("returns 404 for a course that does not exist", async () => {
    const res = await request(app).get("/courses/999");
    expect(res.status).toBe(404);
  });
});

describe("POST /courses", () => {
  it("rejects a missing title with 400", async () => {
    const res = await request(app).post("/courses").send({});
    expect(res.status).toBe(400);
  });
});

Run it with npx vitest (or the test script in package.json). Each test follows arrange, act, assert: set up data, make one request, check the result.

What to test for each route

A route is not tested until its failure paths are tested. For a typical route, cover:

  1. The happy path: correct status, content type and body shape.
  2. Validation: missing fields, wrong types and limits each give 400 (or 422).
  3. Not found: an unknown ID gives 404.
  4. Authentication: no session or token gives 401.
  5. Authorization: another user's object gives 403 or 404, never the data.
  6. Side effects: after a POST, a follow-up GET shows the new item; after a DELETE, it is gone.

Point 5 is the test most often missing, and it guards against the most common API vulnerability.

Does the test actually catch bugs?

A test that always passes is worse than none, because it gives false confidence. A good trick is to break the code on purpose: return 200 instead of 404, drop the ownership check, skip validation. If your tests still pass, they are not checking enough. Tools that automate this are called mutation testing tools; the activity in this lesson uses the same idea by hand.

Keeping tests reliable

  • Isolate data. Reset or reseed the test database before each test file, so tests do not depend on one another's leftovers.
  • No real network. Stub email, payments and third-party APIs. Tests that call the internet fail randomly.
  • No sleeps. Avoid waiting a fixed time; await the promise or poll for a condition.
  • Run them in CI on every pull request, so a broken route never reaches main.
  • Measure coverage, but treat it as a hint. 90% coverage that never checks a 401 misses the bug that matters.

Common mistakes

  • Testing only the happy path.
  • Asserting on the whole response body, so any new field breaks the test. Assert on the fields that matter (toMatchObject).
  • Starting a real server on a fixed port in tests, which clashes with other runs.
  • Sharing state between tests, so they pass alone but fail together.
  • Never checking that a test fails when the code is wrong.

Quick recap

  • API tests send requests through the real app and assert on status, headers and body.
  • Export the Express app separately from listen, then use supertest with a test runner.
  • Cover the happy path, validation, not found, 401, the "other user" case, and side effects.
  • A useful test fails when you break the code on purpose.

Try it: in the Do tab, write a test function that passes on a correct app and catches three broken ones.

Also in this lesson: