Testing Express APIs

Express.js Intermediate — Testing Express APIs

Express.js Intermediate/Advanced

Chapter 5 of 8  ·  Testing Express APIs

Testing Express APIs

Testing a REST API has two distinct layers. Unit tests call your service and utility functions directly — no HTTP involved, fast, easy to isolate. Integration tests fire real HTTP requests at your Express app using supertest and verify the full request/response cycle. The MVC split from express1-8 pays off here directly: because services have no HTTP dependency, they can be unit-tested without a server; because controllers are thin, integration tests cover them completely via supertest. The key enabling move is mocking the database layer so tests don't need a running MySQL instance.

Test Types at a Glance

Unit Tests

Call functions directly. Mock all dependencies. No network, no DB.

Target: services, validators, utilities, error mappers

Tool: Jest alone

Fast — milliseconds each

Integration Tests

Fire HTTP via supertest. Mock the DB layer. Test routes end-to-end.

Target: controllers, routes, middleware

Tool: Jest + supertest + jest.mock()

Medium — tens of ms each

End-to-End Tests

Real DB, real server, real HTTP. Everything running together.

Target: critical user flows

Tool: Jest + supertest + test DB

Slow — real I/O on every request

supertest Basics

supertest lets you make HTTP requests against an Express app without binding to a port. You pass the app directly — supertest opens a temporary connection for the duration of the request and closes it. No app.listen() call needed in your test file.

// npm install --save-dev supertest jest const request = require('supertest'); const app = require('./app'); // app.js exports the app, never calls listen() // One-shot request — new connection each call const res = await request(app) .get('/api/products') .set('Authorization', `Bearer ${token}`) .expect(200) // built-in assertion (throws on mismatch) .expect('Content-Type', /json/); // also works with regex // Persistent agent — keeps cookies between requests (auth sessions, refresh tokens) const agent = request.agent(app); await agent.post('/auth/login').send({ email: 'a@b.com', password: 'pass' }); const profile = await agent.get('/me'); // cookie sent automatically // Common assertions on the result object expect(res.status).toBe(201); expect(res.body.id).toBeDefined(); expect(res.headers['location']).toMatch(/\/api\/products\/\d+/); expect(res.body.error).toMatch(/not found/i);

app.js must NOT call listen()

supertest creates its own temporary server. If app.js calls app.listen() at module-load time, you get port conflicts and Jest won't exit cleanly. The pattern: app.js exports the app only; server.js (never imported in tests) calls listen(). This is the pattern established in express1-8 and pays off fully here.

Mocking the Database Layer

jest.mock('./path/to/module') replaces every export of that module with jest.fn() before any test file imports it. Jest hoists the call above all require()s, so even modules that are imported before the mock call see the mocked version. This is the standard way to test controllers without a real database.

// Suppose app.js → controller → productsService → ProductModel (the DB layer) // We mock ProductModel so nothing ever touches MySQL jest.mock('./src/models/product'); // hoisted — runs before any require() const request = require('supertest'); const app = require('./app'); const ProductModel = require('./src/models/product'); // now all jest.fn()s // Reset call counts and return values before each test beforeEach(() => jest.clearAllMocks()); describe('GET /api/products', () => { test('200 and returns the list from the model', async () => { // Arrange — tell the mock what to return ProductModel.findAll.mockResolvedValue([ { id: 1, name: 'Widget', price: 9.99 }, { id: 2, name: 'Gadget', price: 24.99 }, ]); // Act const res = await request(app).get('/api/products'); // Assert expect(res.status).toBe(200); expect(res.body).toHaveLength(2); expect(ProductModel.findAll).toHaveBeenCalledTimes(1); }); test('500 when the model throws an unexpected error', async () => { ProductModel.findAll.mockRejectedValue(new Error('DB connection lost')); const res = await request(app).get('/api/products'); expect(res.status).toBe(500); // Internal message must NOT be in the response body expect(JSON.stringify(res.body)).not.toMatch(/DB connection lost/); }); });

jest.fn() Cheat Sheet

