Express Middleware Testing Patterns with Jest and Supertest

Express Middleware Testing Patterns with Jest and Supertest

Testing Express middleware is deceptively tricky. Basic Supertest tutorials show you how to fire a request and check the status code. But real middleware testing — the kind that catches actual bugs — requires understanding how middleware chains compose, how dependencies get injected, and how errors propagate through layers you didn't write. This post covers advanced patterns that go beyond the "hello world" examples.

Setting Up a Proper Test Environment

Before diving into patterns, set up a clean application factory. The most common mistake is importing a started server rather than creating a fresh Express instance for each test suite:

// app.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

function createApp(dependencies = {}) {
  const app = express();
  
  app.use(express.json());
  app.use(helmet());
  app.use(cors());
  
  // Inject dependencies — critical for testability
  app.locals.db = dependencies.db;
  app.locals.cache = dependencies.cache;
  app.locals.logger = dependencies.logger || console;
  
  return app;
}

module.exports = { createApp };
// test/setup.js
const { createApp } = require('../app');

function buildTestApp(middlewares = [], dependencies = {}) {
  const app = createApp(dependencies);
  middlewares.forEach(mw => app.use(mw));
  return app;
}

module.exports = { buildTestApp };

This factory pattern lets you compose exactly the middleware you want to test without the noise of the full application stack.

Testing Custom Middleware in Isolation

The core insight: test middleware functions as units before testing them in integration. A middleware is just a function — test it directly first.

// middleware/requestId.js
const { v4: uuidv4 } = require('uuid');

function requestIdMiddleware(req, res, next) {
  req.id = req.headers['x-request-id'] || uuidv4();
  res.setHeader('X-Request-Id', req.id);
  next();
}

module.exports = requestIdMiddleware;
// middleware/__tests__/requestId.test.js
const requestIdMiddleware = require('../requestId');

describe('requestIdMiddleware', () => {
  let req, res, next;

  beforeEach(() => {
    req = { headers: {} };
    res = {
      setHeader: jest.fn(),
    };
    next = jest.fn();
  });

  it('generates a UUID when no request ID header is present', () => {
    requestIdMiddleware(req, res, next);
    
    expect(req.id).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
    );
    expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.id);
    expect(next).toHaveBeenCalledOnce();
  });

  it('uses the incoming request ID when header is present', () => {
    req.headers['x-request-id'] = 'my-trace-id-123';
    requestIdMiddleware(req, res, next);
    
    expect(req.id).toBe('my-trace-id-123');
    expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', 'my-trace-id-123');
  });

  it('calls next() exactly once', () => {
    requestIdMiddleware(req, res, next);
    expect(next).toHaveBeenCalledTimes(1);
    expect(next).toHaveBeenCalledWith(); // called with no arguments — not an error
  });
});

Direct unit testing like this catches logic bugs without HTTP overhead. Once the unit tests pass, layer in integration tests.

Testing Middleware Order and Composition

Middleware order matters enormously in Express. A rate limiter must run before authentication. CORS headers must be set before the response is sent. Here's how to verify that order is preserved:

// middleware/__tests__/chain.integration.test.js
const request = require('supertest');
const express = require('express');

function createOrderTrackingMiddleware(name, store) {
  return (req, res, next) => {
    store.push(name);
    next();
  };
}

describe('Middleware Chain Order', () => {
  it('executes middleware in registration order', async () => {
    const executionOrder = [];
    const app = express();
    
    app.use(createOrderTrackingMiddleware('first', executionOrder));
    app.use(createOrderTrackingMiddleware('second', executionOrder));
    app.use(createOrderTrackingMiddleware('third', executionOrder));
    app.get('/test', (req, res) => res.json({ order: executionOrder }));
    
    const response = await request(app).get('/test');
    
    expect(response.status).toBe(200);
    expect(response.body.order).toEqual(['first', 'second', 'third']);
  });

  it('short-circuits the chain when middleware sends a response', async () => {
    const executionOrder = [];
    const app = express();
    
    app.use(createOrderTrackingMiddleware('before', executionOrder));
    app.use((req, res, next) => {
      executionOrder.push('blocker');
      res.status(403).json({ error: 'Forbidden' });
      // note: does NOT call next()
    });
    app.use(createOrderTrackingMiddleware('after', executionOrder));
    app.get('/test', (req, res) => res.json({ order: executionOrder }));
    
    const response = await request(app).get('/test');
    
    expect(response.status).toBe(403);
    expect(executionOrder).toEqual(['before', 'blocker']);
    expect(executionOrder).not.toContain('after');
  });
});

Mocking Dependencies Inside Middleware

Real middleware often talks to databases, caches, or external services. You need to inject controlled fakes:

