Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Testing Express.js Applications: Unit, Integration, and Middleware Tests

Capítulo 12

Estimated reading time: 13 minutes

+ Exercise

Layered testing strategy aligned with your architecture

A maintainable Express.js codebase typically has multiple layers (e.g., controllers/routes, middleware, services, repositories). A good testing strategy mirrors those boundaries so tests stay stable as implementation evolves. The goal is to verify behavior at the right level:

  • Unit tests: services and repositories in isolation, using mocks/fakes for dependencies. Fast and precise.
  • Integration tests: exercise the HTTP surface (routes/controllers + middleware + error handler) using an in-memory Express app instance. Slower but higher confidence.
  • Middleware tests: verify middleware behavior and order (auth before validation, rate limiting before controller, etc.) and ensure consistent error responses.

Behavior-focused tests assert outcomes (returned values, thrown domain errors, HTTP status/body/headers) rather than internal calls or private function details. Use spies sparingly; prefer verifying observable effects.

Test tooling and a minimal test harness

The examples below use Jest and Supertest because they are common and work well with in-memory Express apps.

// package.json (illustrative scripts) { "scripts": { "test": "jest", "test:watch": "jest --watch" } }

Keep a small helper that builds an Express app for integration tests. The key idea is: construct the app with dependency injection so tests can swap real dependencies for fakes.

// appFactory.js import express from 'express'; export function createApp({ routers, errorHandler, middlewares = [] }) { const app = express(); app.use(express.json()); for (const mw of middlewares) app.use(mw); for (const r of routers) app.use(r); app.use(errorHandler); return app; }

In tests, you can pass a router wired with fake services, and the same centralized error handler used in production (or a test-friendly variant if needed).

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Unit tests: services with mocks/fakes

What to test in a service

  • Business rules: decisions, branching, invariants.
  • Interactions at the boundary: repository calls, external clients (mocked).
  • Error mapping: domain errors thrown for invalid states.

A service should be constructed with its dependencies (repository, clock, id generator, etc.) so tests can inject fakes.

Example service and unit tests

// userService.js export class UserService { constructor({ userRepo, passwordHasher }) { this.userRepo = userRepo; this.passwordHasher = passwordHasher; } async register({ email, password }) { const existing = await this.userRepo.findByEmail(email); if (existing) { const err = new Error('Email already in use'); err.code = 'EMAIL_TAKEN'; throw err; } const passwordHash = await this.passwordHasher.hash(password); const user = await this.userRepo.create({ email, passwordHash }); return { id: user.id, email: user.email }; } }
// userService.test.js import { UserService } from './userService'; test('register creates a user when email is free', async () => { const fakeRepo = { findByEmail: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'u1', email: 'a@b.com' }) }; const fakeHasher = { hash: jest.fn().mockResolvedValue('HASH') }; const svc = new UserService({ userRepo: fakeRepo, passwordHasher: fakeHasher }); const result = await svc.register({ email: 'a@b.com', password: 'secret' }); expect(result).toEqual({ id: 'u1', email: 'a@b.com' }); expect(fakeRepo.findByEmail).toHaveBeenCalledWith('a@b.com'); expect(fakeHasher.hash).toHaveBeenCalledWith('secret'); expect(fakeRepo.create).toHaveBeenCalledWith({ email: 'a@b.com', passwordHash: 'HASH' }); }); test('register throws a domain error when email is taken', async () => { const fakeRepo = { findByEmail: jest.fn().mockResolvedValue({ id: 'existing' }), create: jest.fn() }; const fakeHasher = { hash: jest.fn() }; const svc = new UserService({ userRepo: fakeRepo, passwordHasher: fakeHasher }); await expect(svc.register({ email: 'a@b.com', password: 'secret' })).rejects.toMatchObject({ code: 'EMAIL_TAKEN' }); expect(fakeRepo.create).not.toHaveBeenCalled(); expect(fakeHasher.hash).not.toHaveBeenCalled(); });

Notice the tests assert behavior: returned DTO, thrown error code, and that side effects do not happen when preconditions fail.

Repository unit tests: prefer real DB in separate suite, but you can still unit-test mapping

Repositories often contain mapping logic (DB row to domain model) and query construction. If you have a separate database integration suite, keep repository unit tests focused on mapping and error translation using fakes for the DB client.

