Testing Express Apps

Testing verifies that your routes, middleware, and logic behave correctly before bugs reach users. Untested code breaks in production in ways that are hard to trace. A proper test suite catches regressions when you change existing code and gives you confidence to ship new features. This topic covers writing unit and integration tests for Express using Jest as the test runner and Supertest for making HTTP requests against your app without starting a real server.

Types of Tests for Express Apps

┌───────────────────────────────────────────────────────────────────┐
│                  Testing Pyramid for Express                      │
│                                                                   │
│                         ▲ E2E Tests                               │
│                        ╱ ╲  (full browser tests — slow)           │
│                       ╱───╲                                       │
│                      ╱ Int ╲ Integration Tests                    │
│                     ╱  egr  ╲ (routes + middleware — medium)      │
│                    ╱─────────╲                                    │
│                   ╱ Unit Tests╲ (functions — fast, many)          │
│                  ╱─────────────╲                                  │
└───────────────────────────────────────────────────────────────────┘

Unit test: Test one function in isolation (a validator, a utility)
Integration test: Test a full HTTP request through Express routes
E2E test: Test through a real browser (Playwright, Cypress)

Install Testing Tools

npm install --save-dev jest supertest

Add a test script to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node"
  }
}

Structure Your App for Testability

Separate your Express app setup from the server start. This lets Supertest import the app without opening a port:

// app.js — exports the Express app only
const express = require('express');
const app = express();
app.use(express.json());

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

app.post('/api/users', (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 = app; // Export — do NOT call app.listen() here
// server.js — starts the actual server
const app = require('./app');
app.listen(3000, () => console.log('Server running'));

Your npm start script runs server.js. Tests import app.js directly.

Writing Your First Test with Supertest

// tests/health.test.js
const request = require('supertest');
const app = require('../app');

describe('Health Check', () => {
  test('GET /health returns 200 and status ok', async () => {
    const response = await request(app).get('/health');

    expect(response.statusCode).toBe(200);
    expect(response.body.status).toBe('ok');
  });
});

Run tests with:

npm test

Output:

PASS tests/health.test.js
  Health Check
    ✓ GET /health returns 200 and status ok (45ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

Testing POST Routes

// tests/users.test.js
const request = require('supertest');
const app = require('../app');

describe('POST /api/users', () => {
  test('creates a user with valid data', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@example.com' });

    expect(response.statusCode).toBe(201);
    expect(response.body.name).toBe('Alice');
    expect(response.body.email).toBe('alice@example.com');
    expect(response.body.id).toBeDefined();
  });

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

    expect(response.statusCode).toBe(400);
    expect(response.body.error).toBe('Name and email required');
  });

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

    expect(response.statusCode).toBe(400);
    expect(response.body).toHaveProperty('error');
  });
});

Testing Authenticated Routes

describe('GET /api/profile (protected)', () => {
  test('returns 401 without a token', async () => {
    const response = await request(app).get('/api/profile');
    expect(response.statusCode).toBe(401);
  });

  test('returns profile data with a valid token', async () => {
    // First, log in to get a token
    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({ email: 'alice@example.com', password: 'password123' });

    const token = loginRes.body.token;

    // Use the token to access the protected route
    const profileRes = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${token}`);

    expect(profileRes.statusCode).toBe(200);
    expect(profileRes.body.email).toBe('alice@example.com');
  });
});

Mocking the Database

Tests should not hit a real database. Mocking replaces real database calls with fake ones that return controlled data:

// Mock the User model
jest.mock('../models/User');
const User = require('../models/User');

describe('GET /api/users/:id', () => {
  test('returns a user when found', async () => {
    // Tell the mock what to return
    User.findById.mockResolvedValue({
      _id: '123',
      name: 'Alice',
      email: 'alice@example.com'
    });

    const response = await request(app).get('/api/users/123');

    expect(response.statusCode).toBe(200);
    expect(response.body.data.name).toBe('Alice');
  });

  test('returns 404 when user not found', async () => {
    User.findById.mockResolvedValue(null);

    const response = await request(app).get('/api/users/999');

    expect(response.statusCode).toBe(404);
    expect(response.body.error).toBe('User not found');
  });
});

Unit Testing a Utility Function

// utils/validateEmail.js
const validateEmail = (email) => {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
module.exports = validateEmail;

// tests/validateEmail.test.js
const validateEmail = require('../utils/validateEmail');

describe('validateEmail', () => {
  test('returns true for a valid email', () => {
    expect(validateEmail('alice@example.com')).toBe(true);
  });

  test('returns false for email without @', () => {
    expect(validateEmail('aliceexample.com')).toBe(false);
  });

  test('returns false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });

  test('returns false for email with spaces', () => {
    expect(validateEmail('alice @example.com')).toBe(false);
  });
});

Useful Jest Matchers for Express Testing

┌──────────────────────────────────────────────────────────────────┐
│              Common Jest Matchers                                │
├──────────────────────────┬───────────────────────────────────────┤
│ expect(x).toBe(y)        │ Strict equality (===)                 │
│ expect(x).toEqual(y)     │ Deep object equality                  │
│ expect(x).toBeDefined()  │ Not undefined                         │
│ expect(x).toBeNull()     │ Exactly null                          │
│ expect(x).toBeTruthy()   │ Any truthy value                      │
│ expect(x).toHaveProperty │ Object has a key                      │
│ expect(arr).toContain(v) │ Array contains value                  │
│ expect(x).toBeGreaterThan│ Number comparison                     │
│ expect(fn).toThrow()     │ Function throws an error              │
└──────────────────────────┴───────────────────────────────────────┘

Test Coverage

Run npm run test:coverage to see which lines of code your tests exercise:

----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
app.js    |   95.24 |    87.50 |  100.00 |   95.00 |
routes/   |   88.00 |    75.00 |   90.00 |   88.00 |
----------|---------|----------|---------|---------|

Aim for 80%+ coverage on routes and business logic. 100% coverage is not always practical or necessary, but low coverage means untested code paths that can fail silently in production.

Summary

Testing Express apps uses two key tools: Jest as the test runner and assertion library, and Supertest for making HTTP requests against your app without starting a real server. Separate your Express app setup (app.js) from the server start (server.js) so tests can import the app cleanly. Write integration tests that make real HTTP requests and check status codes and response bodies. Mock database models with jest.mock() to keep tests fast and isolated from real data. Write unit tests for utility functions, validators, and helper logic. Run npm run test:coverage to measure how much of your code is covered and identify untested areas before they cause production bugs.

Leave a Comment

Your email address will not be published. Required fields are marked *