Mocking Strategies for Unit Tests
Learn when and how to mock dependencies in unit tests. Covers stubs, spies, fakes, mock patterns, and how to avoid over-mocking.
What you'll learn
- ✓The difference between mocks, stubs, spies, and fakes
- ✓When to mock and when not to mock
- ✓Mocking patterns in JavaScript and Python
- ✓Dependency injection for testable code
- ✓How to avoid over-mocking and maintain test value
Prerequisites
- •Basic unit testing experience
- •Understanding of dependencies and side effects
- •JavaScript or Python fundamentals
Mocking replaces real dependencies with controlled substitutes during testing. When your function calls a database, sends an email, or hits an external API, you do not want your unit tests to actually do those things. Mocks let you isolate the code under test and control the behavior of its dependencies.
But mocking is easy to overuse. Over-mocked tests pass even when the real code is broken because the mocks hide the breakage. This guide covers when to mock, what to mock, and how to keep your mocks from becoming a liability.
Types of test doubles
Stubs
Return predetermined values. They answer “what should this dependency return?”
// Stub: always returns a fixed value
const getUser = vi.fn().mockReturnValue({ name: 'Alice', role: 'admin' });
Spies
Record how they were called. They answer “was this dependency used correctly?”
// Spy: records calls without changing behavior
const spy = vi.spyOn(analytics, 'track');
doSomething();
expect(spy).toHaveBeenCalledWith('user_signup', { userId: '123' });
Mocks
Combine stubs and spies. They return values AND verify how they were called.
const sendEmail = vi.fn().mockResolvedValue({ success: true });
await notifyUser(sendEmail, user);
expect(sendEmail).toHaveBeenCalledWith(user.email, expect.any(String));
Fakes
Working implementations that take shortcuts. A fake database might use an in-memory array instead of a real database:
class FakeUserRepository {
constructor() {
this.users = [];
}
save(user) {
this.users.push({ ...user, id: this.users.length + 1 });
return this.users[this.users.length - 1];
}
findById(id) {
return this.users.find(u => u.id === id) || null;
}
findByEmail(email) {
return this.users.find(u => u.email === email) || null;
}
}
Fakes are more work to build but more realistic than stubs.
What to mock
Mock external services and IO:
- HTTP requests to external APIs
- Database queries (in unit tests)
- File system operations
- Email/SMS sending
- Message queues
- System clock (for time-dependent logic)
Do not mock:
- The code under test itself
- Pure functions and value objects
- Simple data transformations
- Standard library functions (usually)
Mocking in JavaScript (Vitest/Jest)
Mock functions
import { vi, test, expect } from 'vitest';
test('processes order with mocked payment', async () => {
const processPayment = vi.fn().mockResolvedValue({
transactionId: 'tx_123',
status: 'success',
});
const result = await placeOrder(
{ items: [{ id: 1, price: 29.99 }], total: 29.99 },
processPayment
);
expect(processPayment).toHaveBeenCalledWith({
amount: 29.99,
currency: 'USD',
});
expect(result.status).toBe('confirmed');
});
Mock modules
// Mock an entire module
vi.mock('./emailService', () => ({
sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
import { sendEmail } from './emailService';
import { registerUser } from './userService';
test('sends welcome email on registration', async () => {
await registerUser({ name: 'Alice', email: 'alice@test.com' });
expect(sendEmail).toHaveBeenCalledWith(
'alice@test.com',
expect.stringContaining('Welcome')
);
});
Mock specific methods with spyOn
import * as userApi from './userApi';
test('fetches user profile', async () => {
const spy = vi.spyOn(userApi, 'fetchUser').mockResolvedValue({
id: 1,
name: 'Alice',
});
const profile = await getProfile(1);
expect(spy).toHaveBeenCalledWith(1);
expect(profile.name).toBe('Alice');
spy.mockRestore(); // Restore original implementation
});
Mock timers
test('debounces API calls', async () => {
vi.useFakeTimers();
const search = vi.fn();
const debouncedSearch = debounce(search, 300);
debouncedSearch('a');
debouncedSearch('ab');
debouncedSearch('abc');
expect(search).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('abc');
vi.useRealTimers();
});
Mocking in Python (pytest + unittest.mock)
Basic mocking with patch
from unittest.mock import patch, MagicMock
def test_send_notification():
with patch('myapp.notifications.send_email') as mock_send:
mock_send.return_value = True
result = notify_user(user_id=1, message="Hello")
mock_send.assert_called_once_with(
"alice@test.com",
subject="Notification",
body="Hello"
)
assert result is True
Patch as decorator
@patch('myapp.services.database.get_connection')
@patch('myapp.services.cache.get')
def test_get_user(mock_cache_get, mock_db_conn):
# Note: decorators are applied bottom-up
mock_cache_get.return_value = None
mock_db_conn.return_value.execute.return_value.fetchone.return_value = {
'id': 1, 'name': 'Alice'
}
user = get_user(1)
assert user['name'] == 'Alice'
MagicMock for complex objects
def test_file_processor():
mock_file = MagicMock()
mock_file.read.return_value = "name,email\nAlice,alice@test.com"
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=False)
with patch('builtins.open', return_value=mock_file):
result = process_csv('data.csv')
assert len(result) == 1
assert result[0]['name'] == 'Alice'
pytest monkeypatch
def test_api_call(monkeypatch):
def mock_get(url, **kwargs):
response = MagicMock()
response.status_code = 200
response.json.return_value = {"users": [{"name": "Alice"}]}
return response
monkeypatch.setattr("requests.get", mock_get)
users = fetch_users()
assert users[0]["name"] == "Alice"
Dependency injection for testable code
The easiest code to test is code that receives its dependencies as parameters:
// Hard to test: creates its own dependency
class OrderService {
async placeOrder(order) {
const db = new Database(); // Hidden dependency
const payment = new PaymentGateway(); // Hidden dependency
// ...
}
}
// Easy to test: receives dependencies
class OrderService {
constructor(db, paymentGateway) {
this.db = db;
this.paymentGateway = paymentGateway;
}
async placeOrder(order) {
const result = await this.paymentGateway.charge(order.total);
await this.db.save(order);
return result;
}
}
// Test with mocks
test('places order', async () => {
const mockDb = { save: vi.fn().mockResolvedValue(true) };
const mockPayment = { charge: vi.fn().mockResolvedValue({ success: true }) };
const service = new OrderService(mockDb, mockPayment);
const result = await service.placeOrder({ total: 50 });
expect(mockPayment.charge).toHaveBeenCalledWith(50);
expect(mockDb.save).toHaveBeenCalled();
expect(result.success).toBe(true);
});
The problem with over-mocking
// Over-mocked test: tests nothing useful
test('processes data', () => {
const mockParser = vi.fn().mockReturnValue([1, 2, 3]);
const mockValidator = vi.fn().mockReturnValue(true);
const mockFormatter = vi.fn().mockReturnValue('formatted');
const mockSaver = vi.fn().mockResolvedValue(true);
const result = processData('input', mockParser, mockValidator, mockFormatter, mockSaver);
expect(mockParser).toHaveBeenCalledWith('input');
expect(mockValidator).toHaveBeenCalledWith([1, 2, 3]);
// This test only verifies that functions were called in order.
// It does not verify that the actual logic works.
});
This test passes even if the real implementations are completely broken. It is testing the wiring, not the behavior.
Better approach: mock at the boundary
// Mock only the external boundary (HTTP, database)
test('processes and saves data', async () => {
const mockDb = { save: vi.fn().mockResolvedValue(true) };
// Use REAL parser, validator, and formatter
const result = await processData('name,age\nAlice,30', mockDb);
// Verify the end result, not intermediate steps
expect(mockDb.save).toHaveBeenCalledWith([
{ name: 'Alice', age: 30 }
]);
});
Guidelines for effective mocking
- Mock at the boundary: Mock IO (network, disk, database), not business logic.
- Prefer fakes over stubs for complex dependencies: Fakes catch more bugs.
- Do not mock what you do not own: Instead, wrap third-party libraries and mock the wrapper.
- Keep mock setup short: If mock setup is longer than the test, you are probably mocking too much.
- Verify behavior, not implementation: Test that the right email was sent, not that
sendEmailwas called with exactly these 5 parameters in this order. - Use dependency injection: Design code so dependencies can be swapped easily.
- Reset mocks between tests: Avoid state leaking from one test to another.
Summary
Mocking isolates the code under test from external dependencies like databases, APIs, and file systems. Use stubs for return values, spies for call verification, and fakes for realistic behavior. Mock at the boundary (IO and external services), not internal logic. Over-mocking produces tests that pass even when the code is broken. Design code with dependency injection to make it naturally testable. The goal is tests that break when behavior changes and pass when implementation changes — mocking should help achieve this, not undermine it.
Related articles
- Testing Mocks, Stubs, Spies, and Fakes Compared
Understand the differences between test doubles: mocks, stubs, spies, and fakes, with practical examples in JavaScript and Python.
- Testing End-to-End Testing with Cypress
Write reliable E2E tests with Cypress — selectors, assertions, network stubbing, custom commands, and CI integration.
- Testing Integration Tests vs Unit Tests: Finding the Right Balance
Understand the tradeoffs between unit tests and integration tests. Learn the testing pyramid, trophy, and practical strategies for effective test suites.
- Testing pytest Fixtures: A Complete Guide
Master pytest fixtures for reusable test setup and teardown. Covers scopes, parameterization, factories, autouse, and fixture composition.