Unit Testing with Jest
Unit tests verify a single piece of logic in isolation — a service function, a validator, or a controller — without spinning up an HTTP server or touching a real database. Jest is the most popular test runner for Node and Express projects because it bundles a test framework, an assertion library, mocking, and coverage into one zero-config tool. This page shows how to install Jest, test pure functions and Express controllers with mocked req/res objects, and structure your test files so the suite stays fast and maintainable.
Setting up Jest
Install Jest as a dev dependency and add a test script. For a CommonJS project no extra configuration is needed; Jest discovers any file ending in .test.js or .spec.js, or anything inside a __tests__ folder.
npm install --save-dev jest
Add the script to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
}
}
Run the suite:
npm test
Output:
PASS src/services/price.test.js
calculateTotal
✓ sums line items (2 ms)
✓ applies a percentage discount (1 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 0.612 s
If your project uses ES modules (
"type": "module"inpackage.json), run Jest withnode --experimental-vm-modules node_modules/jest/bin/jest.js, or add Babel viababel-jest. CommonJS requires no such workaround.
Testing pure functions
Pure functions — those that return a value based only on their inputs — are the easiest and most valuable things to unit test. Keep your business logic in plain service modules, free of req/res, so it can be tested directly.
// src/services/price.js
function calculateTotal(items, discountPct = 0) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return subtotal * (1 - discountPct / 100);
}
module.exports = { calculateTotal };
// src/services/price.test.js
const { calculateTotal } = require('./price');
describe('calculateTotal', () => {
test('sums line items', () => {
const items = [
{ price: 10, qty: 2 },
{ price: 5, qty: 1 },
];
expect(calculateTotal(items)).toBe(25);
});
test('applies a percentage discount', () => {
const items = [{ price: 100, qty: 1 }];
expect(calculateTotal(items, 10)).toBe(90);
});
});
describe groups related tests, test (or its alias it) declares a single case, and expect(...).matcher(...) makes an assertion. Common matchers are summarised below.
| Matcher | Passes when |
|---|---|
toBe(x) | strict === equality (primitives) |
toEqual(x) | deep structural equality (objects/arrays) |
toThrow(msg) | the wrapped function throws |
toHaveBeenCalledWith(...) | a mock was called with those args |
resolves / rejects | a promise settles as expected |
Testing controllers with mocked req/res
A controller is just a function of (req, res, next). To unit test one you build fake req and res objects, call the controller, then assert on how it used them. Because res.status() is chained with res.json(), the status mock must return the response object so the chain works.
// src/controllers/userController.js
const userService = require('../services/userService');
async function getUser(req, res, next) {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
res.status(200).json(user);
} catch (err) {
next(err);
}
}
module.exports = { getUser };
// src/controllers/userController.test.js
const { getUser } = require('./userController');
const userService = require('../services/userService');
jest.mock('../services/userService'); // auto-mock the module
function mockRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res); // enable chaining
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe('getUser controller', () => {
afterEach(() => jest.clearAllMocks());
test('returns 200 with the user when found', async () => {
userService.findById.mockResolvedValue({ id: '1', name: 'Ada' });
const req = { params: { id: '1' } };
const res = mockRes();
const next = jest.fn();
await getUser(req, res, next);
expect(userService.findById).toHaveBeenCalledWith('1');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ id: '1', name: 'Ada' });
expect(next).not.toHaveBeenCalled();
});
test('returns 404 when the user is missing', async () => {
userService.findById.mockResolvedValue(null);
const req = { params: { id: '99' } };
const res = mockRes();
await getUser(req, res, jest.fn());
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
});
test('forwards errors to next()', async () => {
const boom = new Error('db down');
userService.findById.mockRejectedValue(boom);
const next = jest.fn();
await getUser({ params: { id: '1' } }, mockRes(), next);
expect(next).toHaveBeenCalledWith(boom);
});
});
Mocks and spies
jest.fn() creates a mock function that records every call. jest.mock('module') replaces an entire module with auto-generated mocks so the controller never reaches the real service or database. When you only want to observe (and optionally override) an existing method, use jest.spyOn:
const logger = require('../utils/logger');
test('logs a warning on missing user', async () => {
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
// ... run code that should warn ...
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore(); // restore the original implementation
});
A spy created with
mockImplementationkeeps the original method swapped out until you callmockRestore(). Forgetting to restore it can leak mocked behaviour into later tests, so preferafterEach(() => jest.restoreAllMocks()).
Structuring test files
Co-locate each test beside the module it covers (price.js → price.test.js) so related code moves together and imports stay short. Reserve a top-level __tests__ directory only for cross-cutting integration suites. Use beforeEach/afterEach hooks to reset state, and keep each test focused on one behaviour so a failure points straight at the cause.
Best Practices
- Keep business logic in framework-agnostic service modules so it can be unit tested without
req/resor HTTP. - Always return the
resobject from mockedstatus()andjson()so chained calls behave like real Express. - Reset mocks between tests with
jest.clearAllMocks()inafterEachto avoid cross-test contamination. - Assert on
next(called or not called) to verify error-forwarding paths in controllers. - Prefer
toEqualfor objects and arrays, and reservetoBefor primitives and reference identity. - Use
--watchlocally for fast feedback and reserve full runs with coverage for CI. - Test edge cases (empty inputs, nulls, rejections), not just the happy path.