TDD, Mocking & Spying in Node.js

TDD, Mocking & Spying in Node.js
Tests that only run after code is written catch bugs. Tests written before code is written prevent bugs. Test-Driven Development flips the sequence — and in doing so, it changes the way you think about design. You write the interface you wish existed, then make the implementation satisfy it.
This module covers the TDD workflow, Jest mocks and spies in depth, module mocking, fake timers, and testing patterns that produce readable, maintainable test suites.
This is Module 28 of the Node.js Full‑Stack Developer course.
The TDD Cycle
1. Red — Write a failing test for the feature you want
2. Green — Write the minimum code to make the test pass
3. Refactor — Improve the code; tests must still passRepeat. The tests accumulate as living documentation of every decision.
TDD Example: Building a Password Reset Service
Step 1: Red — Write the failing test
// features/auth/password-reset.service.test.js
import { requestPasswordReset, resetPassword } from './password-reset.service.js';
// These imports do not exist yet — that's the point
jest.mock('../../lib/email.js');
jest.mock('../../lib/redis.js');
describe('Password Reset Service', () => {
test('requestPasswordReset stores a token and sends an email', async () => {
const { sendPasswordResetEmail } = await import('../../lib/email.js');
const redis = (await import('../../lib/redis.js')).default;
await requestPasswordReset('user@example.com');
// Redis should store a token keyed by email
expect(redis.set).toHaveBeenCalledWith(
expect.stringContaining('password-reset:'),
expect.any(String),
'EX',
3600 // 1 hour TTL
);
// Email service should be called
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
'user@example.com',
expect.any(String)
);
});
test('resetPassword throws if token is invalid', async () => {
const redis = (await import('../../lib/redis.js')).default;
redis.get.mockResolvedValueOnce(null); // token not found
await expect(
resetPassword('invalid-token', 'newpassword123')
).rejects.toMatchObject({ statusCode: 400, message: /invalid or expired/i });
});
});Run npm test → 🔴 fails (the module does not exist).
Step 2: Green — Write the implementation
// features/auth/password-reset.service.js
import crypto from 'crypto';
import redis from '../../lib/redis.js';
import { sendPasswordResetEmail } from '../../lib/email.js';
import { User } from '../users/users.model.js';
import { hashPassword } from '../../lib/crypto.js';
import { AppError } from '../../lib/errors.js';
export async function requestPasswordReset(email) {
const user = await User.findOne({ email });
if (!user) return; // Silent — don't reveal if email exists
const token = crypto.randomBytes(32).toString('hex');
await redis.set(`password-reset:${token}`, user._id.toString(), 'EX', 3600);
await sendPasswordResetEmail(email, token);
}
export async function resetPassword(token, newPassword) {
const userId = await redis.get(`password-reset:${token}`);
if (!userId) throw new AppError('Invalid or expired reset token', 400);
const user = await User.findById(userId);
if (!user) throw new AppError('User not found', 404);
user.password = await hashPassword(newPassword);
await user.save();
await redis.del(`password-reset:${token}`);
}Run npm test → 🟢 passes.
Step 3: Refactor
The implementation is clean already. Add edge cases to the tests:
test('requestPasswordReset is silent for unknown email (no error, no email)', async () => {
const { sendPasswordResetEmail } = await import('../../lib/email.js');
await requestPasswordReset('nobody@example.com');
expect(sendPasswordResetEmail).not.toHaveBeenCalled();
});
test('resetPassword deletes the token after use', async () => {
const redis = (await import('../../lib/redis.js')).default;
redis.get.mockResolvedValueOnce('user-id-123');
// ... setup user ...
await resetPassword('valid-token', 'newpassword123');
expect(redis.del).toHaveBeenCalledWith('password-reset:valid-token');
});jest.fn() — Mock Functions
// Create a mock function
const mockSend = jest.fn();
// Default return value
const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: () => ({ data: [] }) });
// Return different values on successive calls
const mockRandom = jest.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.9)
.mockReturnValue(0.5); // default for subsequent calls
// Mock implementation
const mockHash = jest.fn().mockImplementation(async (text) => `hashed:${text}`);
// Assertions
expect(mockSend).toHaveBeenCalled();
expect(mockSend).toHaveBeenCalledTimes(2);
expect(mockSend).toHaveBeenCalledWith('alice@example.com', expect.any(String));
expect(mockSend).toHaveBeenLastCalledWith('bob@example.com', 'Reset your password');
expect(mockSend).toHaveBeenNthCalledWith(1, 'alice@example.com', expect.anything());
// Reset between tests
mockSend.mockReset(); // clears calls + implementation
mockSend.mockClear(); // clears calls only
mockSend.mockRestore(); // restores original (spies only)jest.spyOn() — Spies
A spy watches an existing function without replacing it (unless you tell it to):
import * as crypto from '../../lib/crypto.js';
test('createUser hashes the password', async () => {
// Spy on the real hashPassword — records calls, still runs original
const spy = jest.spyOn(crypto, 'hashPassword');
await createUser({ name: 'Alice', email: 'a@example.com', password: 'plain' });
expect(spy).toHaveBeenCalledWith('plain');
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore(); // must restore to avoid leaking into other tests
});
// Replace the implementation
jest.spyOn(crypto, 'hashPassword').mockResolvedValue('$2b$12$mocked');jest.mock() — Module Mocking
// Mock an entire module before any imports
jest.mock('../../lib/email.js', () => ({
sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
}));
// Auto-mock — replaces all exports with jest.fn()
jest.mock('../../lib/email.js');
// Access mock in tests
import { sendWelcomeEmail } from '../../lib/email.js';
expect(sendWelcomeEmail).toHaveBeenCalled();
// Override for a single test
sendWelcomeEmail.mockRejectedValueOnce(new Error('SMTP failure'));Partial Mocking
// Keep some methods real, mock others
jest.mock('../../lib/redis.js', () => {
const actual = jest.requireActual('../../lib/redis.js');
return {
...actual,
default: {
...actual.default,
get: jest.fn(),
set: jest.fn(),
},
};
});Fake Timers
describe('Token expiry', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('refresh token expires after 7 days', async () => {
const token = signRefreshToken({ sub: 'user-1', role: 'user', version: 0 });
// Advance time by 8 days
jest.advanceTimersByTime(8 * 24 * 60 * 60 * 1000);
await expect(verifyRefreshToken(token)).toThrow(/expired/);
});
});
// Test a debounced function
test('debounced search fires once after 300ms', () => {
jest.useFakeTimers();
const mockSearch = jest.fn();
const debouncedSearch = debounce(mockSearch, 300);
debouncedSearch('a');
debouncedSearch('ab');
debouncedSearch('abc');
// Not called yet — debounce window hasn't passed
expect(mockSearch).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
// Called once with the last value
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith('abc');
jest.useRealTimers();
});Testing Error Handling
// Synchronous throw
test('throws for invalid id format', () => {
expect(() => parseUserId('not-an-objectid')).toThrow('Invalid ID');
});
// Async rejection — correct pattern
test('rejects with 404 for unknown user', async () => {
await expect(getUserById('000000000000000000000000'))
.rejects.toMatchObject({ statusCode: 404, message: 'User not found' });
});
// Common mistake — do NOT do this:
test('WRONG — will not catch the rejection', async () => {
try {
const result = await getUserById('bad-id'); // throws here
// expect below never runs
expect(result).toBe(null);
} catch (err) {
// Error swallowed — test passes even if wrong error is thrown
}
});
// Use expect.assertions to ensure async errors are caught
test('throws when service fails', async () => {
expect.assertions(1); // test fails if no assertion runs
try {
await riskyOperation();
} catch (err) {
expect(err.message).toMatch(/failed/);
}
});Snapshot Testing for API Responses
test('returns expected user shape', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({ name: 'Alice', email: 'alice@example.com', password: 'password123' });
// First run: creates the snapshot file
// Subsequent runs: fails if shape changes
expect(res.body.user).toMatchSnapshot();
});Snapshots are useful for catching unintended response shape changes. Update them intentionally with jest --updateSnapshot.
Test Factories (Seed Helpers)
// tests/factories/user.factory.js
import { User } from '../../src/features/users/users.model.js';
import { hashPassword } from '../../src/lib/crypto.js';
let counter = 0;
export async function createTestUser(overrides = {}) {
counter++;
const defaults = {
name: `Test User ${counter}`,
email: `user${counter}@test.com`,
password: await hashPassword('password123'),
role: 'user',
};
return User.create({ ...defaults, ...overrides });
}
export async function createTestAdmin(overrides = {}) {
return createTestUser({ role: 'admin', ...overrides });
}// Usage in tests
const admin = await createTestAdmin();
const user = await createTestUser({ email: 'specific@test.com' });Node.js Full‑Stack Course — Module 28 of 32
You now write tests that drive design. Continue to Module 29 to master environment variables and configuration management.
Summary
TDD and advanced Jest techniques produce a test suite that is an asset, not a burden:
- TDD cycle: Red → Green → Refactor — write the test first, make it pass, then improve
jest.fn()creates standalone mock functions with controllable return values and recorded callsjest.spyOn()wraps real functions to record calls while optionally replacing the implementationjest.mock()replaces entire modules before they are imported — the only way to mock ES modules reliably- Fake timers let you test time-dependent code (debounce, retry, expiry) without real delays
- Always
awaitasync assertions insideexpect().rejects— never in a try/catch that swallows errors - Use
expect.assertions(N)in async error tests to guarantee the assertion runs - Test factories produce consistent, numbered test data and keep test setup readable
Continue to Module 29: Environment Variables & Configuration →
