Express.js Testing Guide: Unit, Integration, and E2E Tests with Supertest
Express.js is the most widely used Node.js web framework. It's also the most widely under-tested. The flexibility that makes Express great — you can do anything — also means there's no built-in testing story.
This guide covers the complete Express testing stack: unit tests for route handlers, integration tests with Supertest, and what to do when tests go green but production still breaks.
The Express Testing Stack
You need three things to test Express properly:
- Jest (or Vitest) — test runner and assertion library
- Supertest — HTTP testing without starting a real server
- A test database — SQLite in-memory or a dedicated test Postgres instance
npm install --save-dev jest supertest @types/supertest
# or with TypeScript:
npm install --save-dev jest ts-jest supertest @types/jest @types/supertestStructuring Testable Express Apps
The most common Express testing mistake is attaching app.listen() inside your main module. Supertest needs to call app without it starting a server.
// src/app.js — no listen() call here
const express = require('express');
const userRoutes = require('./routes/users');
const authRoutes = require('./routes/auth');
const { errorHandler } = require('./middleware/error');
const app = express();
app.use(express.json());
app.use('/auth', authRoutes);
app.use('/users', userRoutes);
app.use(errorHandler);
module.exports = app;// src/server.js — listen() here
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Running on port ${PORT}`));Supertest imports app, not server. Your tests never touch port 3000.
Testing Routes with Supertest
// tests/routes/users.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db');
beforeAll(async () => await db.migrate.latest());
afterAll(async () => await db.destroy());
beforeEach(async () => await db.migrate.rollback().then(() => db.migrate.latest()));
describe('GET /users', () => {
it('returns 200 with empty array when no users', async () => {
const res = await request(app).get('/users');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns list of users', async () => {
await db('users').insert([
{ email: 'alice@test.com', name: 'Alice' },
{ email: 'bob@test.com', name: 'Bob' },
]);
const res = await request(app).get('/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].email).toBe('alice@test.com');
});
});
describe('GET /users/:id', () => {
it('returns user when found', async () => {
const [id] = await db('users').insert({ email: 'alice@test.com' }).returning('id');
const res = await request(app).get(`/users/${id}`);
expect(res.status).toBe(200);
expect(res.body.email).toBe('alice@test.com');
});
it('returns 404 when user not found', async () => {
const res = await request(app).get('/users/99999');
expect(res.status).toBe(404);
expect(res.body.message).toMatch(/not found/i);
});
});
describe('POST /users', () => {
it('creates user with valid data', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'new@test.com', name: 'Carol' });
expect(res.status).toBe(201);
expect(res.body.email).toBe('new@test.com');
expect(res.body.id).toBeDefined();
});
it('returns 400 with invalid email', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'not-an-email', name: 'Dave' });
expect(res.status).toBe(400);
expect(res.body.errors).toBeDefined();
});
it('returns 409 on duplicate email', async () => {
await db('users').insert({ email: 'existing@test.com' });
const res = await request(app)
.post('/users')
.send({ email: 'existing@test.com', name: 'Duplicate' });
expect(res.status).toBe(409);
});
});Testing Middleware
Test middleware by mounting it on a minimal Express app:
// tests/middleware/auth.test.js
const request = require('supertest');
const express = require('express');
const jwt = require('jsonwebtoken');
const { authenticate } = require('../../src/middleware/auth');
// Create a minimal test app for middleware testing
function buildTestApp() {
const app = express();
app.use(authenticate);
app.get('/test', (req, res) => res.json({ user: req.user }));
return app;
}
describe('authenticate middleware', () => {
it('returns 401 without Authorization header', async () => {
const res = await request(buildTestApp()).get('/test');
expect(res.status).toBe(401);
});
it('returns 401 with malformed token', async () => {
const res = await request(buildTestApp())
.get('/test')
.set('Authorization', 'Bearer not.a.real.token');
expect(res.status).toBe(401);
});
it('sets req.user with valid token', async () => {
const token = jwt.sign(
{ sub: '1', email: 'user@test.com' },
process.env.JWT_SECRET || 'test-secret'
);
const res = await request(buildTestApp())
.get('/test')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.user.email).toBe('user@test.com');
});
it('returns 401 with expired token', async () => {
const token = jwt.sign(
{ sub: '1' },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: '-1s' }
);
const res = await request(buildTestApp())
.get('/test')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(401);
});
});Testing Error Handlers
Express error handlers (four-argument functions) need explicit testing:
// tests/middleware/error.test.js
const request = require('supertest');
const express = require('express');
const { errorHandler } = require('../../src/middleware/error');
function buildErrorApp(throwFn) {
const app = express();
app.get('/trigger', (req, res, next) => {
try {
throwFn();
} catch (err) {
next(err);
}
});
app.use(errorHandler);
return app;
}
describe('errorHandler', () => {
it('returns 500 for generic errors', async () => {
const app = buildErrorApp(() => { throw new Error('Something went wrong'); });
const res = await request(app).get('/trigger');
expect(res.status).toBe(500);
expect(res.body.message).toBe('Internal server error');
});
it('returns correct status for HttpError', async () => {
const { HttpError } = require('../../src/errors');
const app = buildErrorApp(() => { throw new HttpError(422, 'Unprocessable entity'); });
const res = await request(app).get('/trigger');
expect(res.status).toBe(422);
expect(res.body.message).toBe('Unprocessable entity');
});
it('does not expose stack traces in production', async () => {
process.env.NODE_ENV = 'production';
const app = buildErrorApp(() => { throw new Error('Secret error details'); });
const res = await request(app).get('/trigger');
expect(res.body.stack).toBeUndefined();
process.env.NODE_ENV = 'test';
});
});Unit Testing Route Handlers
For complex route handler logic, unit test the handler function directly:
// tests/handlers/checkout.test.js
const { processCheckout } = require('../../src/handlers/checkout');
const stripeService = require('../../src/services/stripe');
jest.mock('../../src/services/stripe');
describe('processCheckout', () => {
const mockReq = (body) => ({ body, user: { id: 1 } });
const mockRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
const mockNext = jest.fn();
it('creates payment intent and returns client secret', async () => {
stripeService.createPaymentIntent.mockResolvedValue({
client_secret: 'pi_test_secret_123',
});
const req = mockReq({ amount: 5000, currency: 'usd' });
const res = mockRes();
await processCheckout(req, res, mockNext);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ client_secret: 'pi_test_secret_123' })
);
});
it('calls next with error when Stripe fails', async () => {
stripeService.createPaymentIntent.mockRejectedValue(new Error('Stripe error'));
const req = mockReq({ amount: 5000, currency: 'usd' });
const res = mockRes();
await processCheckout(req, res, mockNext);
expect(mockNext).toHaveBeenCalledWith(expect.any(Error));
});
});Testing File Uploads
Supertest handles multipart form data for file upload routes:
// tests/routes/uploads.test.js
const path = require('path');
describe('POST /uploads', () => {
it('accepts valid image upload', async () => {
const res = await request(app)
.post('/uploads')
.set('Authorization', `Bearer ${authToken}`)
.attach('file', path.join(__dirname, '../fixtures/test-image.png'))
.field('description', 'Profile photo');
expect(res.status).toBe(201);
expect(res.body.url).toMatch(/^https?:\/\//);
});
it('rejects non-image files', async () => {
const res = await request(app)
.post('/uploads')
.set('Authorization', `Bearer ${authToken}`)
.attach('file', path.join(__dirname, '../fixtures/test.pdf'));
expect(res.status).toBe(415);
});
});What Tests Miss
A green Supertest suite means your Express logic is correct. It doesn't mean your deployed app is healthy.
Common production Express failures:
- Memory leaks from unclosed DB connections — a route that creates a connection but doesn't release it properly passes tests (they're short-lived) but causes OOM in production
- CORS misconfiguration — your
cors()middleware allows your test origin, blocks your production frontend - Helmet security headers — removed
helmet()or misconfigured CSP breaks certain features in production browsers - Trust proxy —
req.ipreturns wrong values behind nginx/load balancer whentrust proxyis misconfigured - Graceful shutdown — your app doesn't drain connections properly, causing 502 errors during deploys
Production Monitoring for Express APIs
HelpMeTest runs behavioral tests against your deployed Express API continuously:
Test: API basic health
GET /health → 200, { status: "ok" }
POST /users → 201, body has id
GET /users/:id → 200, correct user returned
POST /auth/login → 200, returns token
All response times under 500msSet tests to run every 5 minutes. If your database connection pool exhausts, your JWT secret rotates without token invalidation, or a middleware update breaks auth, you know before users do.
Free tier: 10 tests, unlimited health checks. Try HelpMeTest →
Express Testing Checklist
- App exported separately from server (
app.jsvsserver.js) - Supertest tests for every route: 200, 201, 400, 404, 401, 403, 409
- Middleware tested in isolation with minimal Express app
- Auth middleware: unauthenticated, expired token, insufficient role
- Error handler tested: generic errors, HttpErrors, stack trace hiding in production
- Request validation tested: missing fields, wrong types, boundary values
- File uploads tested if applicable
- Database state verified after mutations (not just response code)
- Production monitoring: behavioral tests running continuously
Supertest is one of the best HTTP testing libraries in any ecosystem. Use it.