This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Introduction

I had 94% test coverage on a React app. Components, hooks, the edge cases nobody thinks about. That same sprint I pushed an API change that broke authentication for anyone who hadn't logged in recently. Found out three hours later from a support ticket.

The test suite was green the whole time.

If you know Jest for React, you already know 80% of what you need to test an Express API. The other 20% is a different test environment and one library you haven't used yet. This article covers that 20%.

The Setup

Start with a plain Node project. No Create React App, no framework opinions. Just Express.

mkdir api-testing-demo
cd api-testing-demo
npm init -y
npm install express
npm install --save-dev jest supertest @types/jest

You know Jest. Supertest is the new piece. It lets you fire HTTP requests directly at your Express app without starting a server on a real port. Instead of rendering components, you're hitting endpoints. The testing model is identical and the target is different.

Add this to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageDirectory": "coverage",
    "collectCoverageFrom": [
      "src/**/*.js",
      "!src/server.js"
    ]
  }
}

testEnvironment: "node" is the one that catches people first. Jest defaults to jsdom, the browser-like environment built for React. Node globals like process don't behave correctly in there. Set it to node and everything works as expected.

Folder structure:

api-testing-demo/
├── src/
│   ├── app.js
│   ├── server.js
│   ├── routes/
│   │   └── users.js
│   └── __tests__/
│       └── users.test.js
└── package.json

app.js exports the Express app. server.js imports it and calls listen(). Keep them separate. If listen() lives in app.js, every test file that imports the app tries to bind a real port. You'll get PORT already in use errors and spend time debugging something that isn't a test problem. Small separation, large impact on reliability.

Testing Routes Without a Server

A basic route:

// src/routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
  res.json({ users: [] });
});
router.post('/', (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email required' });
  }
  res.status(201).json({ id: 1, name, email });
});
module.exports = router;

And the test:

// src/__tests__/users.test.js
const request = require('supertest');
const app = require('../app');
describe('GET /users', () => {
  it('returns empty array when no users exist', async () => {
    const response = await request(app)
      .get('/users')
      .expect('Content-Type', /json/)
      .expect(200);
    expect(response.body.users).toEqual([]);
  });
});
describe('POST /users', () => {
  it('creates a new user with valid data', async () => {
    const newUser = { name: 'Godstime', email: 'godstime@example.com' };
    const response = await request(app)
      .post('/users')
      .send(newUser)
      .expect('Content-Type', /json/)
      .expect(201);
    expect(response.body.id).toBeDefined();
    expect(response.body.name).toBe(newUser.name);
    expect(response.body.email).toBe(newUser.email);
  });
  it('returns 400 when name is missing', async () => {
    const response = await request(app)
      .post('/users')
      .send({ email: 'test@example.com' })
      .expect(400);
    expect(response.body.error).toBe('Name and email required');
  });
});

The pattern is arrange, act and assert just like React Testing Library. Supertest's .expect() handles status codes and headers. Jest's expect() handles the body. If you've written component tests, this is not a new skill.

Test the error cases. APIs fail in production more creatively than they succeed. Missing fields, wrong types, IDs that don't exist. The happy path passing means you've tested the one scenario where nothing goes wrong.

One thing that will cost you an hour if you miss it is to always await your Supertest calls. Without it, Jest sees the test function return and marks it as passing before the request completes. The test is green and wrong at the same time.

// This looks like a test. It is not actually a test.
it('creates user', () => {
  request(app).post('/users').send(data);
});
// This is a test.
it('creates user', async () => {
  await request(app).post('/users').send(data);
});

Jest won't warn you. It just passes.

All tests passing after running npm test.
All tests passing after running npm test.

Database Mocking

Hitting a real database in tests is slow, leaves data behind, and eventually causes tests to break each other. One test creates a user, the next expects an empty collection, and now you're chasing a failure that has nothing to do with the code you changed.

The pattern is to isolate the database before the suite runs, clean between each test, tear it down when done. That's it.

For MongoDB, use MongoDB Memory Server:

npm install --save-dev mongodb-memory-server
// src/__tests__/setup.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
let mongoServer;
beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});
afterEach(async () => {
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    await collections[key].deleteMany();
  }
});