// middleware/rateLimit.js
function createRateLimitMiddleware(cache, options = {}) {
  const { maxRequests = 100, windowMs = 60000 } = options;
  
  return async (req, res, next) => {
    const key = `rate:${req.ip}`;
    const current = await cache.incr(key);
    
    if (current === 1) {
      await cache.expire(key, windowMs / 1000);
    }
    
    res.setHeader('X-RateLimit-Limit', maxRequests);
    res.setHeader('X-RateLimit-Remaining', Math.max(0, maxRequests - current));
    
    if (current > maxRequests) {
      return res.status(429).json({ error: 'Too Many Requests' });
    }
    
    next();
  };
}

module.exports = { createRateLimitMiddleware };
// middleware/__tests__/rateLimit.test.js
const request = require('supertest');
const express = require('express');
const { createRateLimitMiddleware } = require('../rateLimit');

describe('Rate Limit Middleware', () => {
  function buildCacheMock(initialCount = 0) {
    let count = initialCount;
    return {
      incr: jest.fn().mockImplementation(() => Promise.resolve(++count)),
      expire: jest.fn().mockResolvedValue(true),
      _getCount: () => count,
    };
  }

  function buildApp(cache, options) {
    const app = express();
    app.use((req, res, next) => { req.ip = '127.0.0.1'; next(); });
    app.use(createRateLimitMiddleware(cache, options));
    app.get('/test', (req, res) => res.json({ ok: true }));
    return app;
  }

  it('allows requests under the limit', async () => {
    const cache = buildCacheMock(0);
    const app = buildApp(cache, { maxRequests: 5 });
    
    const response = await request(app).get('/test');
    
    expect(response.status).toBe(200);
    expect(response.headers['x-ratelimit-limit']).toBe('5');
    expect(response.headers['x-ratelimit-remaining']).toBe('4');
  });

  it('blocks requests over the limit with 429', async () => {
    const cache = buildCacheMock(5); // already at 5 requests
    const app = buildApp(cache, { maxRequests: 5 });
    
    const response = await request(app).get('/test');
    
    expect(response.status).toBe(429);
    expect(response.body).toEqual({ error: 'Too Many Requests' });
    expect(response.headers['x-ratelimit-remaining']).toBe('0');
  });

  it('sets cache expiry only on first request', async () => {
    const cache = buildCacheMock(0);
    const app = buildApp(cache, { maxRequests: 10, windowMs: 30000 });
    
    await request(app).get('/test');
    
    expect(cache.expire).toHaveBeenCalledWith('rate:127.0.0.1', 30);
    expect(cache.expire).toHaveBeenCalledTimes(1);
  });

  it('does not set expiry on subsequent requests', async () => {
    const cache = buildCacheMock(3); // count starts at 3, will become 4
    const app = buildApp(cache, { maxRequests: 10 });
    
    await request(app).get('/test');
    
    expect(cache.expire).not.toHaveBeenCalled();
  });
});

Testing Async Middleware and Error Propagation

Async middleware is where bugs hide. An unhandled promise rejection won't call next(err) automatically in older Express versions — you need to wrap or handle it explicitly.

// middleware/asyncWrapper.js
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

module.exports = { asyncHandler };
// middleware/userLoader.js
const { asyncHandler } = require('./asyncWrapper');

function createUserLoaderMiddleware(userService) {
  return asyncHandler(async (req, res, next) => {
    const userId = req.params.userId || req.headers['x-user-id'];
    if (!userId) return next();
    
    const user = await userService.findById(userId);
    if (!user) {
      const err = new Error('User not found');
      err.status = 404;
      return next(err);
    }
    
    req.user = user;
    next();
  });
}

module.exports = { createUserLoaderMiddleware };
// middleware/__tests__/userLoader.test.js
const request = require('supertest');
const express = require('express');
const { createUserLoaderMiddleware } = require('../userLoader');

describe('User Loader Middleware', () => {
  function buildApp(userService) {
    const app = express();
    app.get('/users/:userId/profile', 
      createUserLoaderMiddleware(userService),
      (req, res) => res.json({ user: req.user })
    );
    
    // Error handler
    app.use((err, req, res, next) => {
      res.status(err.status || 500).json({ error: err.message });
    });
    
    return app;
  }

  it('attaches user to req when found', async () => {
    const userService = {
      findById: jest.fn().mockResolvedValue({ id: '42', name: 'Alice' }),
    };
    const app = buildApp(userService);
    
    const response = await request(app).get('/users/42/profile');
    
    expect(response.status).toBe(200);
    expect(response.body.user).toEqual({ id: '42', name: 'Alice' });
    expect(userService.findById).toHaveBeenCalledWith('42');
  });

  it('passes 404 error to next() when user is not found', async () => {
    const userService = {
      findById: jest.fn().mockResolvedValue(null),
    };
    const app = buildApp(userService);
    
    const response = await request(app).get('/users/999/profile');
    
    expect(response.status).toBe(404);
    expect(response.body).toEqual({ error: 'User not found' });
  });

  it('propagates unexpected service errors to error handler', async () => {
    const userService = {
      findById: jest.fn().mockRejectedValue(new Error('Database connection lost')),
    };
    const app = buildApp(userService);
    
    const response = await request(app).get('/users/42/profile');
    
    expect(response.status).toBe(500);
    expect(response.body).toEqual({ error: 'Database connection lost' });
  });

  it('skips user loading when no userId is provided', async () => {
    const userService = {
      findById: jest.fn(),
    };
    const app = buildApp(userService);
    
    // Route without :userId param — middleware should pass through
    app.get('/public', createUserLoaderMiddleware(userService), (req, res) => {
      res.json({ user: req.user || null });
    });
    
    const response = await request(app).get('/public');
    
    expect(response.status).toBe(200);
    expect(response.body.user).toBeNull();
    expect(userService.findById).not.toHaveBeenCalled();
  });
});

