DEV Community

Applying API Testing Frameworks: Jest + Supertest with Real-World Examples

Abstract

APIs are contracts: a client sends a request and expects a specific status code, specific headers and a specific body shape. When that contract breaks silently, every consumer breaks with it. This article demonstrates the complete application of an API testing stack — Jest as the test runner and Supertest as the HTTP assertion library — to a real Express REST API for a vehicle-workshop management system. The suite covers the five things every API test should verify: status codes, response bodies, headers, authentication and validation, plus state changes across requests. The result: 8 integration tests running in 0.82 seconds with 100% code coverage, without starting a server or opening a port, fully automated in a GitHub Actions pipeline that runs on every push against two Node.js versions. Full demo repo: github.com/YOUR_USER/api-testing-demo (replace with your repo link).

Why a framework instead of manual testing?

You can test an API by hand with Postman or curl — once. The problem is the 500th time, after every refactor, at 2 a.m. before a release. API testing frameworks turn those manual checks into code: repeatable, versioned, fast, and executable by a machine on every commit. Among the tools in the ecosystem (REST Assured for Java, pytest for Python, Karate, SoapUI), the standard pairing in the Node.js world is Jest + Supertest, and it has a trick the others don't: it talks to your Express app in memory, with no server process and no network at all.

The API under test

A small REST API for a vehicle workshop: list vehicles, fetch one by plate, and register new ones. The key design decision for testability is that app.js exports the app without calling listen():

// src/app.js (summary)
function createApp() {
  const app = express();
  app.use(express.json());

  // In-memory store — fresh per instance, so every test starts clean
  const vehicles = [
    { plate: 'ABC-123', owner: 'Maria Lopez', model: 'Toyota Hilux', status: 'in_service' },
    { plate: 'XYZ-789', owner: 'Jose Quispe', model: 'Kia Rio', status: 'ready' },
  ];

  app.get('/api/vehicles', ...);        // list + ?status= filter
  app.get('/api/vehicles/:plate', ...); // 200 or 404
  app.post('/api/vehicles', ...);       // 401 without API key, 400 invalid, 409 duplicate, 201 created

  return app;
}
module.exports = { createApp };
Enter fullscreen mode Exit fullscreen mode

Supertest receives that app object directly — it handles the HTTP layer internally. No port conflicts, no "is the server up yet?" flakiness.

The five assertions every API test should make

1. Status codes and headers. The most basic contract:

test('returns 200 with the vehicle list and count', async () => {
  const res = await request(app).get('/api/vehicles');

  expect(res.status).toBe(200);
  expect(res.headers['content-type']).toMatch(/application\/json/);
  expect(res.body.count).toBe(2);
});
Enter fullscreen mode Exit fullscreen mode

2. Response body shape. toEqual for exact matches, toMatchObject for partial ones:

test('returns 200 and the full vehicle object when it exists', async () => {
  const res = await request(app).get('/api/vehicles/ABC-123');

  expect(res.status).toBe(200);
  expect(res.body).toEqual({
    plate: 'ABC-123',
    owner: 'Maria Lopez',
    model: 'Toyota Hilux',
    status: 'in_service',
  });
});
Enter fullscreen mode Exit fullscreen mode

3. Error paths. A 404 is part of the contract too — clients depend on its shape:

test('returns 404 with an error message for an unknown plate', async () => {
  const res = await request(app).get('/api/vehicles/ZZZ-999');

  expect(res.status).toBe(404);
  expect(res.body.error).toMatch(/not found/);
});
Enter fullscreen mode Exit fullscreen mode

4. Authentication. Headers are set with .set():

test('returns 401 when the API key is missing', async () => {
  const res = await request(app).post('/api/vehicles').send(newVehicle);
  expect(res.status).toBe(401);
});
Enter fullscreen mode Exit fullscreen mode

5. State changes across requests. The most real-world test of all: create a resource, then fetch it back to prove it actually persisted:

test('creates the vehicle: 201, Location header and persisted state', async () => {
  const res = await request(app)
    .post('/api/vehicles')
    .set('x-api-key', API_KEY)
    .send(newVehicle);

  expect(res.status).toBe(201);
  expect(res.headers.location).toBe('/api/vehicles/DEF-456');

  const check = await request(app).get('/api/vehicles/DEF-456');
  expect(check.status).toBe(200); // the state really changed
});
Enter fullscreen mode Exit fullscreen mode

Validation (400 with the full error list) and conflicts (409 on duplicate plates) complete the suite. One detail that prevents 90% of flaky test suites: a beforeEach that rebuilds the app, so every test gets a fresh, isolated state — order never matters.

Running it

npm test
Enter fullscreen mode Exit fullscreen mode

Real output:

PASS tests/vehicles.test.js
  GET /api/vehicles
    ✓ returns 200 with the vehicle list and count (69 ms)
    ✓ filters by status with a query parameter (8 ms)
  GET /api/vehicles/:plate
    ✓ returns 200 and the full vehicle object when it exists (6 ms)
    ✓ returns 404 with an error message for an unknown plate (5 ms)
  POST /api/vehicles
    ✓ returns 401 when the API key is missing (14 ms)
    ✓ creates the vehicle: 201, Location header and persisted state (9 ms)
    ✓ returns 400 listing every validation error (7 ms)
    ✓ returns 409 when the plate is already registered (5 ms)

Test Suites: 1 passed, 1 total
Tests:       8 passed, 8 total
Time:        0.82 s
Enter fullscreen mode Exit fullscreen mode

Eight full HTTP request/response cycles in under a second. And npm run test:coverage confirms the suite exercises every line:

File      | % Stmts | % Branch | % Funcs | % Lines
----------|---------|----------|---------|--------
All files |     100 |      100 |     100 |     100
 app.js   |     100 |      100 |     100 |     100
Enter fullscreen mode Exit fullscreen mode

Automation: tests on every push with GitHub Actions

Tests you run manually are tests someone will eventually skip. This workflow runs the entire suite on every push and pull request, on a matrix of Node 20 and 22, and fails the build if any contract is broken:

# .github/workflows/api-tests.yml
name: API Tests — Jest + Supertest

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  api-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npx jest --coverage --ci
Enter fullscreen mode Exit fullscreen mode

If a teammate's refactor changes a status code or drops a field from a response, the pipeline goes red and the merge is blocked — the broken contract never reaches the clients.

Limitations worth knowing

Supertest tests the app in isolation: it won't catch problems in your reverse proxy, TLS setup or real network latency, and here the datastore is in-memory (in production suites you'd point tests at a disposable test database, e.g. with Docker). It's also JavaScript-only — for cross-language teams, contract-testing tools like Pact or collection runners like Newman complement it. Integration tests like these are the middle of the testing pyramid: they don't replace unit tests below or a few end-to-end smoke tests above.

Conclusion

With two dev dependencies and one design decision — exporting the app without listen() — we built a suite that verifies status codes, bodies, headers, auth, validation and persistence in 0.82 seconds at 100% coverage, and wired it into CI so the API contract is enforced on every commit forever. If your API doesn't have tests like these yet, this is one of the highest return-on-effort practices in backend development.

Demo repository (code + CI workflow): github.com/YOUR_USER/api-testing-demo

Top comments (0)