Testing Express Error Handling and Custom Middleware

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:

  1. Error classification — the right handler catches the right error type
  2. Status code correctness — each error class maps to the right HTTP status
  3. Response shape consistency — every error response looks the same to clients
  4. 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.

Read more

Start now free