Node.js Intermediate
Chapter 7 of 8 · Testing in Node.js
Testing in Node.js
Tests aren't just a safety net — they're the fastest way to get feedback on your code. A well-tested module can be refactored boldly; an untested one has to be touched carefully and deployed nervously. This chapter covers Jest, the dominant test runner in the Node ecosystem, from the basics of writing and structuring tests through mocking, async testing, and code coverage.
Test Types
Unit tests
Test a single function or class in isolation. Dependencies (database, network, file system) are replaced with mocks. Fast — thousands can run in seconds. The bulk of your test suite.
Integration tests
Test that two or more modules work correctly together — typically your code against a real (test) database. Slower, but catch bugs unit tests miss. Run before every commit or PR.
End-to-end tests
Drive the full application via HTTP (supertest) or a browser (Playwright/Cypress). Slowest and most brittle. Reserve for critical user journeys.
The rule of thumb
Write many unit tests, some integration tests, and few E2E tests. The faster a test is, the more of them you run, and the faster you get feedback.
Setting Up Jest
npm install --save-dev jest
package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/__tests__/**/*.js", "**/*.test.js"],
"coverageDirectory": "coverage",
"collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"]
}
}
💡 File Naming Convention
The two common patterns are co-location (src/users.js + src/users.test.js) and a dedicated folder (src/users.js + __tests__/users.test.js). Co-location is generally preferred for unit tests — the test lives next to what it tests, making it obvious when a file has no test.
The Basic Structure — describe / it / expect
function add(a, b) { return a + b; }
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
module.exports = { add, divide };
math.test.js
const { add, divide } = require('./math');
describe('add()', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, -3)).toBe(-4);
});
});
describe('divide()', () => {
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
The Most Useful Matchers
expect(value).toBe(42);
expect(obj).toEqual({ a: 1 });
expect(obj).toStrictEqual({...});
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThanOrEqual(100);
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(str).toContain('hello');
expect(str).toMatch(/^error:/i);
expect(arr).toHaveLength(3);
expect(arr).toContain('item');
expect(obj).toHaveProperty('user.email', 'alice@example.com');
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('specific message');
expect(() => fn()).toThrow(TypeError);
expect(value).not.toBe(null);
expect(arr).not.toContain('deleted-item');
Setup and Teardown
describe('UserService', () => {
let service;
beforeAll(async () => {
await seedTestDatabase();
});
afterAll(async () => {
await pool.end();
});
beforeEach(() => {
service = new UserService();
});
afterEach(async () => {
await clearTestUsers();
jest.clearAllMocks();
});
it('creates a user', async () => {
const id = await service.create({ name: 'Alice', email: 'a@b.com' });
expect(id).toBeDefined();
});
});
Testing Async Code
it('fetches a user by ID', async () => {
const user = await findUserById(1);
expect(user).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' });
});
it('throws when user is not found', async () => {
await expect(findUserById(9999)).rejects.toThrow('User not found');
});
it('throws a specific error type', async () => {
try {
await findUserById(9999);
expect.fail();
} catch (err) {
expect(err).toBeInstanceOf(NotFoundError);
expect(err.message).toContain('9999');
}
});
Mocking — jest.fn() and jest.mock()
Unit tests should run without real databases, real email servers, or real APIs. Mocking lets you replace dependencies with controlled fakes that record how they were called.
const sendEmail = jest.fn();
sendEmail.mockResolvedValue({ messageId: 'abc123' });
await sendEmail({ to: 'bob@example.com', subject: 'Hello' });
expect(sendEmail).toHaveBeenCalledTimes(1);
expect(sendEmail).toHaveBeenCalledWith({
to: 'bob@example.com',
subject: 'Hello',
});
expect(sendEmail).toHaveBeenLastCalledWith(expect.objectContaining({ to: 'bob@example.com' }));
jest.mock('./emailService');
const emailService = require('./emailService');
emailService.send.mockResolvedValue({ sent: true });
jest.mock('./db', () => ({
execute: jest.fn().mockResolvedValue([[{ id: 1, name: 'Alice' }]]),
end: jest.fn(),
}));
const fs = require('fs');
const readSpy = jest.spyOn(fs, 'readFileSync')
.mockReturnValue('mocked file content');
expect(parseConfig('./config.json')).toEqual({ ... });
expect(readSpy).toHaveBeenCalledWith('./config.json', 'utf8');
readSpy.mockRestore();
A Real Unit Test — UserService with Mocked DB
userService.test.js
jest.mock('./db');
const db = require('./db');
const UserService = require('./userService');
describe('UserService.findById', () => {
afterEach(() => jest.clearAllMocks());
it('returns the user when found', async () => {
db.execute.mockResolvedValue([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]);
const user = await UserService.findById(1);
expect(user).toEqual({ id: 1, name: 'Alice', email: 'a@b.com' });
expect(db.execute).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = ? LIMIT 1',
[1]
);
});
it('returns null when user is not found', async () => {
db.execute.mockResolvedValue([[]]);
const user = await UserService.findById(999);
expect(user).toBeNull();
});
it('propagates database errors', async () => {
db.execute.mockRejectedValue(new Error('Connection lost'));
await expect(UserService.findById(1))
.rejects.toThrow('Connection lost');
});
});
Code Coverage
npx jest --coverage
💡 Coverage Is a Tool, Not a Target
100% coverage doesn't mean your code is correct — it means every line was executed at least once. A test that calls a function without asserting anything gives coverage without confidence. Chase meaningful assertions, not a coverage number. A good starting goal for most projects is 70–80% branch coverage on business logic, with the gaps consciously chosen (e.g., error paths that are genuinely hard to trigger).
Integration Testing with a Real Database
module.exports = {
projects: [
{ displayName: 'unit', testMatch: ['**/*.test.js'] },
{ displayName: 'integration', testMatch: ['**/*.int-test.js'],
globalSetup: './tests/setup/globalSetup.js',
globalTeardown: './tests/setup/globalTeardown.js' },
],
};
tests/setup/globalSetup.js
module.exports = async () => {
process.env.DB_NAME = 'app_test';
await runMigrations();
await seedFixtures();
};
userRepository.int-test.js
const { createUser, findUserById, deleteUser } = require('./userRepository');
const pool = require('./db');
afterAll(async () => pool.end());
describe('UserRepository (integration)', () => {
let createdId;
it('creates and retrieves a user', async () => {
createdId = await createUser({ name: 'Bob', email: 'bob@test.com' });
const user = await findUserById(createdId);
expect(user.name).toBe('Bob');
expect(user.email).toBe('bob@test.com');
});
afterEach(async () => {
if (createdId) await deleteUser(createdId);
});
});
⚠️ Common Testing Mistakes
Tests that depend on each other — each test must be able to run in any order and in isolation. Shared state between tests causes flaky results that are very hard to debug. Use beforeEach/afterEach to reset state.
Asserting implementation, not behaviour — checking that a specific private method was called makes tests brittle. Test what the function returns or what side effects it causes, not how it does it internally.
Not calling jest.clearAllMocks() between tests — mock call counts accumulate across tests in the same file unless you reset them. jest.clearAllMocks() in afterEach prevents one test's calls from polluting the next test's toHaveBeenCalledTimes assertion.
Forgetting to await async matchers — expect(promise).rejects.toThrow() returns a Promise. Without await, the test passes regardless of whether the assertion holds.
Coding Challenges
Challenge 1 — Test a Password Utility
Write a passwordUtils.js module with two functions: hashPassword(plain) (uses bcrypt with 10 salt rounds, returns a Promise) and verifyPassword(plain, hash) (returns a Promise resolving to a boolean). Then write a complete Jest test suite covering: correct passwords verify, wrong passwords don't, the hash is never equal to the plain text, and the hash is different on every call even for the same input. Use npm install bcrypt.
View sample solution ↗
Challenge 2 — Mock an External API
Write a weatherService.js that fetches current weather from an external HTTP API using Node's built-in fetch (Node 18+). Then write tests that mock fetch using jest.spyOn(global, 'fetch') to test: successful response returns parsed weather data, a 404 throws a NotFoundError, a network failure (fetch rejects) throws a NetworkError, and the correct URL was called with the city name. No real HTTP requests should be made during the test run.
View sample solution ↗
Challenge 3 — Test a Rate Limiter
Write a RateLimiter class that allows at most N calls per windowMs milliseconds per key (e.g. an IP address). The check(key) method returns { allowed: boolean, remaining: number, resetAt: number }. Write tests covering: first call is allowed, calls within the limit are allowed, the (N+1)th call is denied, and the window resets after windowMs. Use jest.useFakeTimers() and jest.advanceTimersByTime() to control time without actually waiting.
View sample solution ↗