Day 4: Mocks, Stubs, and Spies
What You'll Learn Today
- What test doubles are (mocks, stubs, spies)
- Creating mock functions with
jest.fn() - Controlling return values and implementations
- Watching existing methods with
jest.spyOn() - Mocking entire modules with
jest.mock() - Resetting and cleaning up mocks
What Are Test Doubles?
In unit testing, the function under test often depends on external modules (APIs, databases, file systems). Replacing these dependencies with stand-ins is called using test doubles.
flowchart TB
subgraph Real["Production Code"]
CODE["Function"]
API["External API"]
DB["Database"]
FS["File System"]
end
subgraph Test["Test Code"]
TCODE["Function"]
MOCK_API["Mock API"]
MOCK_DB["Mock DB"]
MOCK_FS["Mock FS"]
end
CODE --> API
CODE --> DB
CODE --> FS
TCODE --> MOCK_API
TCODE --> MOCK_DB
TCODE --> MOCK_FS
style Real fill:#ef4444,color:#fff
style Test fill:#22c55e,color:#fff
Types of Test Doubles
| Type | Purpose | Jest Implementation |
|---|---|---|
| Stub | Returns fixed values | jest.fn().mockReturnValue() |
| Mock | Records calls for verification | jest.fn() |
| Spy | Watches real implementation | jest.spyOn() |
flowchart LR
subgraph Doubles["Test Double Types"]
STUB["Stub\nReturns fixed values"]
MOCK["Mock\nRecords calls"]
SPY["Spy\nWatches real code"]
end
STUB -->|"Control returns"| U1["Test output\nfor given input"]
MOCK -->|"Verify calls"| U2["Test that code\ncalls correctly"]
SPY -->|"Keep real impl"| U3["Monitor side effects"]
style STUB fill:#3b82f6,color:#fff
style MOCK fill:#8b5cf6,color:#fff
style SPY fill:#22c55e,color:#fff
jest.fn() β Mock Function Basics
jest.fn() creates a mock function that records all calls, arguments, and return values.
test('jest.fn() creates a mock function', () => {
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
// called twice
expect(mockFn).toHaveBeenCalledTimes(2);
// called with specific arguments
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledWith('world');
// last called with
expect(mockFn).toHaveBeenLastCalledWith('world');
});
TypeScript version:
test('jest.fn() creates a mock function', () => {
const mockFn = jest.fn<void, [string]>();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
});
Mock Function Matchers
| Matcher | Description |
|---|---|
toHaveBeenCalled() |
Called at least once |
toHaveBeenCalledTimes(n) |
Called exactly n times |
toHaveBeenCalledWith(arg1, arg2, ...) |
Called with specific arguments |
toHaveBeenLastCalledWith(arg1, ...) |
Last call's arguments |
toHaveBeenNthCalledWith(n, arg1, ...) |
Nth call's arguments |
toHaveReturned() |
Returned successfully |
toHaveReturnedWith(value) |
Returned a specific value |
Controlling Return Values
mockReturnValue β Fixed Return Value
test('mockReturnValue returns a fixed value', () => {
const getPrice = jest.fn().mockReturnValue(100);
expect(getPrice()).toBe(100);
expect(getPrice()).toBe(100); // always returns 100
});
mockReturnValueOnce β One-Time Return Value
test('mockReturnValueOnce returns different values per call', () => {
const random = jest.fn()
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValueOnce(3);
expect(random()).toBe(1);
expect(random()).toBe(2);
expect(random()).toBe(3);
expect(random()).toBeUndefined(); // no more mocked values
});
mockResolvedValue β Promise Return Values
For async function mocks, use mockResolvedValue.
test('mockResolvedValue returns a resolved promise', async () => {
const fetchUser = jest.fn().mockResolvedValue({ name: 'Alice', age: 25 });
const user = await fetchUser();
expect(user).toEqual({ name: 'Alice', age: 25 });
expect(fetchUser).toHaveBeenCalledTimes(1);
});
test('mockRejectedValue returns a rejected promise', async () => {
const fetchUser = jest.fn().mockRejectedValue(new Error('Network error'));
await expect(fetchUser()).rejects.toThrow('Network error');
});
TypeScript version:
interface User {
name: string;
age: number;
}
test('mockResolvedValue returns a resolved promise', async () => {
const fetchUser = jest.fn<Promise<User>, []>()
.mockResolvedValue({ name: 'Alice', age: 25 });
const user = await fetchUser();
expect(user).toEqual({ name: 'Alice', age: 25 });
});
| Method | Purpose |
|---|---|
mockReturnValue(val) |
Always return a fixed value |
mockReturnValueOnce(val) |
Return a value once |
mockResolvedValue(val) |
Always return a resolved Promise |
mockResolvedValueOnce(val) |
Return a resolved Promise once |
mockRejectedValue(err) |
Always return a rejected Promise |
mockRejectedValueOnce(err) |
Return a rejected Promise once |
mockImplementation β Custom Implementation
For more complex mocks, use mockImplementation.
test('mockImplementation provides custom behavior', () => {
const add = jest.fn().mockImplementation((a, b) => a + b);
expect(add(1, 2)).toBe(3);
expect(add(10, 20)).toBe(30);
});
// shorthand: pass implementation to jest.fn()
test('jest.fn() accepts an implementation directly', () => {
const add = jest.fn((a, b) => a + b);
expect(add(1, 2)).toBe(3);
});
Practical Example: Testing Callbacks
Mock functions are ideal for testing callbacks.
// forEach.js
function forEach(items, callback) {
for (let i = 0; i < items.length; i++) {
callback(items[i], i);
}
}
module.exports = forEach;
TypeScript version:
// forEach.ts
export function forEach<T>(items: T[], callback: (item: T, index: number) => void): void {
for (let i = 0; i < items.length; i++) {
callback(items[i], i);
}
}
// forEach.test.js
const forEach = require('./forEach');
test('calls callback for each item', () => {
const mockCallback = jest.fn();
forEach(['a', 'b', 'c'], mockCallback);
// called 3 times
expect(mockCallback).toHaveBeenCalledTimes(3);
// check arguments for each call
expect(mockCallback).toHaveBeenNthCalledWith(1, 'a', 0);
expect(mockCallback).toHaveBeenNthCalledWith(2, 'b', 1);
expect(mockCallback).toHaveBeenNthCalledWith(3, 'c', 2);
});
jest.spyOn() β Watching Existing Methods
jest.spyOn() watches an existing method on an object. The original implementation is preserved, so real behavior continues while calls are recorded.
// calculator.js
const calculator = {
add(a, b) {
return a + b;
},
subtract(a, b) {
return a - b;
},
};
module.exports = calculator;
// calculator.test.js
const calculator = require('./calculator');
test('spyOn tracks calls without changing behavior', () => {
const spy = jest.spyOn(calculator, 'add');
const result = calculator.add(1, 2);
expect(result).toBe(3); // original implementation
expect(spy).toHaveBeenCalledWith(1, 2);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore(); // restore original
});
Overriding Return Values with spyOn
test('spyOn can override return value', () => {
const spy = jest.spyOn(calculator, 'add').mockReturnValue(999);
const result = calculator.add(1, 2);
expect(result).toBe(999); // overridden
expect(spy).toHaveBeenCalledWith(1, 2);
spy.mockRestore();
});
Spying on console.log
test('spy on console.log', () => {
const spy = jest.spyOn(console, 'log').mockImplementation();
console.log('hello');
console.log('world');
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore();
});
TypeScript version:
test('spy on console.log', () => {
const spy = jest.spyOn(console, 'log').mockImplementation();
console.log('hello');
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore();
});
| Comparison | jest.fn() | jest.spyOn() |
|---|---|---|
| Target | Creates a new function | Watches an existing method |
| Original implementation | None (returns undefined) | Preserved |
| Restoration | Not needed | Use mockRestore() |
| Use case | Callbacks, dependency stand-ins | Monitoring existing code |
jest.mock() β Mocking Entire Modules
jest.mock() replaces an entire module with a mock.
// userService.js
const axios = require('axios');
async function getUser(id) {
const response = await axios.get(`https://api.example.com/users/${id}`);
return response.data;
}
module.exports = { getUser };
TypeScript version:
// userService.ts
import axios from 'axios';
export interface User {
id: number;
name: string;
email: string;
}
export async function getUser(id: number): Promise<User> {
const response = await axios.get<User>(`https://api.example.com/users/${id}`);
return response.data;
}
// userService.test.js
const axios = require('axios');
const { getUser } = require('./userService');
jest.mock('axios');
describe('getUser', () => {
test('fetches user data from API', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
axios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(user).toEqual(mockUser);
expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
expect(axios.get).toHaveBeenCalledTimes(1);
});
test('handles API error', async () => {
axios.get.mockRejectedValue(new Error('Network error'));
await expect(getUser(1)).rejects.toThrow('Network error');
});
});
TypeScript test version:
// userService.test.ts
import axios from 'axios';
import { getUser } from './userService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('getUser', () => {
test('fetches user data from API', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
mockedAxios.get.mockResolvedValue({ data: mockUser });
const user = await getUser(1);
expect(user).toEqual(mockUser);
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://api.example.com/users/1'
);
});
});
flowchart TB
subgraph Without["Without Mocks"]
T1["Test"] --> S1["userService"] --> A1["axios"] --> API["External API\n(unreliable)"]
end
subgraph With["With Mocks"]
T2["Test"] --> S2["userService"] --> A2["axios (mock)\nFixed response"]
end
style Without fill:#ef4444,color:#fff
style With fill:#22c55e,color:#fff
Factory Function Mocks
Specify the mock implementation as the second argument to jest.mock().
jest.mock('./logger', () => ({
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
Resetting and Cleaning Up Mocks
Resetting mock state between tests is essential.
describe('mock cleanup', () => {
const mockFn = jest.fn();
afterEach(() => {
mockFn.mockClear(); // or mockReset() or mockRestore()
});
test('first test', () => {
mockFn('hello');
expect(mockFn).toHaveBeenCalledTimes(1);
});
test('second test starts fresh', () => {
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(1); // not 2
});
});
| Method | What It Resets |
|---|---|
mockClear() |
Clears call records (keeps implementation) |
mockReset() |
Clears records + resets implementation (returns undefined) |
mockRestore() |
Restores original implementation (use with spyOn) |
flowchart LR
subgraph Clear["mockClear()"]
C1["Call records β Cleared"]
C2["Implementation β Kept"]
end
subgraph Reset["mockReset()"]
R1["Call records β Cleared"]
R2["Implementation β Reset"]
end
subgraph Restore["mockRestore()"]
RE1["Call records β Cleared"]
RE2["Implementation β Original"]
end
style Clear fill:#3b82f6,color:#fff
style Reset fill:#f59e0b,color:#fff
style Restore fill:#22c55e,color:#fff
Best practice: Set
clearMocks: trueinjest.config.jsto automatically runmockClear()after each test.
// jest.config.js
module.exports = {
clearMocks: true,
};
Summary
| Concept | Description |
|---|---|
| Test Doubles | Replace external dependencies with stand-ins |
jest.fn() |
Creates a mock function that records calls |
mockReturnValue |
Sets a fixed return value |
mockResolvedValue |
Sets mock to return a Promise |
mockImplementation |
Provides custom mock behavior |
jest.spyOn() |
Watches existing methods (preserves implementation) |
jest.mock() |
Replaces an entire module with mocks |
mockClear / mockReset / mockRestore |
Reset mock state |
Key Takeaways
jest.fn()mocks callbacks and dependency functionsjest.spyOn()watches real code without changing its behaviorjest.mock()replaces entire modules to eliminate external dependencies- Always reset mock state between tests
Exercises
Exercise 1: Basics
Write tests for the following notifyUsers function. Pass sendEmail as a mock function.
function notifyUsers(users, sendEmail) {
users.forEach(user => {
sendEmail(user.email, `Hello, ${user.name}!`);
});
}
Exercise 2: Intermediate
Write tests for the following fetchAndSave function using jest.mock().
const api = require('./api');
const db = require('./db');
async function fetchAndSave(id) {
const data = await api.fetch(id);
await db.save(data);
return data;
}
Challenge
Use jest.spyOn() to control Math.random() so it always returns 0.5, then test the following function.
function getRandomItem(arr) {
const index = Math.floor(Math.random() * arr.length);
return arr[index];
}
References
Next up: In Day 5, we'll learn about "Testing Asynchronous Code." You'll explore how to test async/await, Promises, and timers (setTimeout/setInterval) with Jest!