// userRepo.js export class UserRepo { constructor({ db }) { this.db = db; } async findByEmail(email) { const row = await this.db.queryOne('SELECT id, email FROM users WHERE email = $1', [email]); return row ? { id: row.id, email: row.email } : null; } }
// userRepo.test.js import { UserRepo } from './userRepo'; test('findByEmail returns null when no row', async () => { const db = { queryOne: jest.fn().mockResolvedValue(null) }; const repo = new UserRepo({ db }); await expect(repo.findByEmail('a@b.com')).resolves.toBeNull(); expect(db.queryOne).toHaveBeenCalledWith(expect.any(String), ['a@b.com']); }); test('findByEmail maps row to domain shape', async () => { const db = { queryOne: jest.fn().mockResolvedValue({ id: 'u1', email: 'a@b.com' }) }; const repo = new UserRepo({ db }); await expect(repo.findByEmail('a@b.com')).resolves.toEqual({ id: 'u1', email: 'a@b.com' }); });

Integration tests: routes/controllers with an in-memory app

What integration tests should cover

  • HTTP contract: status codes, response body shape, headers.
  • Middleware chain behavior: auth, validation, rate limiting, and error handling.
  • Wiring: controller uses service correctly (without asserting internal calls).

Integration tests should not require a real network port. Supertest can call the Express app instance directly.

Build a test router with injected fakes

// userRoutes.js import { Router } from 'express'; export function createUserRouter({ userService, authMiddleware, validateRegister }) { const r = Router(); r.post('/users', authMiddleware, validateRegister, async (req, res, next) => { try { const dto = await userService.register(req.body); res.status(201).json({ data: dto }); } catch (e) { next(e); } }); return r; }
// errorHandler.js export function errorHandler(err, req, res, next) { const status = err.status || (err.code === 'EMAIL_TAKEN' ? 409 : 500); const code = err.code || 'INTERNAL_ERROR'; res.status(status).json({ error: { code, message: err.message } }); }
// userRoutes.int.test.js import request from 'supertest'; import { createApp } from './appFactory'; import { createUserRouter } from './userRoutes'; import { errorHandler } from './errorHandler'; function makeAuth({ allow = true } = {}) { return (req, res, next) => { if (!allow) return res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } }); req.user = { id: 'u-auth' }; next(); }; } function makeValidateRegister({ ok = true } = {}) { return (req, res, next) => { if (!ok) return res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid payload' } }); next(); }; } test('POST /users returns 201 and data when authorized and valid', async () => { const userService = { register: jest.fn().mockResolvedValue({ id: 'u1', email: 'a@b.com' }) }; const router = createUserRouter({ userService, authMiddleware: makeAuth({ allow: true }), validateRegister: makeValidateRegister({ ok: true }) }); const app = createApp({ routers: [router], errorHandler }); const res = await request(app).post('/users').send({ email: 'a@b.com', password: 'secret' }); expect(res.status).toBe(201); expect(res.body).toEqual({ data: { id: 'u1', email: 'a@b.com' } }); }); test('POST /users returns 401 when auth fails (service not called)', async () => { const userService = { register: jest.fn() }; const router = createUserRouter({ userService, authMiddleware: makeAuth({ allow: false }), validateRegister: makeValidateRegister({ ok: true }) }); const app = createApp({ routers: [router], errorHandler }); const res = await request(app).post('/users').send({ email: 'a@b.com', password: 'secret' }); expect(res.status).toBe(401); expect(res.body.error.code).toBe('UNAUTHORIZED'); expect(userService.register).not.toHaveBeenCalled(); }); test('POST /users returns 400 when validation fails (service not called)', async () => { const userService = { register: jest.fn() }; const router = createUserRouter({ userService, authMiddleware: makeAuth({ allow: true }), validateRegister: makeValidateRegister({ ok: false }) }); const app = createApp({ routers: [router], errorHandler }); const res = await request(app).post('/users').send({ email: 'not-an-email' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('VALIDATION_ERROR'); expect(userService.register).not.toHaveBeenCalled(); });

This suite tests the HTTP behavior and verifies that middleware short-circuits the request before the controller/service runs.

Testing middleware order and behavior

Why order tests matter

Middleware order is part of your application behavior. If auth is accidentally placed after validation, you may leak validation details to unauthenticated clients. If rate limiting is placed after expensive work, it won’t protect resources. Order tests catch regressions when routers are refactored.

