Lesson 08.3: Mocking Dependencies
Duration: 45 minutes | Difficulty: Intermediate
Learning Objectives
By the end of this lesson, you will be able to:
- Understand why mocking is essential for unit tests
- Create mock functions and modules
- Use spies to observe function behavior
- Mock external services (API calls, databases)
Why Mock?
Unit tests should test one unit in isolation. But real code has dependencies:
// This function depends on fetch (external API)
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
Without mocking, your tests would:
- Be slow: Real API calls take time
- Be flaky: Network issues cause failures
- Require setup: Need a running API server
- Have side effects: Might modify real data
With mocking, tests are fast, reliable, and isolated.
Mock Functions
Creating a Mock Function
import { vi, describe, it, expect } from 'vitest';
// For Jest: const mockFn = jest.fn();
describe('Order processing', () => {
it('should call payment processor', () => {
// Create a mock function
const processPayment = vi.fn();
// Use the mock
processPayment(100, 'credit_card');
// Assert it was called correctly
expect(processPayment).toHaveBeenCalled();
expect(processPayment).toHaveBeenCalledWith(100, 'credit_card');
expect(processPayment).toHaveBeenCalledTimes(1);
});
});
Mock Return Values
const getUserById = vi.fn();
// Return a specific value
getUserById.mockReturnValue({ id: 1, name: 'Alice' });
// Return different values on subsequent calls
getUserById
.mockReturnValueOnce({ id: 1, name: 'Alice' })
.mockReturnValueOnce({ id: 2, name: 'Bob' });
// Return a Promise
getUserById.mockResolvedValue({ id: 1, name: 'Alice' });
getUserById.mockRejectedValue(new Error('Not found'));
Mocking Modules
Mock an Entire Module
// Mock the entire 'axios' module
vi.mock('axios');
import axios from 'axios';
import { fetchUsers } from './userService';
describe('userService', () => {
it('should fetch users from API', async () => {
// Setup mock response
(axios.get as any).mockResolvedValue({
data: [{ id: 1, name: 'Alice' }]
});
// Call the function that uses axios
const users = await fetchUsers();
// Assert
expect(axios.get).toHaveBeenCalledWith('/api/users');
expect(users).toEqual([{ id: 1, name: 'Alice' }]);
});
});
Mock Specific Functions
// Only mock certain functions
vi.mock('./database', () => ({
...vi.importActual('./database'), // Keep real implementations
query: vi.fn() // Mock just this one
}));
Spies
Spies let you observe calls without replacing the implementation:
import * as mathUtils from './mathUtils';
describe('Calculator', () => {
it('should call add internally', () => {
// Spy on the function
const addSpy = vi.spyOn(mathUtils, 'add');
// Use the real implementation
const result = calculate('2 + 3');
// Assert the spy was called
expect(addSpy).toHaveBeenCalledWith(2, 3);
expect(result).toBe(5);
// Clean up
addSpy.mockRestore();
});
});
Spying and Replacing
const consoleSpy = vi.spyOn(console, 'log');
// Replace implementation
consoleSpy.mockImplementation(() => {}); // Silence console.log
// Or use original but track calls
consoleSpy.mockImplementation((...args) => {
// Do something with args
});
Practical Example: Mocking API Calls
The Code Being Tested
// userService.ts
export async function createUser(userData: UserInput) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
}
The Test
// userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUser } from './userService';
describe('createUser', () => {
beforeEach(() => {
// Reset mocks before each test
vi.restoreAllMocks();
});
it('should send POST request with user data', async () => {
// Arrange: Mock fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' })
});
// Act
const result = await createUser({ name: 'Alice', email: 'alice@example.com' });
// Assert
expect(fetch).toHaveBeenCalledWith('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
headers: { 'Content-Type': 'application/json' }
});
expect(result).toEqual({ id: 1, name: 'Alice' });
});
it('should throw error when API fails', async () => {
// Arrange: Mock failed response
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500
});
// Act & Assert
await expect(createUser({ name: 'Alice' }))
.rejects.toThrow('Failed to create user');
});
});
Mocking Timers
For code that uses setTimeout, setInterval, or Date:
describe('Delayed operations', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should call callback after delay', () => {
const callback = vi.fn();
// Function that calls callback after 1000ms
delayedCall(callback, 1000);
// Callback not called yet
expect(callback).not.toHaveBeenCalled();
// Fast-forward time
vi.advanceTimersByTime(1000);
// Now it's called
expect(callback).toHaveBeenCalled();
});
it('should use current date', () => {
// Set a specific date
vi.setSystemTime(new Date('2025-01-01'));
expect(getCurrentYear()).toBe(2025);
});
});
Mocking Best Practices
1. Mock at the Boundary
// ❌ Don't mock internal functions
vi.mock('./calculateSubtotal'); // Too internal
// ✅ Mock external dependencies
vi.mock('axios');
vi.mock('./database');
2. Keep Mocks Simple
// ❌ Over-complicated mock
const mockDb = {
query: vi.fn().mockImplementation((sql) => {
if (sql.includes('SELECT')) {
return Promise.resolve([...]);
} else if (sql.includes('INSERT')) {
return Promise.resolve({ insertId: 1 });
}
// ... more logic
})
};
// ✅ Simple, focused mock
const mockDb = {
query: vi.fn().mockResolvedValue([{ id: 1 }])
};
3. Reset Mocks Between Tests
beforeEach(() => {
vi.clearAllMocks(); // Clear call history
// or
vi.resetAllMocks(); // Clear + reset implementations
// or
vi.restoreAllMocks(); // Restore original implementations
});
4. Don't Over-Mock
// ❌ Mocking everything (test doesn't test much)
it('should process order', () => {
vi.mock('./validateOrder');
vi.mock('./calculateTotal');
vi.mock('./applyDiscount');
vi.mock('./chargePayment');
vi.mock('./sendConfirmation');
processOrder(order); // What are we actually testing?
});
// ✅ Mock only external dependencies
it('should process order and charge payment', () => {
vi.mock('./paymentGateway'); // External service
// Real order processing logic is tested
const result = processOrder(order);
expect(paymentGateway.charge).toHaveBeenCalled();
});
Key Takeaways
- Mocks isolate your unit — Test one thing at a time
- Mock at boundaries — External APIs, databases, file system
- Spies observe — Track calls without replacing
- Reset between tests — Prevent test pollution
- Keep mocks simple — Only mock what you need
Practice Exercise
Mock the EmailService dependency in this function:
import { EmailService } from './emailService';
export async function registerUser(email: string, password: string) {
const user = await createUserInDatabase(email, password);
await EmailService.sendWelcomeEmail(email);
return user;
}
Write tests that:
- Verify
sendWelcomeEmailis called with correct email - Handle case where email sending fails
- Verify user is still created even if email fails
Next Lesson
Let's learn how to test asynchronous code effectively.