Avancé30 min de lecture

Testing APIs with Jest

Write unit and integration tests for your Express API using Jest and Supertest.

Why Test Your API?

Every developer has experienced this scenario: you fix a bug in the user registration endpoint, deploy to production, and suddenly the login endpoint breaks. You did not touch the login code, but your fix changed a shared validation function that both endpoints depend on. Without tests, you would not discover this until a user reports that they cannot log in — possibly hours or days later.

Tests are your safety net. They catch bugs before your users do. They give you confidence to refactor code because you can verify that existing behavior has not changed. They document what your code is supposed to do — a well-written test suite is the most accurate documentation of your API's behavior. And they prevent regressions — the situation where fixing one thing breaks another.

There are three main types of tests, each serving a different purpose:

Unit Tests test individual functions in complete isolation. You test a validateEmail() function by passing various inputs and checking the outputs. Unit tests are fast (milliseconds), do not require a running server or database, and pinpoint exactly which function is broken. They are ideal for testing pure logic: validation functions, utility helpers, data transformations.

Integration Tests test how multiple components work together. For an Express API, this means testing actual HTTP endpoints — sending a POST request to /api/users with a JSON body and verifying the response status, body, and headers. Integration tests catch bugs that unit tests miss: middleware ordering issues, database query errors, route configuration problems, authentication flows.

End-to-End (E2E) Tests test the entire system from the user's perspective. For a web application, this means automating a browser to sign up, log in, create a post, and verify it appears on the page. E2E tests are the most realistic but also the slowest and most brittle.

For backend APIs, integration tests with Supertest are the sweet spot. They test real HTTP requests against your Express app, exercise your middleware pipeline, hit your database (a test database), and verify actual responses. They catch the bugs that matter most — the ones your users would encounter — while remaining fast enough to run on every commit. A typical API test suite is 80% integration tests and 20% unit tests for complex business logic.

Jest Setup

Jest is the most popular JavaScript testing framework. Created by Facebook and used by companies like Airbnb, Spotify, and Twitter, it provides everything you need for testing in a single package: a test runner, assertion library, mocking utilities, code coverage, and watch mode.

Install Jest and Supertest as development dependencies:

bash
npm install --save-dev jest supertest

Add the test script to your package.json:

json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watchAll",
    "test:coverage": "jest --coverage"
  }
}

Jest automatically finds test files using these conventions: files ending in .test.js or .spec.js, or files inside a __tests__/ directory. The naming convention user.test.js or user.spec.js keeps tests co-located with the code they test. Organize tests in a structure that mirrors your source code: if your route is in routes/users.js, your test goes in routes/users.test.js or __tests__/routes/users.test.js.

Jest provides these core functions for writing tests:

describe(name, fn) — Groups related tests into a test suite. Nest describe blocks for sub-categories:

javascript
describe('User API', () => {
  describe('POST /api/users', () => { /* create user tests */ });
  describe('GET /api/users/:id', () => { /* get user tests */ });
});

it(name, fn) or test(name, fn) — Defines a single test case. The name should describe the expected behavior in plain English: it('should return 404 when user not found'), not it('test 1').

expect(value) — Creates an assertion. Chain it with matchers:

  • .toBe(expected) — Strict equality (===)
  • .toEqual(expected) — Deep equality for objects and arrays
  • .toBeTruthy() / .toBeFalsy() — Truthy/falsy checks
  • .toContain(item) — Array includes item or string includes substring
  • .toBeInstanceOf(Class) — Type checking
  • .toThrow() — Function throws an error
  • .toHaveLength(n) — Array or string length
  • .toBeGreaterThan(n) / .toBeLessThan(n) — Numeric comparisons

beforeAll(fn) / afterAll(fn) — Run once before/after all tests in the suite. Use for setup (connecting to test database) and teardown (closing connections).

beforeEach(fn) / afterEach(fn) — Run before/after each individual test. Use for resetting state (clearing the database between tests so each test starts fresh).

Supertest — HTTP Integration Tests

Supertest is a library that makes HTTP requests to your Express application without starting an actual server. This is the key insight — you do not need to call app.listen() and make real network requests. Supertest binds directly to your Express app instance and simulates HTTP requests in-process, making tests fast and deterministic.

The critical architectural requirement: separate your Express app creation from your server startup. Your app.js should create and configure the Express app and export it. Your server.js (or index.js) should import the app and call listen(). This separation allows Supertest to import the app without starting the server:

javascript
// app.js — creates and exports the Express app
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => res.json([]));
module.exports = app;

// server.js — starts the server (NOT imported in tests)
const app = require('./app');
app.listen(3000, () => console.log('Server running'));

Now your tests import app.js and use Supertest:

javascript
const request = require('supertest');
const app = require('../app');

describe('GET /api/users', () => {
  it('should return a 200 status code', async () => {
    const response = await request(app).get('/api/users');
    expect(response.status).toBe(200);
  });

  it('should return an array', async () => {
    const response = await request(app).get('/api/users');
    expect(response.body).toBeInstanceOf(Array);
  });
});

Supertest supports all HTTP methods and lets you chain configuration:

javascript
// POST with JSON body
const res = await request(app)
  .post('/api/users')
  .send({ name: 'Alice', email: 'alice@test.com' })
  .set('Authorization', 'Bearer token123')
  .expect('Content-Type', /json/)
  .expect(201);

