Testing Express Middleware with Supertest

Testing Express Middleware with Supertest

Express middleware sits between the HTTP request and your route handlers — and it's where security headers, rate limits, request validation, and error formatting live. Supertest lets you test middleware in two ways: in isolation on a minimal app, or integrated into your full application stack. Both matter.

Key Takeaways

Test middleware in isolation first. Mount only the middleware you're testing onto a minimal Express app. This gives you fast, focused tests that don't depend on your full route setup.

Test error middleware with a route that throws. Add a route that throws or calls next(err) to trigger your error handler — error middleware with 4 arguments only fires on errors.

Verify headers, not just status codes. Security middleware like Helmet sets response headers. Assert the exact header values, not just that the response succeeded.

Rate limiting tests need state reset between runs. Most rate limiters use in-memory stores. Reset state in afterEach or use a per-request store in tests to avoid cross-test contamination.

Test middleware ordering explicitly. The order Express processes middleware is part of your contract — test that auth fires before route handlers, and error handlers fire last.

Why Test Middleware Separately

Route handler tests tell you whether your business logic works. They don't tell you whether:

  • Your CORS configuration allows the right origins and rejects the wrong ones
  • Your rate limiter fires at the right threshold and returns the right error format
  • Your request validation middleware rejects malformed JSON before it reaches your handlers
  • Your error handler formats errors consistently across all routes
  • Your security headers (CSP, HSTS, X-Frame-Options) are present on every response

Supertest makes it practical to test all of these without spinning up a real server.

Pattern: Minimal Test App

The cleanest approach is to create a minimal Express app for each middleware under test:

// tests/middleware/cors.test.js
const express = require('express');
const cors = require('cors');
const request = require('supertest');

function buildApp(corsOptions) {
  const app = express();
  app.use(cors(corsOptions));
  app.get('/data', (req, res) => res.json({ ok: true }));
  return app;
}

describe('CORS middleware', () => {
  const allowedOrigin = 'https://app.example.com';
  let app;

  beforeAll(() => {
    app = buildApp({
      origin: allowedOrigin,
      methods: ['GET', 'POST'],
      allowedHeaders: ['Content-Type', 'Authorization'],
      credentials: true,
    });
  });

  it('sets Access-Control-Allow-Origin for allowed origin', async () => {
    const res = await request(app)
      .get('/data')
      .set('Origin', allowedOrigin);

    expect(res.headers['access-control-allow-origin']).toBe(allowedOrigin);
  });

  it('sets Access-Control-Allow-Credentials', async () => {
    const res = await request(app)
      .get('/data')
      .set('Origin', allowedOrigin);

    expect(res.headers['access-control-allow-credentials']).toBe('true');
  });

  it('does not set ACAO header for disallowed origin', async () => {
    const res = await request(app)
      .get('/data')
      .set('Origin', 'https://evil.com');

    expect(res.headers['access-control-allow-origin']).toBeUndefined();
  });

  it('handles preflight OPTIONS request', async () => {
    const res = await request(app)
      .options('/data')
      .set('Origin', allowedOrigin)
      .set('Access-Control-Request-Method', 'POST')
      .set('Access-Control-Request-Headers', 'Authorization');

    expect(res.status).toBe(204);
    expect(res.headers['access-control-allow-methods']).toContain('POST');
    expect(res.headers['access-control-allow-headers']).toContain('Authorization');
  });

  it('returns 204 for preflight from disallowed origin', async () => {
    // CORS spec: preflight still returns 204 but without ACAO header
    const res = await request(app)
      .options('/data')
      .set('Origin', 'https://evil.com')
      .set('Access-Control-Request-Method', 'GET');

    expect(res.headers['access-control-allow-origin']).toBeUndefined();
  });
});

Testing Rate Limiting Middleware

// tests/middleware/rate-limit.test.js
const express = require('express');
const rateLimit = require('express-rate-limit');
const request = require('supertest');

