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(nolisten()) fromserver.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
// 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
Authorizationheader - 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
| Approach | Speed | Isolation | Realism |
|---|---|---|---|
| Mock service layer | Fastest | High | Low |
| In-memory SQLite | Fast | Medium | Medium |
| Dedicated test DB | Slower | Low (needs cleanup) | High |
| Docker container | Slowest | High | Highest |
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
// 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
// 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
// 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
// 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
// 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.