The .send() method sets the request body and automatically sets Content-Type: application/json. The .set() method sets request headers — essential for testing authenticated endpoints. The .expect() method can assert on status codes, headers, and content types inline. You can chain multiple .expect() calls.

The response object gives you everything you need: res.status (number), res.body (parsed JSON), res.headers (object), and res.text (raw response body). Use res.body for JSON APIs and write assertions with Jest's expect().

Complete API Test File

javascript
const request = require('supertest');
const app = require('../app');
const mongoose = require('mongoose');
const User = require('../models/User');

// ── Setup & Teardown ─────────────────────────────────
beforeAll(async () => {
  // Connect to test database (separate from dev/prod!)
  await mongoose.connect(process.env.MONGODB_URI_TEST);
});

afterEach(async () => {
  // Clean up data between tests (each test starts fresh)
  await User.deleteMany({});
});

afterAll(async () => {
  // Close database connection after all tests finish
  await mongoose.connection.close();
});

// ── GET /api/users ───────────────────────────────────
describe('GET /api/users', () => {
  it('should return an empty array when no users exist', async () => {
    const res = await request(app).get('/api/users');
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
  });

  it('should return all users', async () => {
    // Arrange: create test data
    await User.create({ name: 'Alice', email: 'alice@test.com' });
    await User.create({ name: 'Bob', email: 'bob@test.com' });

    // Act: make the request
    const res = await request(app).get('/api/users');

    // Assert: verify the response
    expect(res.status).toBe(200);
    expect(res.body).toHaveLength(2);
    expect(res.body[0]).toHaveProperty('name');
    expect(res.body[0]).toHaveProperty('email');
  });
});

// ── POST /api/users ──────────────────────────────────
describe('POST /api/users', () => {
  it('should create a new user with valid data', async () => {
    const userData = { name: 'Alice', email: 'alice@test.com' };

    const res = await request(app)
      .post('/api/users')
      .send(userData)
      .expect('Content-Type', /json/);

    expect(res.status).toBe(201);
    expect(res.body.name).toBe('Alice');
    expect(res.body.email).toBe('alice@test.com');
    expect(res.body).toHaveProperty('_id');

    // Verify it was actually saved to the database
    const userInDb = await User.findById(res.body._id);
    expect(userInDb).not.toBeNull();
    expect(userInDb.name).toBe('Alice');
  });

  it('should return 400 when name is missing', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'alice@test.com' });

    expect(res.status).toBe(400);
    expect(res.body).toHaveProperty('error');
  });

  it('should return 400 when email is missing', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Alice' });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/email/i);
  });

  it('should return 409 when email already exists', async () => {
    await User.create({ name: 'Alice', email: 'alice@test.com' });

    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Alice 2', email: 'alice@test.com' });

    expect(res.status).toBe(409);
  });
});

// ── GET /api/users/:id ───────────────────────────────
describe('GET /api/users/:id', () => {
  it('should return a user by ID', async () => {
    const user = await User.create({ name: 'Alice', email: 'alice@test.com' });

    const res = await request(app).get(`/api/users/${user._id}`);

    expect(res.status).toBe(200);
    expect(res.body.name).toBe('Alice');
  });

  it('should return 404 for non-existent ID', async () => {
    const fakeId = new mongoose.Types.ObjectId();
    const res = await request(app).get(`/api/users/${fakeId}`);

    expect(res.status).toBe(404);
  });
});

Test Database & Cleanup

Never run tests against your development or production database. Tests create, modify, and delete data constantly. Running afterEach(() => User.deleteMany({})) against your production database would wipe out all your real users. Running tests against your development database would corrupt the data you use for manual testing and debugging.

The standard practice is to use environment-specific database connections. Your .env file contains MONGODB_URI for development. Your .env.test file (or a TEST_MONGODB_URI variable) contains a separate connection string pointing to a dedicated test database. When Jest runs, it loads the test environment configuration.

javascript
// In your test setup or jest.config.js
process.env.MONGODB_URI = process.env.MONGODB_URI_TEST || 'mongodb://localhost:27017/myapp_test';

Test lifecycle management follows a clear pattern:

beforeAll — Runs once before any tests in the file. Use it to establish the database connection. This is expensive (connecting to MongoDB takes time), so you do it once, not before every test.

javascript
beforeAll(async () => {
  await mongoose.connect(process.env.MONGODB_URI_TEST);
});

afterEach — Runs after every individual test. Use it to clean the database so each test starts with a blank slate. This is the most important lifecycle hook. Tests that depend on each other's data are fragile and break randomly depending on execution order.

javascript
afterEach(async () => {
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    await collections[key].deleteMany({});
  }
});

afterAll — Runs once after all tests in the file complete. Close the database connection to prevent Jest from hanging (open connections prevent the process from exiting).

javascript
afterAll(async () => {
  await mongoose.connection.close();
});

The Arrange-Act-Assert pattern structures each test clearly:

  1. Arrange — Set up the test data. Create users, seed the database, prepare the request body.
  2. Act — Perform the action you are testing. Make the HTTP request with Supertest.
  3. Assert — Verify the results. Check the response status, body, and headers. Optionally verify the database state.

This pattern makes tests readable and maintainable. When a test fails, you can immediately see what was set up, what action was taken, and which assertion failed. Name your tests descriptively: it('should return 404 when user does not exist') tells you exactly what the test verifies without reading the code.

What does Supertest do?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.