
Most Express APIs start life tested by hand with curl. That works for five routes, and then it stops working. Here I write about how I replace those manual checks with Jest and Supertest: real HTTP requests through the full Express stack, run in about a second, every time I save.
The examples run on Node 10 LTS with Jest 23 and Supertest 3. The app under test is a small task API with Joi validation, JSON error responses and JWT-protected routes. All the output below comes from real runs, including the failures.
Why I test at the HTTP boundary with Jest and Supertest
A unit test that calls a controller with a fake req and res mostly tests the fakes. Regressions in an API rarely live inside one function. Instead, they show up in routing, middleware order, validation and the error format.
Supertest sends a real request through all of that. Meanwhile, Jest gives me the runner, the assertions and coverage. Together, Jest and Supertest cover the layer where my bugs actually happen. I still write a few unit tests, but only for logic with real branching.
Setup: install and configure Jest
Install both as dev dependencies:
npm install --save-dev jest@23 supertest@3
Then add the config to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --runInBand --coverage"
},
"jest": {
"testEnvironment": "node",
"collectCoverageFrom": ["src/**/*.js", "!src/server.js"],
"coverageThreshold": {
"global": { "branches": 80, "lines": 90 }
}
}
}
The testEnvironment line matters more than it looks. Jest 23 defaults to jsdom, a fake browser. That default suits React code, but it slows down a server suite and hides differences in globals. An API should always test in the node environment.
The app factory makes Jest and Supertest easy
Supertest needs the Express app, not a running server. That is why I split the code into app.js, which builds the app, and server.js, which calls listen. I explain that layout in building REST API endpoints in Node.js and Express. The factory also accepts its dependencies:
function createApp({ tasksService = createTasksService() } = {}) {
const app = express();
app.use(express.json({ limit: '100kb' }));
app.use('/tasks', createTasksRouter(createTasksController(tasksService)));
return app;
}
Because of this, each test gets a fresh app with fresh data. Also, a test can pass in a broken service to force an error path. No port is opened, so tests never fight over port 3000.
Writing the first Jest and Supertest tests
Here is the first test file, test/tasks.test.js:
const request = require('supertest');
const { createApp } = require('../src/app');
const { createTasksService } = require('../src/services/tasks.service');
describe('tasks API', () => {
let app;
beforeEach(() => { app = createApp(); });
test('POST /tasks creates a task and returns 201 with Location', async () => {
const res = await request(app)
.post('/tasks')
.send({ title: 'Write the report' })
.expect(201);
expect(res.headers.location).toBe(`/tasks/${res.body.id}`);
expect(res.body).toMatchObject({ title: 'Write the report', done: false });
});
test('POST /tasks rejects unknown fields', async () => {
const res = await request(app)
.post('/tasks')
.send({ title: 'Valid title', done: true })
.expect(400);
expect(res.body.issues).toEqual([
{ path: 'body.done', message: '"done" is not allowed' }
]);
});
test('GET /tasks?done=false filters', async () => {
await request(app).post('/tasks').send({ title: 'one' }).expect(201);
const res = await request(app).get('/tasks?done=false').expect(200);
expect(res.headers['x-total-count']).toBe('1');
});
test('GET /tasks/:id returns 404 for a valid but unknown id', async () => {
await request(app)
.get('/tasks/2f1c3f5e-8a77-4c1e-9b7e-1d2b3c4d5e6f')
.expect(404);
});
test('a failing service returns 500', async () => {
const broken = Object.assign(createTasksService(), {
list() { throw new Error('db down'); }
});
const res = await request(createApp({ tasksService: broken })).get('/tasks');
expect(res.status).toBe(500);
});
});
Running npx jest gives this:
tasks API
✓ POST /tasks creates a task and returns 201 with Location (24ms)
✓ POST /tasks rejects unknown fields (3ms)
✓ GET /tasks?done=false filters (4ms)
✓ GET /tasks/:id returns 404 for a valid but unknown id (2ms)
✓ a failing service returns 500 (8ms)
Tests: 5 passed, 5 total
Time: 0.631s
Notice what these Jest and Supertest tests check. They assert on status codes, headers and the error body, because clients depend on those. They do not check how the controller calls the service.
The trap: a green test that checks nothing
This test looks like it expects a 500:
test('looks like it checks for a 500', () => {
request(createApp()).get('/tasks').expect(500);
});
In reality, GET /tasks returns 200, and the test still passes:
✓ looks like it checks for a 500 (4ms)
Tests: 1 passed, 1 total
The request is a promise, and nobody waits for it. So Jest finishes the test before the response arrives. The fix is to await or return the request. I use async functions for every Supertest test, so the habit sticks. Also, I break each new test on purpose once, to see it fail.
Testing protected routes
Routes behind JWT authentication in Express need a token. Logging in through the API in every test is slow, so I sign tokens directly in a helper:
// test/helpers/auth.js
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
function tokenFor(user = { id: 'u1', role: 'user' }, options = {}) {
return jwt.sign({ role: user.role }, SECRET, {
subject: user.id,
expiresIn: '5m',
algorithm: 'HS256',
...options
});
}
module.exports = { tokenFor };
Then I test the cases that matter for security:
test('rejects a missing token', async () => {
await request(app).get('/tasks').expect(401);
});
test('rejects an expired token', async () => {
const token = tokenFor(undefined, { expiresIn: '-1s' });
await request(app).get('/tasks').set('Authorization', `Bearer ${token}`).expect(401);
});
test('only an admin can delete a task', async () => {
await request(app)
.delete('/tasks/2f1c3f5e-8a77-4c1e-9b7e-1d2b3c4d5e6f')
.set('Authorization', `Bearer ${tokenFor()}`)
.expect(403);
});
Set JWT_SECRET for the test run with a .env.test file, the same way I manage environment variables with dotenv. Never reuse the production secret in tests.
Mocks, spies and error paths
With Jest and Supertest, injection covers most error paths without any mocking library. Still, jest.spyOn helps when I want to check a side effect, such as logging:
test('logs unexpected errors', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
// request that triggers a 500 ...
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
I keep mocks at the edges: the database, the clock and outside HTTP calls. Mocking my own modules usually means the test knows too much about the code.
Running Jest and Supertest against a real database
Mocks pass while SQL breaks, and Jest and Supertest cannot catch that on their own. A missing column or a unique constraint only fails against a real PostgreSQL. So my integration suite runs against a throwaway database in Docker:
docker run --rm -d -p 5433:5432 -e POSTGRES_PASSWORD=test postgres:11
Then three rules keep the suite stable. First, run with --runInBand, so test files do not race on the same tables. Second, clear the tables in beforeEach with TRUNCATE tasks RESTART IDENTITY CASCADE. Third, close the pool in afterAll with pool.end(). Otherwise, Jest waits on open connections. Idle clients in a PostgreSQL connection pool keep the process alive until the pool ends.
If Jest prints “Jest did not exit one second after the test run has completed”, run it with --detectOpenHandles. It points at the server, timer or socket that is still open.
Coverage as a gate, not a goal
Jest also measures coverage for the Jest and Supertest suite. The coverageThreshold setting turns it into a build check. With only the five tests above, the run fails:
Jest: "global" coverage threshold for branches (80%) not met: 55.56%
Jest: "global" coverage threshold for lines (90%) not met: 83.1%
That failure is useful, because the report lists the uncovered lines. In this case, they were the update and delete branches. However, I do not chase 100%. A suite at 85% that tests bad input and failures beats a suite at 100% that only tests happy paths.
Common mistakes with Jest and Supertest
- Forgetting to await the request. The test passes no matter what.
- Leaving the default jsdom environment. Set
testEnvironmenttonode. - Calling listen in the app module. Tests then open real ports and clash.
- Sharing state between tests. Build a fresh app or truncate tables in
beforeEach. - Only testing the happy path. Most bugs live in 400, 401, 404 and 500 responses.
FAQ
Do I need to start the server to use Supertest?
No. Pass the Express app to request(app). Supertest binds it to a temporary port for each request, so you never call listen in tests.
Why does Jest hang after my tests finish?
Something is still open, usually a database pool, a server or a timer. Close it in afterAll, and use --detectOpenHandles to find it.
Should I use Mocha or Jest for a Node.js API?
Both work well. I pick Jest because the runner, assertions, mocks and coverage come in one package with almost no setup.
How many unit tests should an API have?
Fewer than you might think. I write HTTP-level tests for every route and unit tests only for logic with real branching, such as pricing or permissions.
A test you have never seen fail is a test you cannot trust, so I break each one once on purpose. Test at the HTTP boundary and await every request. Then run the integration suite against a real database, so Jest and Supertest catch regressions before your clients do.
Last updated on 31 October 2026