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 eachIntegration 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 eachEnd-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 requestsupertest 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.
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.
jest.fn() Cheat Sheet
| Call | Behaviour |
|---|---|
| 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.calls | Array 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.
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.
Test Organisation
⚠ 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
| API | Notes |
|---|---|
| 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.
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.
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.