CallBehaviour
jest.fn()Creates a mock function that returns undefined and records all calls.
fn.mockReturnValue(x)Returns x synchronously every time called.
fn.mockResolvedValue(x)Returns Promise.resolve(x) — use for async model methods.
fn.mockRejectedValue(err)Returns Promise.reject(err) — simulates DB errors.
fn.mockResolvedValueOnce(x)Returns x on the next call only, then falls through to the default.
fn.mockImplementation(impl)Replaces the function body — for complex conditional behaviour.
jest.clearAllMocks()Resets call counts and return values on all mocks. Put in beforeEach.
jest.spyOn(obj, 'method')Wraps an existing method without replacing it — call .mockRestore() to undo.
fn.mock.callsArray of argument arrays for each call: fn.mock.calls[0][1] = first call, second arg.
expect(fn).toHaveBeenCalledWith(a, b)Asserts the mock was called with specific arguments.

Unit Testing Services

Services contain business logic — validation, error mapping, orchestration. They're pure functions of their inputs and their model dependencies. Mock the model, call the service directly, assert the result or the thrown error. No HTTP, no supertest.

jest.mock('./src/models/product'); const ProductModel = require('./src/models/product'); const productsService = require('./src/services/productsService'); beforeEach(() => jest.clearAllMocks()); describe('productsService.get(id)', () => { test('returns the product when found', async () => { ProductModel.findById.mockResolvedValue({ id: 1, name: 'Widget' }); const result = await productsService.get(1); expect(result.name).toBe('Widget'); }); test('throws 404 when model returns null', async () => { ProductModel.findById.mockResolvedValue(null); await expect(productsService.get(99)) .rejects.toMatchObject({ status: 404 }); }); }); describe('productsService.create(data)', () => { test('throws 422 for missing name — without calling the model', async () => { await expect(productsService.create({ price: 5 })) .rejects.toMatchObject({ status: 422 }); expect(ProductModel.create).not.toHaveBeenCalled(); }); test('calls model.create with trimmed name', async () => { ProductModel.create.mockResolvedValue({ id: 3, name: 'Trimmed', price: 10 }); await productsService.create({ name: ' Trimmed ', price: 10 }); expect(ProductModel.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'Trimmed' }) ); }); });

Testing Middleware

Authentication middleware is best tested in two ways: unit tests that call the middleware function directly with mock req/res/next objects, and integration tests via supertest that send a real token to a protected route. The integration path is simpler and covers more — use it for the main cases, unit tests for edge cases.

// Integration approach — generate a real token with jwt.sign() in the test const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET || 'test-secret'; function tokenFor(payload) { return `Bearer ${jwt.sign(payload, SECRET, { algorithm: 'HS256', expiresIn: '15m' })}`; } function expiredToken() { return `Bearer ${jwt.sign({ sub: 1, role: 'user' }, SECRET, { expiresIn: '-1s' })}`; } // In tests — no login route needed at all const res = await request(app) .get('/api/me') .set('Authorization', tokenFor({ sub: 1, role: 'admin' })); expect(res.status).toBe(200); // Unit approach — test the middleware function itself const authenticate = require('./src/middleware/authenticate'); test('calls next(err) with 401 for missing header', () => { const req = { get: () => undefined }; const res = {}; const next = jest.fn(); authenticate(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ status: 401 }) ); });

Test Organisation

// jest.config.js — recommended settings for Express API projects module.exports = { testEnvironment: 'node', // not 'jsdom' — this is a server forceExit: true, // close after all tests (Express keeps event loop open) clearMocks: true, // automatically clear mock state between tests testMatch: ['**/*.test.js'], coverageThreshold: { global: { lines: 80, functions: 80, branches: 70 }, }, }; // ── Typical test file structure ──────────────────────────────────────────────── jest.mock('./src/models/product'); // hoisted to top by jest — always first const request = require('supertest'); const app = require('./app'); const ProductModel = require('./src/models/product'); // Data factory — override only what each test needs to change const makeProduct = (overrides = {}) => ({ id: 1, name: 'Widget', price: 9.99, stock: 5, ...overrides, }); beforeEach(() => jest.clearAllMocks()); beforeAll(() => jest.spyOn(console, 'error').mockImplementation(() => {})); afterAll(() => jest.restoreAllMocks()); describe('GET /api/products', () => { /* ... */ }); describe('GET /api/products/:id', () => { /* ... */ }); describe('POST /api/products', () => { /* ... */ });

