Skip to main content

How to test an Express.js application?

Testing an Express.js application means writing automated checks for your routes, middleware, and handlers using Supertest to simulate HTTP requests against a live app instance, without running a real server on a port.

Theory

TL;DR

  • Supertest acts as a fake HTTP client: it spins up an in-memory server, sends requests, and lets you assert on status codes, headers, and response body
  • Separate app.js (no listen()) from server.js (starts the server) - this single structural change makes everything else work
  • Unit tests mock dependencies; integration tests run the full app stack through real middleware chains
  • Use Supertest for any route test; mock the database layer after you confirm the HTTP wiring is correct
  • Async handlers without a global error middleware will cause silent test failures

Quick example

js
// app.js - export the app, never call listen() here const express = require('express'); const app = express(); app.use(express.json()); app.get('/users', (req, res) => res.json({ users: [] })); module.exports = app; // app.test.js const request = require('supertest'); const app = require('./app'); test('GET /users returns 200', async () => { const res = await request(app).get('/users'); expect(res.status).toBe(200); // HTTP status expect(res.body).toEqual({ users: [] }); // response body });

Supertest imports the app object directly, starts an in-memory server on a random port, and closes it after the test. No port conflicts, no app.listen(3000) needed.

Key difference: unit tests vs. integration tests

Unit tests call handler functions directly with mocked req/res objects. Fast, but they skip Express's middleware stack entirely. Integration tests with Supertest run the full request through every app.use() in order, which is how production traffic actually flows. For most Express bugs, the integration test catches what the unit test misses.

When to use each approach

  • Single route behavior: Supertest, assert status and body
  • Middleware chain (auth, logging, validation): integration test with full pipeline
  • DB-dependent route: mock the service layer with jest.mock(), run Supertest on top
  • Protected routes: generate a real JWT in the test, pass it via the Authorization header
  • E2E user flows: Cypress or Playwright, not Supertest (Supertest has no browser)
  • CI/CD pipelines: Supertest only, no live ports needed

How Supertest works internally

When you call request(app), Supertest passes your Express instance to Node's http.createServer() and binds it to a random high port (like 52341). The request travels through Express's actual router and middleware stack, V8 executes every callback, and the response pipes back through the test agent's socket. After the assertion, Supertest calls server.close(). No network I/O, no port 3000, no leftover server process.

Test database strategy

ApproachSpeedIsolationRealism
Mock service layerFastestHighLow
In-memory SQLiteFastMediumMedium
Dedicated test DBSlowerLow (needs cleanup)High
Docker containerSlowestHighHighest

Start with mocking the service layer. Once you have confidence the HTTP wiring is correct, add an in-memory DB for the critical paths.

Common mistakes

Mistake: calling app.listen() inside app.js

js
// Wrong - app.js binds port, blocks tests const app = express(); app.listen(3000); module.exports = app; // Fix: split into app.js (no listen) + server.js // server.js const app = require('./app'); app.listen(3000);

When app.js calls listen(), tests either fail with EADDRINUSE or Supertest binds a second port and requests never hit the right instance.

Mistake: missing await on Supertest

js
// Wrong - test ends before response arrives test('GET /users', () => { request(app).get('/users').expect(200); // no await, no return }); // Fix test('GET /users', async () => { await request(app).get('/users').expect(200); });

The test passes silently because Jest marks it done before the Promise resolves. This is one of the most common sources of false positives in Express test suites.

Mistake: async handlers without a global error middleware

js
// app.js - crashes silently in tests app.get('/data', async (req, res) => { throw new Error('DB fail'); // unhandled, no response sent }); // Fix: add before module.exports app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });

Without the 4-argument error handler, Express 4 leaves the socket hanging and the test times out. About 40% of developers miss this specifically for async routes.

Mistake: not testing the full middleware chain for protected routes

js
// Wrong - auth middleware never runs const handler = require('./handlers/profile'); handler({ user: mockUser }, res, next); // Fix - test through the full chain const token = jwt.sign({ id: 1 }, process.env.JWT_SECRET); await request(app) .get('/profile') .set('Authorization', `Bearer ${token}`) .expect(200);

Mistake: real database in tests

Any test that writes to a shared DB makes your suite order-dependent and flaky. Use jest.mock('./db') or an in-memory alternative. A single dirty record from test A breaks test B in ways that are painful to debug.

