Unit tests for Express applications focus on the business logic inside service and utility functions — the code that transforms data, applies rules, and makes decisions — in complete isolation from the database, HTTP layer, and external services. Jest is the go-to test runner for Node.js, providing a fast test runner, built-in mocking, snapshot testing, and a clean assertion API. Testing service methods in isolation is the fastest way to get comprehensive coverage of your application’s business rules.
Jest Core API for Node.js
| Function | Purpose | Example |
|---|---|---|
describe(name, fn) |
Group related tests | describe('TaskService', () => { ... }) |
it(name, fn) / test() |
Individual test case | it('should hash password', async () => { ... }) |
expect(value) |
Start an assertion chain | expect(result).toBe(42) |
beforeEach(fn) |
Run before each test in describe block | Set up mocks, create test data |
afterEach(fn) |
Run after each test | Clean up mocks, restore state |
jest.fn() |
Create a mock function | const mockSave = jest.fn().mockResolvedValue(user) |
jest.spyOn(obj, 'method') |
Spy on an existing method | jest.spyOn(bcrypt, 'hash').mockResolvedValue('hashed') |
jest.mock('module') |
Auto-mock an entire module | jest.mock('../services/email.service') |
jest.clearAllMocks() |
Reset all mock call history | In afterEach |
Common Jest Matchers
| Matcher | Asserts |
|---|---|
.toBe(val) |
Strict equality (===) |
.toEqual(val) |
Deep equality (objects/arrays) |
.toMatchObject(partial) |
Object contains at least these properties |
.toBeTruthy() / .toBeFalsy() |
Truthy/falsy value |
.toBeNull() |
Strictly null |
.toContain(item) |
Array contains item or string contains substring |
.toHaveLength(n) |
Array or string length |
.toThrow(error) |
Function throws an error |
.rejects.toThrow() |
Async function rejects |
.resolves.toEqual(val) |
Async function resolves with value |
.toHaveBeenCalledWith(args) |
Mock was called with specific args |
.toHaveBeenCalledTimes(n) |
Mock was called n times |
jest.mock('../services/email.service') replaces every export from the module with a jest.fn() that returns undefined by default. This is the cleanest way to prevent side effects (sending actual emails, calling external APIs) during unit tests. The mock is hoisted to the top of the test file — even before imports. If you need to control what the mock returns, use mockResolvedValue() or mockReturnValue() on the function.User.findOne() internally, do not assert expect(User.findOne).toHaveBeenCalledWith({ email }) unless you specifically need to verify the query. Instead, test what the caller cares about: the return value, the state change, or the error thrown. Tests that are coupled to implementation details break whenever you refactor, even when the behaviour is unchanged.Complete Unit Test Examples
// ── Unit tests: auth service ──────────────────────────────────────────────
// src/__tests__/unit/auth.service.test.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
// Module under test
const { hashPassword, verifyPassword, generateTokenPair, verifyToken }
= require('../../services/auth.service');
describe('Auth Service', () => {
describe('hashPassword()', () => {
it('should return a bcrypt hash', async () => {
const hash = await hashPassword('MyPassword123!');
expect(hash).not.toBe('MyPassword123!');
expect(hash).toMatch(/^\$2[aby]\$\d{2}\$/); // bcrypt hash format
});
it('should produce different hashes for the same password', async () => {
const [h1, h2] = await Promise.all([
hashPassword('same-password'),
hashPassword('same-password'),
]);
expect(h1).not.toBe(h2); // different salts → different hashes
});
it('should reject empty password', async () => {
await expect(hashPassword('')).rejects.toThrow();
});
});
describe('verifyPassword()', () => {
let hash;
beforeAll(async () => { hash = await bcrypt.hash('correct-password', 12); });
it('should return true for correct password', async () => {
expect(await verifyPassword('correct-password', hash)).toBe(true);
});
it('should return false for wrong password', async () => {
expect(await verifyPassword('wrong-password', hash)).toBe(false);
});
});
describe('generateTokenPair()', () => {
const mockUser = { _id: '64a1f2b3c8e4d5f6a7b8c9d1', email: 'test@test.com', role: 'user' };
it('should return accessToken and refreshToken', () => {
const { accessToken, refreshToken } = generateTokenPair(mockUser);
expect(accessToken).toBeDefined();
expect(refreshToken).toBeDefined();
expect(typeof accessToken).toBe('string');
});
it('should embed user data in access token', () => {
const { accessToken } = generateTokenPair(mockUser);
const decoded = jwt.decode(accessToken);
expect(decoded.sub).toBe(mockUser._id.toString());
expect(decoded.role).toBe(mockUser.role);
});
it('should set short expiry on access token', () => {
const { accessToken } = generateTokenPair(mockUser);
const decoded = jwt.decode(accessToken);
const expiresInSeconds = decoded.exp - decoded.iat;
expect(expiresInSeconds).toBeLessThanOrEqual(60 * 60); // max 1 hour
});
});
});
// ── Unit tests: task query builder ────────────────────────────────────────
// src/__tests__/unit/task-query.test.js
const { parseTaskQuery } = require('../../utils/task-query.util');
describe('parseTaskQuery()', () => {
it('should use safe defaults', () => {
const result = parseTaskQuery({});
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
expect(result.sort).toEqual({ createdAt: -1 });
expect(result.filter).not.toHaveProperty('status');
});
it('should parse valid status filter', () => {
const { filter } = parseTaskQuery({ status: 'pending' });
expect(filter.status).toBe('pending');
});
it('should ignore invalid status', () => {
const { filter } = parseTaskQuery({ status: 'invalid-status' });
expect(filter).not.toHaveProperty('status');
});
it('should cap limit at 50', () => {
expect(parseTaskQuery({ limit: '999' }).limit).toBe(50);
});
it('should reject sort on disallowed field', () => {
const { sort } = parseTaskQuery({ sort: 'password' });
expect(sort).toEqual({ createdAt: -1 }); // falls back to default
});
it('should parse descending sort', () => {
const { sort } = parseTaskQuery({ sort: '-priority' });
expect(sort).toEqual({ priority: -1 });
});
it('should build text search filter', () => {
const { filter } = parseTaskQuery({ q: 'client meeting' });
expect(filter.$text).toEqual({ $search: 'client meeting' });
});
it('should sanitise overly long search query', () => {
const { filter } = parseTaskQuery({ q: 'a'.repeat(200) });
expect(filter.$text.$search.length).toBeLessThanOrEqual(100);
});
it('should parse date range', () => {
const { filter } = parseTaskQuery({ from: '2025-01-01', to: '2025-01-31' });
expect(filter.createdAt.$gte).toEqual(new Date('2025-01-01'));
expect(filter.createdAt.$lte).toEqual(new Date('2025-01-31'));
});
});
// ── Unit tests: auth middleware ────────────────────────────────────────────
// src/__tests__/unit/auth.middleware.test.js
const jwt = require('jsonwebtoken');
const { verifyAccessToken } = require('../../middleware/auth.middleware');
describe('verifyAccessToken middleware', () => {
let req, res, next;
const SECRET = 'test-secret';
beforeEach(() => {
process.env.JWT_SECRET = SECRET;
req = { headers: {} };
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
next = jest.fn();
});
afterEach(() => jest.clearAllMocks());
it('should call next() with valid token', () => {
const token = jwt.sign({ sub: '123', role: 'user' }, SECRET, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${token}`;
verifyAccessToken(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user.sub).toBe('123');
});
it('should return 401 when no token', () => {
verifyAccessToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('should return 401 for expired token', () => {
const token = jwt.sign({ sub: '123' }, SECRET, { expiresIn: '-1s' });
req.headers.authorization = `Bearer ${token}`;
verifyAccessToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
const body = res.json.mock.calls[0][0];
expect(body.code).toBe('TOKEN_EXPIRED');
});
it('should return 401 for tampered token', () => {
const token = jwt.sign({ sub: '123', role: 'user' }, SECRET);
const tampered = token.slice(0, -5) + 'xxxxx';
req.headers.authorization = `Bearer ${tampered}`;
verifyAccessToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
});
How It Works
Step 1 — Describe Blocks Organise Tests by Feature
Nested describe() blocks create a hierarchy that mirrors the code structure. describe('TaskService') > describe('parseTaskQuery()') > it('should use safe defaults') produces readable test output: TaskService > parseTaskQuery() > should use safe defaults. This makes failing test output immediately point to the exact feature and scenario that broke.
Step 2 — jest.fn() Creates Controllable Mock Dependencies
A mock function records every call made to it — arguments, return values, call count. mockResolvedValue(data) makes it return a resolved Promise with data. mockRejectedValue(new Error()) makes it throw. This allows testing how your code handles various responses from dependencies (success, error, empty) without those dependencies actually running.
Step 3 — Test the Contract, Not the Implementation
The best unit tests describe the function’s contract from the caller’s perspective: “given this input, return this output” or “given this condition, throw this error.” Tests that assert on internal implementation (which internal method was called, how many times, with which sub-expressions) become test maintenance debt — they break whenever the implementation changes even if the contract is unchanged.
Step 4 — Async Tests Require Proper async/await
Jest handles async tests via either returning a Promise or using async/await in the test function. Without awaiting the async function, the test completes synchronously before the assertion runs — always passing even if the function throws. Always declare async test functions with async and await all async operations. For error cases, use await expect(fn()).rejects.toThrow().
Step 5 — beforeEach and afterEach Manage Shared State
beforeEach resets mock implementations and shared variables before every test — preventing test pollution where mock state from test A affects test B. jest.clearAllMocks() in afterEach clears the call history of all mocks. jest.restoreAllMocks() restores any methods that were spied on with spyOn back to their original implementations.
Common Mistakes
Mistake 1 — Forgetting to await async operations
❌ Wrong — test passes without actually running the assertion:
it('should hash password', () => {
const hash = hashPassword('test'); // returns Promise — not awaited!
expect(hash).not.toBe('test'); // asserts on a Promise object, not the hash
// Test passes (Promise is truthy and !== 'test') but is meaningless
});
✅ Correct — await the async function:
it('should hash password', async () => {
const hash = await hashPassword('test'); // resolved value
expect(hash).not.toBe('test'); // correct assertion on the string
});
Mistake 2 — Testing implementation rather than behaviour
❌ Wrong — breaks if internal implementation changes:
expect(Task.findOne).toHaveBeenCalledWith({ _id: id, user: userId });
// Refactor to findById — test breaks even though result is the same!
✅ Correct — test the return value:
const task = await taskService.getById(id, userId);
expect(task._id.toString()).toBe(id);
expect(task.user.toString()).toBe(userId);
Mistake 3 — Not clearing mocks between tests
❌ Wrong — mock call count accumulates across tests:
it('test 1', () => { emailService.send(); expect(emailService.send).toHaveBeenCalledTimes(1); });
it('test 2', () => { emailService.send(); expect(emailService.send).toHaveBeenCalledTimes(1); });
// Test 2 fails — mock was called twice total (1 from test 1 + 1 from test 2)
✅ Correct — clear mocks in afterEach:
afterEach(() => jest.clearAllMocks());
Quick Reference
| Task | Jest Code |
|---|---|
| Mock function | const mockFn = jest.fn().mockResolvedValue(data) |
| Spy on method | jest.spyOn(obj, 'method').mockResolvedValue(data) |
| Mock entire module | jest.mock('../service') |
| Assert call args | expect(mock).toHaveBeenCalledWith(arg1, arg2) |
| Assert call count | expect(mock).toHaveBeenCalledTimes(1) |
| Assert async throws | await expect(fn()).rejects.toThrow('message') |
| Assert async resolves | await expect(fn()).resolves.toEqual(expected) |
| Deep equality | expect(obj).toEqual({ key: 'value' }) |
| Partial match | expect(obj).toMatchObject({ key: 'value' }) |
| Clear mocks | afterEach(() => jest.clearAllMocks()) |