function buildApp(limit) {
  const app = express();
  app.set('trust proxy', 1);

  const limiter = rateLimit({
    windowMs: 60 * 1000, // 1 minute
    max: limit,
    standardHeaders: true,
    legacyHeaders: false,
    handler: (req, res) => {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: res.getHeader('Retry-After'),
      });
    },
    // Use memory store with a per-instance store for test isolation
    skip: () => false,
  });

  app.use('/api/', limiter);
  app.get('/api/data', (req, res) => res.json({ ok: true }));

  return app;
}

describe('Rate limiting middleware', () => {
  it('allows requests under the limit', async () => {
    const app = buildApp(5);

    for (let i = 0; i < 5; i++) {
      const res = await request(app)
        .get('/api/data')
        .set('X-Forwarded-For', '10.0.0.1');
      expect(res.status).toBe(200);
    }
  });

  it('blocks the request that exceeds the limit', async () => {
    const app = buildApp(3);

    // Make 3 allowed requests
    for (let i = 0; i < 3; i++) {
      await request(app).get('/api/data').set('X-Forwarded-For', '10.0.0.2');
    }

    // 4th request should be blocked
    const res = await request(app)
      .get('/api/data')
      .set('X-Forwarded-For', '10.0.0.2');

    expect(res.status).toBe(429);
    expect(res.body).toMatchObject({ error: 'Too many requests' });
  });

  it('includes rate limit headers on successful response', async () => {
    const app = buildApp(10);

    const res = await request(app)
      .get('/api/data')
      .set('X-Forwarded-For', '10.0.0.3');

    expect(res.headers['ratelimit-limit']).toBe('10');
    expect(res.headers['ratelimit-remaining']).toBeDefined();
  });

  it('includes Retry-After header on 429 response', async () => {
    const app = buildApp(1);

    await request(app).get('/api/data').set('X-Forwarded-For', '10.0.0.4');

    const res = await request(app)
      .get('/api/data')
      .set('X-Forwarded-For', '10.0.0.4');

    expect(res.status).toBe(429);
    expect(res.headers['retry-after']).toBeDefined();
  });

  it('rate limits by IP, not globally', async () => {
    const app = buildApp(2);

    // Exhaust limit for IP 10.0.0.5
    await request(app).get('/api/data').set('X-Forwarded-For', '10.0.0.5');
    await request(app).get('/api/data').set('X-Forwarded-For', '10.0.0.5');
    const blocked = await request(app).get('/api/data').set('X-Forwarded-For', '10.0.0.5');
    expect(blocked.status).toBe(429);

    // Different IP should still work
    const allowed = await request(app).get('/api/data').set('X-Forwarded-For', '10.0.0.6');
    expect(allowed.status).toBe(200);
  });
});

Testing Request Validation Middleware

// tests/middleware/validation.test.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const request = require('supertest');

function validationMiddleware(validations) {
  return async (req, res, next) => {
    await Promise.all(validations.map(v => v.run(req)));
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(422).json({
        error: 'Validation failed',
        details: errors.array().map(e => ({ field: e.path, message: e.msg })),
      });
    }
    next();
  };
}

function buildApp() {
  const app = express();
  app.use(express.json());

  const createUserValidations = [
    body('email').isEmail().withMessage('must be a valid email'),
    body('password').isLength({ min: 8 }).withMessage('must be at least 8 characters'),
    body('age').optional().isInt({ min: 0, max: 150 }).withMessage('must be between 0 and 150'),
  ];

  app.post(
    '/users',
    validationMiddleware(createUserValidations),
    (req, res) => res.status(201).json({ created: true })
  );

  return app;
}