Real-world usage

  • NestJS uses the same pattern: supertest(app.getHttpAdapter().getInstance())
  • Express + Prisma: mock the Prisma client with jest-mock-extended, run Supertest on routes
  • Strapi CMS: wraps Supertest for testing custom API controllers
  • Microservice setup: 60% unit tests on handlers, 30% integration with Supertest and mocked Redis, 10% contract tests with Pact.js

Follow-up questions

Q: Why use Supertest instead of raw http.request()?
A: Supertest chains .expect() assertions, auto-closes the server after each test, and handles JSON parsing automatically. Raw http.request() requires all of that manually.

Q: How do you test middleware in isolation?
A: Create a minimal Express app just for the test, mount only that middleware, and hit it with Supertest. This avoids interference from other middleware in the main app.

Q: How do you mock external HTTP calls inside handlers?
A: Use nock: nock('https://api.stripe.com').post('/charges').reply(200, { id: 'ch_1' }). It intercepts outgoing requests at the Node http layer before they reach the network.

Q: What is the difference between request(app) and request(server)?
A: request(app) creates a temporary in-memory server per test. request(server) connects to an already-running server on a real port. Use request(app) for integration tests and request(server) only if you need persistent state across a full test suite.

Q: In a microservice with 100+ routes and Redis caching, how do you structure tests for 90% coverage without making each test slow?
A: Layer it: 60% unit tests on handlers (Jest, no HTTP), 30% integration tests (Supertest with ioredis-mock), 10% contract tests (Pact.js for service boundaries). Run with --maxWorkers=4 to parallelize. Seed test data with factory functions, not fixtures. This keeps median integration test time under 50ms.

Examples

Basic: testing a public route

js
// app.js const express = require('express'); const app = express(); app.use(express.json()); app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); }); module.exports = app; // health.test.js const request = require('supertest'); const app = require('./app'); test('GET /health returns status ok', async () => { const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.status).toBe('ok'); expect(typeof res.body.uptime).toBe('number'); });

A health check endpoint is usually the first test you write. It confirms Supertest is wired correctly and the app module exports cleanly.

Intermediate: testing middleware and route together

js
// routes/users.js const router = require('express').Router(); // Timestamp middleware - runs before every handler in this router router.use((req, res, next) => { req.timestamp = Date.now(); next(); }); router.get('/', (req, res) => { res.json({ users: [], requestedAt: req.timestamp }); }); module.exports = router; // test/users.test.js const request = require('supertest'); const app = require('../app'); describe('Users API', () => { test('GET /users includes middleware timestamp', async () => { const res = await request(app).get('/users'); expect(res.status).toBe(200); expect(res.body.requestedAt).toBeDefined(); // middleware ran expect(res.body.requestedAt).toBeGreaterThan(0); // real timestamp expect(Array.isArray(res.body.users)).toBe(true); }); });

This test confirms the middleware and route handler work together. A unit test on the handler alone would never catch a broken next() call in the middleware.

Advanced: mocked service layer with auth and error handling

js
// Mock the DB service before importing the app jest.mock('../services/users.service', () => ({ findById: jest.fn(), create: jest.fn(), })); const request = require('supertest'); const jwt = require('jsonwebtoken'); const app = require('../app'); const usersService = require('../services/users.service'); const token = jwt.sign({ id: 1, role: 'admin' }, 'test-secret'); describe('POST /users', () => { test('creates user when authenticated', async () => { usersService.create.mockResolvedValue({ id: 5, name: 'Alice' }); const res = await request(app) .post('/users') .set('Authorization', `Bearer ${token}`) .send({ name: 'Alice', email: 'alice@example.com' }); expect(res.status).toBe(201); expect(res.body.name).toBe('Alice'); expect(usersService.create).toHaveBeenCalledWith({ name: 'Alice', email: 'alice@example.com', }); }); test('returns 500 when service throws', async () => { usersService.create.mockRejectedValue(new Error('DB connection lost')); const res = await request(app) .post('/users') .set('Authorization', `Bearer ${token}`) .send({ name: 'Bob', email: 'bob@example.com' }); expect(res.status).toBe(500); expect(res.body).toHaveProperty('error'); }); });

The mock replaces the real DB call, so the test runs in under 10ms and never touches a database. The second test verifies the global error middleware catches async failures and returns a proper 500 instead of a hanging socket.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?