Testing Middleware That Modifies Request and Response

Some middleware transforms data on the way in or out. Testing transformation correctness is different from testing side effects:

// middleware/responseTransformer.js
function responseTransformer(req, res, next) {
  const originalJson = res.json.bind(res);
  
  res.json = function(data) {
    const wrapped = {
      success: res.statusCode < 400,
      data: data,
      timestamp: new Date().toISOString(),
      requestId: req.id,
    };
    return originalJson(wrapped);
  };
  
  next();
}

module.exports = responseTransformer;
// middleware/__tests__/responseTransformer.test.js
const request = require('supertest');
const express = require('express');
const responseTransformer = require('../responseTransformer');

describe('Response Transformer Middleware', () => {
  let app;

  beforeEach(() => {
    app = express();
    app.use((req, res, next) => { req.id = 'test-request-id'; next(); });
    app.use(responseTransformer);
  });

  it('wraps successful responses with envelope', async () => {
    app.get('/test', (req, res) => res.json({ name: 'Alice' }));
    
    const response = await request(app).get('/test');
    
    expect(response.status).toBe(200);
    expect(response.body).toMatchObject({
      success: true,
      data: { name: 'Alice' },
      requestId: 'test-request-id',
    });
    expect(response.body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });

  it('marks error responses as success: false', async () => {
    app.get('/error', (req, res) => res.status(400).json({ message: 'Bad input' }));
    
    const response = await request(app).get('/error');
    
    expect(response.status).toBe(400);
    expect(response.body.success).toBe(false);
    expect(response.body.data).toEqual({ message: 'Bad input' });
  });

  it('does not double-wrap already-wrapped responses', async () => {
    app.get('/test', (req, res) => res.json({ name: 'Bob' }));
    
    const response = await request(app).get('/test');
    
    // Make sure we don't have data.data nesting
    expect(response.body.data.data).toBeUndefined();
  });
});

Testing Middleware Combinations with jest.spyOn

When you need to verify that middleware calls specific functions in a specific sequence, jest.spyOn combined with call order tracking is powerful:

// middleware/__tests__/logging.test.js
const request = require('supertest');
const express = require('express');

describe('Logging Middleware Integration', () => {
  it('logs request and response with correct timing', async () => {
    const logger = {
      info: jest.fn(),
      error: jest.fn(),
    };
    
    const calls = [];
    logger.info.mockImplementation((msg, data) => {
      calls.push({ msg, data, time: Date.now() });
    });
    
    const loggingMiddleware = (req, res, next) => {
      const start = Date.now();
      logger.info('request received', { method: req.method, path: req.path });
      
      res.on('finish', () => {
        logger.info('request completed', {
          status: res.statusCode,
          duration: Date.now() - start,
        });
      });
      
      next();
    };
    
    const app = express();
    app.use(loggingMiddleware);
    app.get('/test', (req, res) => res.json({ ok: true }));
    
    await request(app).get('/test');
    
    expect(logger.info).toHaveBeenCalledTimes(2);
    expect(calls[0].msg).toBe('request received');
    expect(calls[1].msg).toBe('request completed');
    expect(calls[1].data.status).toBe(200);
    expect(calls[1].time).toBeGreaterThanOrEqual(calls[0].time);
  });
});

Key Takeaways

The patterns that matter most for production Express middleware testing:

  1. Use factory functions — never import a started server, create fresh instances per test
  2. Test units before integration — middleware functions are just functions, test them directly
  3. Mock at the boundary — inject fakes through dependency injection, not module mocking when you can avoid it
  4. Verify call order explicitly — use tracking arrays or spies to assert execution sequence
  5. Test error propagation — both expected errors (404, 403) and unexpected ones (database crashes)
  6. Test the transformation contract — for middleware that wraps responses, verify the exact shape

Investing in thorough middleware tests pays off when a new middleware is added to the chain and silently breaks assumptions downstream. Your tests become the specification for how the chain must behave.

Read more

Start now free