Testing Express Error Handling and Custom Middleware
Error handling is the part of an Express app most developers test last — and it's the part that matters most when things go wrong in production. A well-tested error handling stack means your API never leaks stack traces to clients, always returns machine-readable error formats, and handles async failures gracefully. This post walks through testing every layer of Express error handling.
Understanding the Express Error Handler Stack
Express error handling has a specific contract: error middleware takes four arguments (err, req, res, next). The order of these middleware registrations matters — generic handlers must come after specific ones, and all error handlers must be registered after your routes.
// app.js — the full error handling stack
const express = require('express');
const { NotFoundError, ValidationError, AppError } = require('./errors');
function createApp() {
const app = express();
app.use(express.json());
// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/products', require('./routes/products'));
// 404 handler — catches routes that didn't match
app.use((req, res, next) => {
next(new NotFoundError(`Cannot ${req.method} ${req.path}`));
});
// Validation error handler
app.use((err, req, res, next) => {
if (err instanceof ValidationError) {
return res.status(422).json({
error: 'Validation Failed',
code: 'VALIDATION_ERROR',
errors: err.fields,
});
}
next(err);
});
// Application error handler
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({
error: err.message,
code: err.code,
});
}
next(err);
});
// Generic error handler — catches everything else
app.use((err, req, res, next) => {
const isDev = process.env.NODE_ENV === 'development';
res.status(500).json({
error: 'Internal Server Error',
code: 'INTERNAL_ERROR',
...(isDev && { stack: err.stack, message: err.message }),
});
});
return app;
}
module.exports = { createApp };Defining Custom Error Classes
Before testing error handling, you need well-structured error classes. These make the test assertions clean and specific:
// errors/index.js
class AppError extends Error {
constructor(message, status, code) {
super(message);
this.name = this.constructor.name;
this.status = status;
this.code = code;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(message, fields = []) {
super(message, 422, 'VALIDATION_ERROR');
this.fields = fields;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403, 'FORBIDDEN');
}
}
class ConflictError extends AppError {
constructor(message) {
super(message, 409, 'CONFLICT');
}
}
module.exports = {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
ForbiddenError,
ConflictError,
};Testing the 404 Handler
The 404 handler is straightforward to test — request a route that doesn't exist:
// __tests__/error-handling.test.js
const request = require('supertest');
const { createApp } = require('../app');
describe('404 Handler', () => {
let app;
beforeAll(() => {
app = createApp();
});
it('returns 404 for unknown GET routes', async () => {
const response = await request(app).get('/api/nonexistent-route');
expect(response.status).toBe(404);
expect(response.body).toMatchObject({
error: expect.stringContaining('Cannot GET /api/nonexistent-route'),
code: 'NOT_FOUND',
});
});
it('returns 404 for unknown POST routes', async () => {
const response = await request(app)
.post('/api/does-not-exist')
.send({ data: 'test' });
expect(response.status).toBe(404);
expect(response.body.code).toBe('NOT_FOUND');
});
it('includes the method and path in the error message', async () => {
const response = await request(app).delete('/api/unknown/123');
expect(response.status).toBe(404);
expect(response.body.error).toContain('DELETE');
expect(response.body.error).toContain('/api/unknown/123');
});
it('returns JSON content type for 404 errors', async () => {
const response = await request(app).get('/api/missing');
expect(response.headers['content-type']).toMatch(/application\/json/);
});
});Testing the 500 Handler and Stack Trace Leakage
The 500 handler has a critical security property: it must never expose stack traces in production. Test both environments:
describe('500 Handler', () => {
it('returns 500 for unhandled errors', async () => {
const app = createApp();
// Add a route that throws synchronously
app.get('/test/crash', (req, res) => {
throw new Error('Simulated crash');
});
// Re-register error handlers after adding the route
// (In a real app, use the factory pattern so this is automatic)
app.use((err, req, res, next) => {
res.status(500).json({ error: 'Internal Server Error', code: 'INTERNAL_ERROR' });
});
const response = await request(app).get('/test/crash');
expect(response.status).toBe(500);
expect(response.body.code).toBe('INTERNAL_ERROR');
});
it('does not expose stack traces in production', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const app = createApp();
app.get('/test/crash', (req, res) => {
throw new Error('Secret internal error with db credentials');
});
const response = await request(app).get('/test/crash');
expect(response.status).toBe(500);
expect(response.body.stack).toBeUndefined();
expect(response.body.message).toBeUndefined();
expect(JSON.stringify(response.body)).not.toContain('credentials');
process.env.NODE_ENV = originalEnv;
});
it('exposes error details in development', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const app = createApp();
app.get('/test/crash', (req, res) => {
throw new Error('Debug this crash');
});
const response = await request(app).get('/test/crash');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Debug this crash');
expect(response.body.stack).toBeDefined();
process.env.NODE_ENV = originalEnv;
});
});Testing Validation Error Handling
Validation errors deserve their own test suite because they have a richer response shape — they need to tell the client exactly which fields failed and why:
// routes/users.js
const express = require('express');
const { ValidationError } = require('../errors');
const { validateUser } = require('../validators/user');
const router = express.Router();
router.post('/', async (req, res, next) => {
try {
const errors = validateUser(req.body);
if (errors.length > 0) {
throw new ValidationError('Invalid user data', errors);
}
const user = await req.app.locals.userService.create(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
});
module.exports = router;// validators/user.js
function validateUser(data) {
const errors = [];
if (!data.email) {
errors.push({ field: 'email', message: 'Email is required' });
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push({ field: 'email', message: 'Email format is invalid' });
}
if (!data.name || data.name.trim().length < 2) {
errors.push({ field: 'name', message: 'Name must be at least 2 characters' });
}
if (!data.password || data.password.length < 8) {
errors.push({ field: 'password', message: 'Password must be at least 8 characters' });
}
return errors;
}
module.exports = { validateUser };// __tests__/validation-errors.test.js
describe('Validation Error Handling', () => {
it('returns 422 with field-level errors for invalid data', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'A', password: 'short' });
expect(response.status).toBe(422);
expect(response.body).toMatchObject({
error: 'Validation Failed',
code: 'VALIDATION_ERROR',
});
const fieldNames = response.body.errors.map(e => e.field);
expect(fieldNames).toContain('email');
expect(fieldNames).toContain('name');
expect(fieldNames).toContain('password');
});
it('returns all validation errors at once, not just the first', async () => {
const response = await request(app)
.post('/api/users')
.send({}); // all fields missing
expect(response.status).toBe(422);
expect(response.body.errors.length).toBeGreaterThanOrEqual(3);
});
it('returns 422 for missing required body fields', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'valid@example.com' }); // missing name and password
expect(response.status).toBe(422);
const fields = response.body.errors.map(e => e.field);
expect(fields).not.toContain('email'); // email was valid
expect(fields).toContain('name');
expect(fields).toContain('password');
});
it('provides human-readable messages for each field error', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'bad', name: 'X', password: '1234567' });
expect(response.status).toBe(422);
response.body.errors.forEach(err => {
expect(err.message).toBeDefined();
expect(typeof err.message).toBe('string');
expect(err.message.length).toBeGreaterThan(0);
});
});
});Testing Async Error Propagation
Async route handlers are where most error handling bugs hide. Without the asyncHandler wrapper, rejected promises don't reach Express error middleware:
// middleware/asyncHandler.js
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = { asyncHandler };// routes/orders.js
const { asyncHandler } = require('../middleware/asyncHandler');
const { NotFoundError, ForbiddenError } = require('../errors');
router.get('/:id', asyncHandler(async (req, res) => {
const order = await orderService.findById(req.params.id);
if (!order) {
throw new NotFoundError(`Order ${req.params.id} not found`);
}
if (order.userId !== req.user.id) {
throw new ForbiddenError('You can only view your own orders');
}
res.json(order);
}));// __tests__/async-errors.test.js
describe('Async Error Propagation', () => {
let orderService;
let app;
beforeEach(() => {
orderService = {
findById: jest.fn(),
};
app = createApp({ orderService });
// Simulate authenticated user
app.use((req, res, next) => {
req.user = { id: 'user-1' };
next();
});
});
it('propagates async NotFoundError through error middleware', async () => {
orderService.findById.mockResolvedValue(null);
const response = await request(app).get('/api/orders/missing-id');
expect(response.status).toBe(404);
expect(response.body.code).toBe('NOT_FOUND');
expect(response.body.error).toContain('missing-id');
});
it('propagates async ForbiddenError for unauthorized access', async () => {
orderService.findById.mockResolvedValue({ id: '123', userId: 'other-user' });
const response = await request(app).get('/api/orders/123');
expect(response.status).toBe(403);
expect(response.body.code).toBe('FORBIDDEN');
});
it('propagates unexpected async errors as 500', async () => {
orderService.findById.mockRejectedValue(new Error('Database timeout'));
const response = await request(app).get('/api/orders/456');
expect(response.status).toBe(500);
expect(response.body.code).toBe('INTERNAL_ERROR');
});
it('does not swallow errors thrown inside then() callbacks', async () => {
// This pattern is a common async error trap
orderService.findById.mockResolvedValue({ id: '123', userId: 'user-1' });
// Add a route that throws inside a then()
app.get('/test/then-throw', asyncHandler(async (req, res) => {
await Promise.resolve().then(() => {
throw new Error('Error inside then');
});
}));
const response = await request(app).get('/test/then-throw');
expect(response.status).toBe(500);
});
});Testing the Error Middleware Stack Ordering
The most subtle error handling bug is when the wrong handler catches an error because middleware is registered in the wrong order:
// __tests__/error-middleware-order.test.js
describe('Error Middleware Ordering', () => {
it('validation errors are caught by validation handler, not generic handler', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid' });
// Must be 422, not 500
expect(response.status).toBe(422);
expect(response.body.code).toBe('VALIDATION_ERROR');
// Validation handler produces 'errors' array; generic handler does not
expect(Array.isArray(response.body.errors)).toBe(true);
});
it('AppError subclasses use their own status code', async () => {
// ConflictError should produce 409, not 422 or 500
const app = createApp();
const { ConflictError } = require('../errors');
app.post('/test/conflict', (req, res, next) => {
next(new ConflictError('Email already registered'));
});
const response = await request(app)
.post('/test/conflict')
.send({});
expect(response.status).toBe(409);
expect(response.body.code).toBe('CONFLICT');
});
it('non-AppError instances fall through to generic 500 handler', async () => {
const app = createApp();
app.get('/test/native-error', (req, res, next) => {
next(new TypeError('Cannot read property of undefined'));
});
const response = await request(app).get('/test/native-error');
// TypeError is not an AppError — must not leak the message in production
expect(response.status).toBe(500);
expect(response.body.code).toBe('INTERNAL_ERROR');
});
});Testing Error Response Consistency
A consistent error response format is a promise to your API consumers. A test that enforces this format protects that promise:
// __tests__/error-response-format.test.js
const ERROR_SHAPE = {
error: expect.any(String),
code: expect.any(String),
};
describe('Error Response Format Consistency', () => {
const errorScenarios = [
{ path: '/api/unknown-route', method: 'get', expectedStatus: 404 },
{ path: '/api/users', method: 'post', body: {}, expectedStatus: 422 },
{ path: '/api/users/does-not-exist', method: 'get', expectedStatus: 404 },
];
test.each(errorScenarios)(
'$method $path returns consistent error shape',
async ({ path, method, body, expectedStatus }) => {
const req = request(app)[method](path);
if (body) req.send(body);
const response = await req;
expect(response.status).toBe(expectedStatus);
expect(response.body).toMatchObject(ERROR_SHAPE);
expect(response.headers['content-type']).toMatch(/application\/json/);
}
);
});Key Takeaways
Testing Express error handling thoroughly requires covering four distinct concerns:
- Error classification — the right handler catches the right error type
- Status code correctness — each error class maps to the right HTTP status
- Response shape consistency — every error response looks the same to clients
- Information security — stack traces and internal messages never reach production clients
The middleware ordering tests are particularly valuable — they're the only automated way to catch the "wrong handler caught this error" class of bugs before production does.