This starts a real MongoDB instance in memory. Spins up in under a second, completely isolated from your development database. The afterEach cleanup is what keeps tests from interfering with each other. Skip it and your tests will pass individually and fail as a suite. The error messages won't point you here. That's the fun part.

For SQL databases, SQLite in-memory works the same way:

// knexfile.js
module.exports = {
  test: {
    client: 'sqlite3',
    connection: ':memory:',
    useNullAsDefault: true,
    migrations: {
      directory: './migrations'
    }
  }
};

With the database wired up, you can go one level deeper and assert against it directly, not just what the API responded with but what actually ended up stored.

it('actually saves the user', async () => {
  const newUser = { name: 'Godstime', email: 'godstime@example.com' };
  await request(app)
    .post('/users')
    .send(newUser)
    .expect(201);
  const saved = await User.findOne({ email: newUser.email });
  expect(saved).toBeTruthy();
  expect(saved.name).toBe(newUser.name);
});

The difference between testing what the API claims it did and testing what it actually did.

Coverage report after running npm run test:coverage.
Coverage report after running npm run test:coverage.

Authentication & Middleware

Protected routes are where the time investment pays back. Manually testing auth means generating a token, pasting it into Postman, remembering to test the no-token case, and repeating the whole thing after every change. Tests run all of it in two seconds.

The middleware:

const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token' });
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

The tests:

describe('Authentication', () => {
  it('allows access with a valid token', async () => {
    const token = jwt.sign({ userId: 1 }, process.env.JWT_SECRET);
    await request(app)
      .get('/protected-route')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);
  });
  it('rejects requests with no token', async () => {
    const response = await request(app)
      .get('/protected-route')
      .expect(401);
    expect(response.body.error).toBe('No token');
  });
  it('rejects a tampered token', async () => {
    await request(app)
      .get('/protected-route')
      .set('Authorization', 'Bearer not-a-real-token')
      .expect(401);
  });
});

Sometimes you want to test the route logic without running JWT verification. You can mock the middleware to skip straight to an authenticated state:

jest.mock('../middleware/authenticate', () => {
  return (req, res, next) => {
    req.user = { userId: 1, email: 'test@example.com' };
    next();
  };
});

Worth knowing it exists. Worth being careful with it. Mock the middleware everywhere and you end up testing that your mocks work, which is a different thing entirely. Real tokens for route tests, mocks for the route handler in isolation. That line is worth respecting.

CI/CD Integration

If you already have a pipeline from my previous GitLab article, adding API tests is about ten lines:

test:api:
  stage: test
  needs: [install]
  variables:
    JWT_SECRET: "ci-test-secret"
  script:
    - npm run test:coverage
  artifacts:
    when: always
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

GitLab picks up the coverage percentage and displays it on the pipeline page and inline on merge requests. JWT_SECRET goes in Settings > CI/CD > Variables, not in the yml file. Anything committed to .gitlab-ci.yml is readable. Treat it that way.

One thing that catches people in CI but not locally is the tests that hang until they time out. Usually an open database connection that didn't get closed. Without the afterAll closing everything properly, CI just sits there until the job times out and fails. The logs won't say "open connection." They'll just say the job exceeded the time limit.

afterAll(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});

Both lines. In that order.

GitHub Actions equivalent:

- name: Run API tests
  run: npm run test:coverage
  env:
    JWT_SECRET: test-secret
    NODE_ENV: test
The test:api job passing in GitLab with coverage reported on the pipeline page.
The test:api job passing in GitLab with coverage reported on the pipeline page.

Conclusion

You already knew how to write these tests. The mental shift is treating the API as a black box where HTTP request goes in, HTTP response comes out. Start with one route, cover the happy path and the error cases, add database assertions once those pass.

The goal is to feel as uncomfortable shipping an untested endpoint as you do shipping an untested component. Once that discomfort exists on both sides, the backend stops being the half of the application you ship on faith.

The tool I just explained in its entirety is open-source. You can clone my GitLab repo and try it for yourself.

 Godstime Aburu Godstime Aburu

Godstime Aburu is a technical writer and Computer Engineering graduate published on Smashing Magazine and OpenReplay. He specializes in making complex backend concepts accessible to frontend developers. Find more of his work at Smashing Magazine, OpenReplay and PHP Architect.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.