⚠ jest.mock() is hoisted — the path must be a literal string

Jest's Babel transform moves jest.mock() calls to the top of the file before any imports. This means you cannot use a variable in the path: jest.mock(MODELS_PATH) will fail because the variable hasn't been assigned yet. Always use a literal string: jest.mock('./src/models/product'). This is the most common "why isn't my mock working?" mistake.

Quick Reference

APINotes
request(app).get(url)Make a one-shot GET. Returns a thenable — await it to get the response object.
.send(body)Set the request body. Also sets Content-Type: application/json automatically.
.set(header, value)Set a request header. For auth: .set('Authorization', 'Bearer ...').
.attach(field, buffer, opts)Attach a file for multipart/form-data requests (multer).
.expect(status)Built-in status assertion — throws if the status doesn't match.
request.agent(app)Persistent agent — cookies are sent on every subsequent request.
jest.mock('./path')Replace all module exports with jest.fn(). Must be a literal string.
fn.mockResolvedValue(x)Make an async mock return x. Required for all async model methods.
fn.mockRejectedValue(err)Simulate a rejected promise from the model.
jest.clearAllMocks()Reset call counts and mock return values. Put in beforeEach.
expect(fn).not.toHaveBeenCalled()Verify a mock was never called — useful to confirm short-circuit paths.
expect(obj).toMatchObject(partial)Passes if obj contains all keys in partial. Used for error objects: { status: 404 }.

Coding Challenges

Challenge 1 — Unit Testing a Service

Write a productsService with four methods: list(), get(id), create(data), remove(id). The service validates input (name required, price must be a non-negative number) and maps model results to errors (model returns null → service throws 404). Use jest.mock() to replace the model and write unit tests for all four methods — no supertest, no HTTP. Tests must cover: list() returns whatever the model returns; get(id) returns the product when found and throws status 404 when model returns null; create(data) throws 422 for missing name without calling the model; create(data) calls the model with trimmed name; create(data) propagates a DB 409 error from the model; remove(id) returns true on success and throws 404 when model returns false. Use jest.clearAllMocks() in beforeEach. Use a data factory function for product fixtures.

View sample solution ↗

Challenge 2 — Integration Testing Routes with a Mocked Service

Take a complete products CRUD Express app (routes → controller → service → model). Use jest.mock() to mock the service layer (not the model — the service is the dependency boundary for controllers). Write integration tests using supertest for all five routes: GET /api/products, GET /api/products/:id, POST /api/products, PATCH /api/products/:id, DELETE /api/products/:id. Each route needs at least: the happy-path (correct status code, response body shape, headers like Location); the not-found case (service throws 404 → route returns 404 JSON); where applicable, the validation case (service throws 422 → route returns 422 with details). Also verify: the 500 case (service throws an unexpected error → route returns 500 with a sanitised message, not the raw error message); console.error is silenced with jest.spyOn. Use expect(fn).toHaveBeenCalledWith() on at least one test to confirm the controller passes the right arguments to the service.

View sample solution ↗

Challenge 3 — Testing Authentication Middleware

Write tests for an authenticate middleware and a requireRole factory (from express2-1). Use both approaches: (A) Unit tests — call the middleware directly with mock req/res/next objects. Test: missing Authorization header calls next(err) with status 401; non-Bearer token calls next(err) with status 401; a tampered token calls next(err) with status 401; an expired token calls next(err) with a message matching "expired"; a valid token sets req.user and calls next() with no arguments; requireRole('admin') calls next(err) with status 403 when req.user.role is 'user'. (B) Integration tests via supertest — mount the middleware on a test route in a minimal app. Test: no token → 401; expired token → 401; valid token with correct role → 200; valid token with wrong role → 403. Generate tokens directly with jwt.sign() — no login route needed.

View sample solution ↗