Pattern: record execution trace

Create small probe middlewares that push markers into an array stored on req (or a closure). Then assert the order in the response.

// middlewareOrder.int.test.js import request from 'supertest'; import express from 'express'; import { createApp } from './appFactory'; import { errorHandler } from './errorHandler'; function trace(name, { failWith } = {}) { return (req, res, next) => { req._trace = req._trace || []; req._trace.push(name); if (failWith) return res.status(failWith.status).json(failWith.body); next(); }; } test('auth runs before validation and controller', async () => { const router = express.Router(); router.post('/x', trace('auth'), trace('validation'), (req, res) => { req._trace.push('controller'); res.json({ trace: req._trace }); }); const app = createApp({ routers: [router], errorHandler }); const res = await request(app).post('/x').send({}); expect(res.status).toBe(200); expect(res.body.trace).toEqual(['auth', 'validation', 'controller']); }); test('rate limiting short-circuits before controller', async () => { const router = express.Router(); router.get('/limited', trace('rateLimit', { failWith: { status: 429, body: { error: { code: 'RATE_LIMITED', message: 'Too many requests' } } } }), (req, res) => { res.json({ ok: true }); }); const app = createApp({ routers: [router], errorHandler }); const res = await request(app).get('/limited'); expect(res.status).toBe(429); expect(res.body.error.code).toBe('RATE_LIMITED'); });

These tests are intentionally simple: they assert the externally visible behavior (response) and the trace order.

Testing auth middleware behavior

For auth middleware, focus on: missing token, invalid token, and successful authentication attaching identity to the request.

// authMiddleware.test.js test('rejects when no Authorization header', async () => { const auth = makeAuthMiddleware({ tokenVerifier: () => { throw Object.assign(new Error('bad'), { code: 'BAD_TOKEN' }); } }); // Use a tiny app to observe behavior const app = require('express')(); app.get('/me', auth, (req, res) => res.json({ userId: req.user.id })); app.use((err, req, res, next) => res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Missing token' } })); const res = await require('supertest')(app).get('/me'); expect(res.status).toBe(401); });

Even when testing a single middleware, using an in-memory app keeps the test close to real usage (it runs through Express’ middleware mechanism).

Asserting consistent error responses

When multiple middleware and controllers can fail, integration tests should ensure errors share a consistent shape. This prevents clients from handling many special cases and catches regressions when new errors are introduced.

Define a reusable assertion helper

// testAsserts.js export function expectErrorShape(res, { status, code }) { expect(res.status).toBe(status); expect(res.body).toHaveProperty('error'); expect(res.body.error).toEqual(expect.objectContaining({ code: code, message: expect.any(String) })); }
// errorShape.int.test.js import request from 'supertest'; import express from 'express'; import { createApp } from './appFactory'; import { errorHandler } from './errorHandler'; import { expectErrorShape } from './testAsserts'; test('service domain error becomes consistent HTTP error response', async () => { const router = express.Router(); router.get('/boom', async (req, res, next) => { const err = new Error('Email already in use'); err.code = 'EMAIL_TAKEN'; next(err); }); const app = createApp({ routers: [router], errorHandler }); const res = await request(app).get('/boom'); expectErrorShape(res, { status: 409, code: 'EMAIL_TAKEN' }); });

Keep these assertions stable: check the presence of required fields and types, not the full message text unless it is part of the contract.

Test data setup/teardown patterns

Unit tests: build data with factories

Factories reduce duplication and make intent clear. Prefer explicit overrides so tests show what matters.

// testFactories.js export function userAttrs(overrides = {}) { return { id: 'u1', email: 'a@b.com', passwordHash: 'HASH', ...overrides }; }
// example usage const existingUser = userAttrs({ id: 'u-existing', email: 'taken@b.com' });

Integration tests: isolate state per test

If your integration tests use stateful fakes (e.g., an in-memory repository), reset them in beforeEach. If you use a real database in a separate suite, use transactions or truncate tables between tests.