describe('Request validation middleware', () => {
  let app;

  beforeAll(() => {
    app = buildApp();
  });

  it('accepts a valid payload', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'valid@example.com', password: 'strongpass' });

    expect(res.status).toBe(201);
    expect(res.body).toEqual({ created: true });
  });

  it('rejects invalid email', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'not-an-email', password: 'strongpass' });

    expect(res.status).toBe(422);
    expect(res.body).toMatchObject({
      error: 'Validation failed',
      details: expect.arrayContaining([
        { field: 'email', message: 'must be a valid email' },
      ]),
    });
  });

  it('rejects short password', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'valid@example.com', password: 'short' });

    expect(res.status).toBe(422);
    expect(res.body.details).toEqual(
      expect.arrayContaining([
        { field: 'password', message: 'must be at least 8 characters' },
      ])
    );
  });

  it('reports multiple validation errors at once', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'bad', password: 'no' });

    expect(res.status).toBe(422);
    expect(res.body.details).toHaveLength(2);
  });

  it('optional field passes when absent', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'valid@example.com', password: 'strongpass' });

    expect(res.status).toBe(201);
  });

  it('optional field fails when present and invalid', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'valid@example.com', password: 'strongpass', age: 200 });

    expect(res.status).toBe(422);
    expect(res.body.details[0]).toMatchObject({ field: 'age' });
  });
});

Testing Error Handler Middleware

Express error handlers have 4 arguments: (err, req, res, next). You need a route that actually throws to trigger them:

// tests/middleware/error-handler.test.js
const express = require('express');
const request = require('supertest');

function errorHandler(err, req, res, next) {
  console.error(err); // silence this in tests if noisy

  if (err.type === 'validation') {
    return res.status(400).json({ error: err.message, code: err.code });
  }

  if (err.status) {
    return res.status(err.status).json({ error: err.message });
  }

  res.status(500).json({ error: 'Internal server error' });
}

function buildApp() {
  const app = express();
  app.use(express.json());

  app.get('/throw-validation', (req, res, next) => {
    const err = new Error('Email is required');
    err.type = 'validation';
    err.code = 'MISSING_EMAIL';
    next(err);
  });

  app.get('/throw-not-found', (req, res, next) => {
    const err = new Error('Resource not found');
    err.status = 404;
    next(err);
  });

  app.get('/throw-unhandled', (req, res, next) => {
    next(new Error('Something unexpected happened'));
  });

  app.get('/throw-sync', (req, res) => {
    throw new Error('Sync throw');
  });

  // Error handler MUST be last
  app.use(errorHandler);

  return app;
}

describe('Error handler middleware', () => {
  let app;

  beforeAll(() => {
    app = buildApp();
    // Silence console.error during tests
    jest.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterAll(() => {
    console.error.mockRestore();
  });

  it('formats validation errors with 400 and error code', async () => {
    const res = await request(app).get('/throw-validation');

    expect(res.status).toBe(400);
    expect(res.body).toEqual({
      error: 'Email is required',
      code: 'MISSING_EMAIL',
    });
  });

  it('uses err.status when set', async () => {
    const res = await request(app).get('/throw-not-found');

    expect(res.status).toBe(404);
    expect(res.body).toEqual({ error: 'Resource not found' });
  });

  it('returns 500 for unhandled errors', async () => {
    const res = await request(app).get('/throw-unhandled');

    expect(res.status).toBe(500);
    expect(res.body).toEqual({ error: 'Internal server error' });
  });

  it('does not leak error details in 500 responses', async () => {
    const res = await request(app).get('/throw-unhandled');

    expect(res.body.stack).toBeUndefined();
    expect(res.body.message).toBeUndefined();
  });
});

Testing Security Headers (Helmet)

// tests/middleware/security-headers.test.js
const express = require('express');
const helmet = require('helmet');
const request = require('supertest');

function buildApp() {
  const app = express();
  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", 'cdn.example.com'],
      },
    },
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
    },
  }));
  app.get('/', (req, res) => res.json({ ok: true }));
  return app;
}

