Testing HTTP Layers Without a Real Server
Testing an Express application at the HTTP layer means verifying status codes, response bodies, and headers for real requests, without necessarily starting a listening server on a port. The supertest library wraps Node's http module so it can bind to an ephemeral in-process socket, letting Jest issue requests like request(app).get('/users/1') directly against the exported Express app object and assert on the response, all inside the same process as the test runner.
Cricket analogy: Similar to a bowling machine testing a batter's technique in an indoor net rather than a full stadium match, supertest exercises the Express app's routing and middleware in-process without needing a real deployed server listening on a stadium-scale port.
Assembling Requests and Assertions with supertest
A typical supertest call chains an HTTP verb like .post('/api/users') with .send() to attach a JSON body, .set() to add headers such as Authorization, and then one or more .expect() assertions for status code and response shape, finishing with await so Jest waits for the request to resolve before the test completes. Because supertest returns a promise-based chain, it composes naturally with async/await test functions and with Jest's built-in matchers like toMatchObject() for asserting on nested response bodies.
Cricket analogy: Similar to a DRS review chaining ball-tracking, snickometer, and umpire's call checks before a final verdict, a supertest chain composes .send(), .set(), and .expect() calls before resolving, mirroring how each check must complete before the final decision is returned.
const request = require('supertest');
const app = require('../app');
describe('POST /api/users', () => {
it('creates a user and returns 201 with the new record', async () => {
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${validToken}`)
.send({ name: 'Priya Shah', email: 'priya@example.com' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: 'Priya Shah',
});
});
});Mocking the Database and External Services
Route handlers usually delegate to a database layer or an external API client, and unit-style route tests typically mock that dependency with jest.mock() so the test exercises only the routing, validation, and response-shaping logic without needing a real database connection or network call. For integration-style tests that intentionally exercise the real database, a common pattern is spinning up a disposable Postgres or MongoDB instance via Docker in a beforeAll hook and truncating tables in afterEach, keeping each test's data isolated.
Cricket analogy: Similar to a batting simulator using a bowling machine instead of a live bowler to isolate a batter's technique from opponent variability, mocking the database with jest.mock() isolates the route handler's logic from a real database's variability and latency.
For integration tests, libraries like mongodb-memory-server or testcontainers can spin up a real, ephemeral database engine in CI without touching a shared development database, giving you real query behavior while keeping each test run isolated.
Testing Error-Handling Middleware
Express centralizes error handling in middleware functions with four parameters, (err, req, res, next), and testing that this middleware correctly maps thrown errors to status codes and response bodies means either sending a request that triggers a real error path, like an invalid ID that fails validation, or calling the middleware function directly with a mock error object, request, response, and next function created via jest.fn(). Asserting that res.status().json() was called with the expected arguments confirms the middleware behaves correctly without needing supertest at all for that unit-level check.
Cricket analogy: Similar to testing what happens when a no-ball is called mid-over, verifying the umpire's signal, the extra run, and the free-hit rule all trigger correctly, testing Express error middleware verifies that a thrown error correctly triggers the right status code and response body.
Calling next(err) inside an async route handler without wrapping the handler, or using a helper like express-async-handler, silently swallows the promise rejection instead of forwarding it to Express's error middleware, so tests that expect a 500 response may instead hang or return an unrelated timeout.
- supertest exercises an Express app's HTTP layer in-process without requiring a real listening server on a network port.
- A typical supertest chain combines an HTTP verb, .send() for the body, .set() for headers, and .expect() for assertions.
- jest.mock() isolates route handler logic from the real database or external API for fast, deterministic unit tests.
- Integration tests can use disposable databases via Docker or in-memory servers to exercise real query behavior safely.
- Express error-handling middleware takes four parameters, (err, req, res, next), and can be tested directly or via a triggering request.
- Async route handlers must forward rejected promises to next(err), often via a wrapper, or errors will not reach error middleware.
- toMatchObject() is useful for asserting on a subset of a response body without requiring an exact full match.
Practice what you learned
1. What does supertest allow you to test without starting a real network-bound server?
2. Why would you mock the database layer in a route-handler unit test?
3. How many parameters must an Express error-handling middleware function have to be recognized as such?
4. What commonly goes wrong if an async Express route handler throws without being wrapped or without calling next(err)?
5. What is a benefit of using an in-memory or Docker-based disposable database for integration tests instead of always mocking?
Was this page helpful?
You May Also Like
Parameterized Tests with test.each()
Learn how to use Jest's test.each() and describe.each() to run the same test logic against multiple input/output pairs without duplicating code.
Jest Configuration: Multi-Project Setups
Learn how to configure Jest's projects array to run multiple test environments and configurations, like unit and integration suites, from a single Jest invocation.
Jest in Monorepos
Learn how to configure Jest to run efficiently across multiple packages in a monorepo, covering shared config, workspace-aware discovery, and CI caching.