// inMemoryUserRepo.js export function makeInMemoryUserRepo(seed = []) { const users = new Map(seed.map(u => [u.email, u])); return { async findByEmail(email) { return users.get(email) || null; }, async create({ email, passwordHash }) { const u = { id: `u-${users.size + 1}`, email, passwordHash }; users.set(email, u); return u; }, _dump() { return [...users.values()]; } }; }
// integration setup pattern let repo; let service; let app; beforeEach(() => { repo = makeInMemoryUserRepo(); service = new UserService({ userRepo: repo, passwordHasher: { hash: async (p) => `hash:${p}` } }); const router = createUserRouter({ userService: service, authMiddleware: makeAuth({ allow: true }), validateRegister: makeValidateRegister({ ok: true }) }); app = createApp({ routers: [router], errorHandler }); });

This keeps tests deterministic and avoids hidden coupling through shared state.

Dependency injection patterns for testability

Constructor injection for services and repositories

Constructor injection (passing dependencies as arguments) makes unit tests straightforward. Avoid importing singletons directly inside modules when those singletons represent external systems (DB clients, HTTP clients, caches).

ComponentInjectWhy
ServiceRepository, external clients, clock, id generatorMock time/IDs, isolate business rules
RepositoryDB client/query executorFake DB for unit tests, swap DB in integration suite
MiddlewareToken verifier, rate limiter store, configTest edge cases without real infra
Router factoryService + middlewaresIntegration tests can wire fakes easily

Router factories instead of global wiring

Export functions like createUserRouter({ userService, authMiddleware, validateRegister }) rather than exporting a router that imports everything globally. This keeps wiring at the composition root and makes tests able to construct minimal apps.

Testing rate limiting behavior without waiting

Rate limiting often depends on time windows and counters. For tests, prefer injecting a limiter implementation you can control (fake store, fake clock) and assert behavior at the HTTP level.

// fakeRateLimiter.js export function makeFakeRateLimiter({ limit = 2 } = {}) { let count = 0; return (req, res, next) => { count += 1; if (count > limit) return res.status(429).json({ error: { code: 'RATE_LIMITED', message: 'Too many requests' } }); next(); }; }
// rateLimit.int.test.js import request from 'supertest'; import express from 'express'; import { createApp } from './appFactory'; import { errorHandler } from './errorHandler'; import { makeFakeRateLimiter } from './fakeRateLimiter'; test('third request is rate limited', async () => { const router = express.Router(); router.get('/ping', (req, res) => res.json({ ok: true })); const limiter = makeFakeRateLimiter({ limit: 2 }); const app = createApp({ middlewares: [limiter], routers: [router], errorHandler }); await request(app).get('/ping').expect(200); await request(app).get('/ping').expect(200); const res = await request(app).get('/ping'); expect(res.status).toBe(429); expect(res.body.error.code).toBe('RATE_LIMITED'); });

This verifies the contract (429 + error shape) without relying on real timers.

Focusing tests on behavior (and avoiding brittle tests)

Prefer these assertions

  • Unit: returned value, thrown domain error (code/status), state changes in fakes.
  • Integration: HTTP status, response body shape, headers, and that certain side effects did not occur (e.g., service not called when middleware blocks).
  • Middleware: short-circuit behavior, request augmentation (e.g., req.user exists), and order.

Avoid these brittle assertions

  • Exact error message strings if they are not part of the API contract.
  • Testing private helper functions directly (test through the public function/middleware).
  • Overusing toHaveBeenCalledTimes for internal calls unless it represents a meaningful contract (e.g., “should not call repo.create when validation fails”).

Step-by-step: building a small test pyramid for one endpoint

Step 1: unit-test the service rule

Write tests for the service behavior (success and failure) using a fake repository and fake hasher.

Step 2: integration-test the route contract

Use an in-memory Express app with the router factory. Inject a fake auth middleware and fake validation middleware to cover 201/401/400 paths.

Step 3: middleware order test

Add a trace-based test to ensure auth runs before validation and that rate limiting short-circuits early.

Step 4: error shape test

Trigger a domain error (e.g., EMAIL_TAKEN) and assert the consistent error response shape and status mapping.

Now answer the exercise about the content:

In a layered Express.js application, what best explains why integration tests typically build an in-memory app using dependency injection?

You are right! Congratulations, now go to the next page

You missed! Try again.

Integration tests should exercise routes/controllers plus middleware and the error handler through the HTTP surface. Building an in-memory app with dependency injection lets tests replace real services/repositories with fakes, keeping tests behavior-focused and stable.

Next chapter

Deployment Readiness for Express.js: Reliability and Operational Concerns

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.