describe('Security headers (Helmet)', () => {
  let app;

  beforeAll(() => { app = buildApp(); });

  it('sets X-Frame-Options to DENY', async () => {
    const res = await request(app).get('/');
    expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
  });

  it('sets X-Content-Type-Options', async () => {
    const res = await request(app).get('/');
    expect(res.headers['x-content-type-options']).toBe('nosniff');
  });

  it('sets Strict-Transport-Security with correct maxAge', async () => {
    const res = await request(app).get('/');
    const hsts = res.headers['strict-transport-security'];
    expect(hsts).toContain('max-age=31536000');
    expect(hsts).toContain('includeSubDomains');
  });

  it('sets Content-Security-Policy', async () => {
    const res = await request(app).get('/');
    const csp = res.headers['content-security-policy'];
    expect(csp).toContain("default-src 'self'");
    expect(csp).toContain('cdn.example.com');
  });

  it('removes X-Powered-By header', async () => {
    const res = await request(app).get('/');
    expect(res.headers['x-powered-by']).toBeUndefined();
  });
});

Testing Middleware Order

The order Express processes middleware is part of your contract:

// tests/middleware/ordering.test.js
const express = require('express');
const request = require('supertest');

describe('Middleware execution order', () => {
  it('processes middleware in registration order', async () => {
    const order = [];
    const app = express();

    app.use((req, res, next) => { order.push('first'); next(); });
    app.use((req, res, next) => { order.push('second'); next(); });
    app.get('/test', (req, res) => {
      order.push('handler');
      res.json({ order });
    });

    const res = await request(app).get('/test');
    expect(res.body.order).toEqual(['first', 'second', 'handler']);
  });

  it('stops processing when middleware sends response without calling next()', async () => {
    const order = [];
    const app = express();

    app.use((req, res, next) => {
      order.push('auth');
      res.status(401).json({ error: 'unauthorized' }); // Does NOT call next()
    });
    app.get('/test', (req, res) => {
      order.push('handler'); // Should never run
      res.json({ ok: true });
    });

    const res = await request(app).get('/test');
    expect(res.status).toBe(401);
    expect(order).toEqual(['auth']); // Handler never ran
  });

  it('passes errors to error handler skipping regular middleware', async () => {
    const order = [];
    const app = express();

    app.get('/test', (req, res, next) => {
      next(new Error('oops'));
    });
    app.use((req, res, next) => {
      order.push('regular'); // Should be skipped
      next();
    });
    app.use((err, req, res, next) => {
      order.push('error-handler');
      res.status(500).json({ error: err.message });
    });

    await request(app).get('/test');
    expect(order).toEqual(['error-handler']);
  });
});

Tips for Reliable Middleware Tests

Isolate state between tests. Rate limiters and session stores hold state. Use a fresh app instance per test or flush the store in beforeEach.

Test the actual middleware, not a re-implementation. Import the same middleware module your app uses. If you test a reimplemented version, you're testing nothing.

Verify Content-Type on error responses. Error handlers often forget to set Content-Type: application/json. Assert it so clients know how to parse errors.

Test middleware with the right HTTP method. CORS preflight uses OPTIONS. Make sure your middleware is registered for OPTIONS routes or uses app.use().

Use jest.spyOn(console, 'error').mockImplementation(() => {}) in error handler tests to keep test output clean while still allowing your error handler to log internally.

Summary

Middleware testing with Supertest covers:

  • CORS — allowed origins, blocked origins, preflight responses, credential headers
  • Rate limiting — threshold enforcement, per-IP isolation, rate limit headers, Retry-After
  • Validation — accepted payloads, rejected fields, multiple errors, optional field handling
  • Error handlers — error-typed formatting, status codes, no internal detail leakage
  • Security headers — Helmet CSP, HSTS, X-Frame-Options, X-Content-Type-Options
  • Middleware ordering — execution order, early exit, error propagation

The minimal-app pattern (build a fresh Express app containing only the middleware under test) keeps each test suite focused and avoids cross-suite state contamination.

Start now free