Testing Express Apps

Export the app without listening, drive it with supertest, and lean on Node 24's built-in test runner — no extra framework required.

The single design decision that makes Express testable is to separate the app from the server. Put all your routes and middleware in app.js and export default app; do the app.listen() in a separate server.js. Now tests import app and exercise it in-process, with no open port and no teardown races.

The modern stack

  • node:test — Node 24 ships a full test runner and assert. No Jest, no config, run with node --test.
  • supertest — makes real HTTP requests against your app object and gives you a fluent assertion API on the response.
import request from 'supertest';
import app from '../app.js';

await request(app).get('/health').expect(200);

What to test where

Pure services get fast unit tests (call the function, assert the return). Routes and middleware wiring get a smaller set of integration tests through supertest. This mirrors the controller/service split — the layering you built for clarity pays off again as testability.

Example

Example · javascript
// app.js  ->  export default app;   (no listen here)
// server.js  ->  import app; app.listen(PORT);   (only place that binds a port)

// tests/orders.test.js — Node 24 built-in runner, no framework install.
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import app from '../app.js';

describe('POST /api/orders', () => {
  let token;
  before(async () => {
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'pw' });
    token = res.body.token;
  });

  test('rejects an empty order with 400 and a useful body', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${token}`)
      .send({ items: [] })
      .expect(400);

    assert.equal(res.body.error, 'ValidationError');
    assert.ok(Array.isArray(res.body.errors));
  });

  test('creates a valid order with 201', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${token}`)
      .send({ items: [{ price: 10, qty: 2 }] })
      .expect(201);

    assert.equal(res.body.total, 20);
  });
});

// Run:  node --test
// Watch:  node --test --watch

When to use it

  • A team writes supertest integration tests that import the Express app object directly, firing real HTTP requests without starting an actual server process.
  • A developer mocks the database service layer in unit tests so controller logic can be tested in isolation without a running database.
  • A CI pipeline runs Jest with --coverage to verify that all routes and middleware branches have been exercised by the test suite.

More examples

Supertest route integration test

Uses supertest to make real HTTP requests against the imported Express app without binding to a port.

Example · js
const request = require('supertest');
const app = require('../app');

describe('GET /api/users', () => {
  it('returns 200 and an array', async () => {
    const res = await request(app).get('/api/users');
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });

  it('returns 401 when not authenticated', async () => {
    const res = await request(app).get('/api/users/me');
    expect(res.status).toBe(401);
  });
});

Mocking the service layer

Mocks the userService module with jest.mock() so the test exercises controller logic without touching the database.

Example · js
jest.mock('../services/userService');
const userService = require('../services/userService');
const request = require('supertest');
const app = require('../app');

describe('GET /users/:id', () => {
  it('returns user when found', async () => {
    userService.findById.mockResolvedValue({ id: '1', name: 'Alice' });
    const res = await request(app).get('/api/users/1');
    expect(res.status).toBe(200);
    expect(res.body.name).toBe('Alice');
  });

  it('returns 404 when not found', async () => {
    userService.findById.mockResolvedValue(null);
    const res = await request(app).get('/api/users/99');
    expect(res.status).toBe(404);
  });
});

Testing POST with body and auth header

Signs a test JWT and sends it as a Bearer token in the Authorization header alongside a JSON body to test an authenticated POST route.

Example · js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('../app');

const token = jwt.sign({ sub: 'user1', role: 'admin' }, process.env.JWT_SECRET);

describe('POST /api/products', () => {
  it('creates a product when authenticated', async () => {
    const res = await request(app)
      .post('/api/products')
      .set('Authorization', `Bearer ${token}`)
      .send({ name: 'Widget', price: 9.99 });
    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty('id');
  });
});

Discussion

  • Be the first to comment on this lesson.