Mocking Strategies
Mocking replaces real implementations with controlled substitutes during testing. Done well, mocking enables fast, deterministic tests that isolate the code under test. Done poorly, mocking creates brittle tests coupled to implementation details that break on every refactor. This guide covers when to mock, what to mock, and how to mock effectively.
Why Mock?
Mocking serves several purposes:
Isolation. Test one unit without its dependencies:
// Without mocking: test depends on real database
const user = await userService.getUser(1); // Hits actual DB
// With mocking: test is isolated
vi.mocked(db.query).mockResolvedValue({ id: 1, name: 'John' });
const user = await userService.getUser(1); // Uses mock
Speed. Avoid slow operations:
// Real: 500ms network request
await fetch('/api/users');
// Mocked: instant
vi.mocked(fetch).mockResolvedValue({ json: () => ({ users: [] }) });
Determinism. Control unpredictable values:
// Unpredictable
const id = crypto.randomUUID();
// Predictable
vi.mocked(crypto.randomUUID).mockReturnValue('test-uuid-123');
Edge cases. Test scenarios hard to create naturally:
// Hard to trigger real 500 error
vi.mocked(fetch).mockRejectedValue(new Error('Server error'));
// Hard to test time-dependent code
vi.setSystemTime(new Date('2024-12-25'));
Levels of Mocking
Function Mocks
Replace individual functions:
import { describe, it, expect, vi } from 'vitest';
// Create a mock function
const mockCallback = vi.fn();
// Configure return values
mockCallback.mockReturnValue(42);
mockCallback.mockReturnValueOnce(1).mockReturnValueOnce(2);
mockCallback.mockResolvedValue({ data: 'async' });
mockCallback.mockImplementation((x) => x * 2);
// Verify calls
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockCallback).toHaveBeenCalledTimes(3);
Use function mocks for:
- Callbacks passed to functions under test
- Event handlers
- Dependency-injected functions
Module Mocks
Replace entire modules:
// Vitest
vi.mock('./database', () => ({
query: vi.fn(),
connect: vi.fn(),
}));
// Jest
jest.mock('./database', () => ({
query: jest.fn(),
connect: jest.fn(),
}));
// src/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUser } from './userService';
import { query } from './database';
vi.mock('./database');
describe('getUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns user from database', async () => {
vi.mocked(query).mockResolvedValue([{ id: 1, name: 'John' }]);
const user = await getUser(1);
expect(query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', [1]);
expect(user).toEqual({ id: 1, name: 'John' });
});
});
Use module mocks for:
- External libraries
- Internal modules with side effects
- Heavy dependencies you want to replace entirely
Partial Module Mocks
Keep some exports real, mock others:
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
return {
...actual,
sendEmail: vi.fn(), // Only mock this
};
});
Spies
Observe calls without replacing implementation:
const user = {
getName() {
return 'John';
},
};
const spy = vi.spyOn(user, 'getName');
user.getName();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('John'); // Real value
spy.mockReturnValue('Jane'); // Can also override
expect(user.getName()).toBe('Jane');
spy.mockRestore(); // Restore original
expect(user.getName()).toBe('John');
Use spies for:
- Verifying calls without changing behavior
- Partially mocking objects
- Console methods, built-in functions
Mocking HTTP Requests
Built-in Mocking (Vitest/Jest)
// Mock global fetch
global.fetch = vi.fn();
beforeEach(() => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ users: [] }),
} as Response);
});
it('fetches users', async () => {
await fetchUsers();
expect(fetch).toHaveBeenCalledWith('/api/users');
});
MSW (Mock Service Worker)
MSW intercepts requests at the network level—the gold standard for HTTP mocking:
pnpm add -D msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'John' });
}),
// Error response
http.get('/api/error', () => {
return HttpResponse.json(
{ message: 'Something went wrong' },
{ status: 500 }
);
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override handlers in specific tests
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
it('handles server errors', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
})
);
await expect(fetchUsers()).rejects.toThrow('Server error');
});
Why MSW over fetch mocking:
- Tests real fetch code (headers, body parsing, etc.)
- Works with any HTTP client (fetch, axios, got)
- Same handlers work in browser for development
- Request inspection is more natural
Nock (Node.js HTTP)
Alternative for Node.js-specific HTTP mocking:
pnpm add -D nock
import nock from 'nock';
beforeEach(() => {
nock('https://api.example.com')
.get('/users')
.reply(200, [{ id: 1, name: 'John' }]);
});
afterEach(() => {
nock.cleanAll();
});
it('fetches users from API', async () => {
const users = await fetchUsers();
expect(users).toHaveLength(1);
});
Mocking Timers
Control setTimeout, setInterval, and Date:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('timer-based functions', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('executes after delay', () => {
const callback = vi.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
it('handles intervals', () => {
const callback = vi.fn();
setInterval(callback, 100);
vi.advanceTimersByTime(350);
expect(callback).toHaveBeenCalledTimes(3);
});
it('mocks current date', () => {
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
expect(new Date().toISOString()).toBe('2024-01-01T00:00:00.000Z');
expect(Date.now()).toBe(1704067200000);
});
});
Timer utilities:
vi.advanceTimersByTime(ms); // Advance by specific time
vi.advanceTimersToNextTimer(); // Jump to next scheduled timer
vi.runAllTimers(); // Execute all pending timers
vi.runOnlyPendingTimers(); // Run current timers, not new ones
vi.getTimerCount(); // Number of pending timers
Dependency Injection vs. Module Mocking
Two approaches to making code testable:
Module Mocking
Mock imports directly:
// userService.ts
import { db } from './database';
export async function getUser(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
// userService.test.ts
vi.mock('./database');
it('queries database', async () => {
vi.mocked(db.query).mockResolvedValue([{ id: '1' }]);
await getUser('1');
expect(db.query).toHaveBeenCalled();
});
Pros: No changes to production code Cons: Coupled to import structure, can be fragile
Dependency Injection
Pass dependencies explicitly:
// userService.ts
interface Database {
query(sql: string, params: unknown[]): Promise<unknown[]>;
}
export class UserService {
constructor(private db: Database) {}
async getUser(id: string) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
// userService.test.ts
it('queries database', async () => {
const mockDb = { query: vi.fn().mockResolvedValue([{ id: '1' }]) };
const service = new UserService(mockDb);
await service.getUser('1');
expect(mockDb.query).toHaveBeenCalled();
});
Pros: Explicit dependencies, easier to test, more flexible Cons: More boilerplate, requires upfront design
Recommendation: Use dependency injection for classes and complex modules. Use module mocking for simple functions and external libraries.
Database Mocking vs. Test Databases
Mocking Database Calls
vi.mock('./database');
it('creates user in database', async () => {
vi.mocked(db.insert).mockResolvedValue({ id: '1', name: 'John' });
const user = await createUser({ name: 'John' });
expect(db.insert).toHaveBeenCalledWith('users', { name: 'John' });
expect(user.id).toBe('1');
});
When to mock:
- Unit testing business logic
- Testing error handling
- Fast CI pipelines
Test Database
Use a real database (often in-memory or Docker):
// Setup: create test database
beforeAll(async () => {
await db.migrate();
});
beforeEach(async () => {
await db.truncateAll();
});
it('creates user in database', async () => {
const user = await createUser({ name: 'John' });
const saved = await db.query('SELECT * FROM users WHERE id = ?', [user.id]);
expect(saved[0].name).toBe('John');
});
When to use real database:
- Integration tests
- Testing complex queries
- Verifying schema constraints
- Testing transactions
In-Memory Databases
SQLite in-memory for testing:
// test/setup.ts
import Database from 'better-sqlite3';
export const testDb = new Database(':memory:');
beforeAll(() => {
testDb.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
});
Anti-Patterns
Over-Mocking
Mocking too much defeats the purpose of testing:
// ❌ Over-mocked: tests nothing real
it('creates user', async () => {
const mockValidator = { validate: vi.fn().mockReturnValue(true) };
const mockHasher = { hash: vi.fn().mockReturnValue('hash') };
const mockDb = { insert: vi.fn().mockResolvedValue({ id: '1' }) };
const mockEmailer = { send: vi.fn() };
const service = new UserService(mockValidator, mockHasher, mockDb, mockEmailer);
await service.createUser({ email: '[email protected]', password: 'pass' });
// What are we even testing? Just that methods were called?
expect(mockValidator.validate).toHaveBeenCalled();
expect(mockHasher.hash).toHaveBeenCalled();
expect(mockDb.insert).toHaveBeenCalled();
});
// ✅ Better: use real implementations where practical
it('creates user with hashed password', async () => {
const mockDb = { insert: vi.fn().mockResolvedValue({ id: '1' }) };
const service = new UserService(
new RealValidator(), // Use real validator
new RealHasher(), // Use real hasher
mockDb, // Mock only external boundary
new FakeEmailer(), // Fake that doesn't send real emails
);
await service.createUser({ email: '[email protected]', password: 'pass' });
// Test real behavior
expect(mockDb.insert).toHaveBeenCalledWith('users', expect.objectContaining({
email: '[email protected]',
passwordHash: expect.stringMatching(/^\$2[aby]\$/), // bcrypt format
}));
});
Mocking What You Don't Own
Don't mock third-party libraries directly—wrap them:
// ❌ Mocking library internals
vi.mock('stripe', () => ({
Stripe: vi.fn().mockImplementation(() => ({
customers: { create: vi.fn() },
charges: { create: vi.fn() },
})),
}));
// ✅ Create your own wrapper
// src/payments.ts
import Stripe from 'stripe';
export interface PaymentProvider {
createCustomer(email: string): Promise<{ id: string }>;
charge(customerId: string, amount: number): Promise<{ id: string }>;
}
export class StripePaymentProvider implements PaymentProvider {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey);
}
async createCustomer(email: string) {
const customer = await this.stripe.customers.create({ email });
return { id: customer.id };
}
async charge(customerId: string, amount: number) {
const charge = await this.stripe.charges.create({
customer: customerId,
amount,
currency: 'usd',
});
return { id: charge.id };
}
}
// Test with a fake implementation
class FakePaymentProvider implements PaymentProvider {
async createCustomer() {
return { id: 'cus_fake' };
}
async charge() {
return { id: 'ch_fake' };
}
}
Testing Implementation Details
// ❌ Tests internal implementation
it('calls _internalMethod', () => {
const spy = vi.spyOn(service, '_validateInput');
service.process({ data: 'test' });
expect(spy).toHaveBeenCalled();
});
// ✅ Tests behavior
it('rejects invalid input', () => {
expect(() => service.process({ data: '' })).toThrow('Input required');
});
Not Resetting Mocks
// ❌ Mocks leak between tests
it('first test', () => {
vi.mocked(api.fetch).mockResolvedValue({ data: 'first' });
// ...
});
it('second test', () => {
// Still uses mock from first test!
});
// ✅ Reset mocks
afterEach(() => {
vi.clearAllMocks(); // Clear call history
// or
vi.resetAllMocks(); // Clear history + reset return values
// or
vi.restoreAllMocks(); // Restore original implementations
});
When to Use Real Implementations
Use real implementations for:
- Pure functions with no side effects
- Data transformation logic
- Validation utilities
- In-memory data structures
- Small, fast utilities
// These don't need mocking
import { formatDate, validateEmail, sortUsers } from './utils';
it('sorts users by name', () => {
const users = [{ name: 'Zoe' }, { name: 'Alice' }];
expect(sortUsers(users)).toEqual([{ name: 'Alice' }, { name: 'Zoe' }]);
});
Mock only at system boundaries:
- Network requests (HTTP, WebSocket)
- Databases
- File system
- External services
- Time and randomness
Quick Reference
| Scenario | Approach |
|---|---|
| Callback functions | vi.fn() |
| External modules | vi.mock() |
| Partial module override | vi.mock() with importActual |
| Method observation | vi.spyOn() |
| HTTP requests | MSW (preferred) or mock fetch |
| Timers | vi.useFakeTimers() |
| Dates | vi.setSystemTime() |
| Database | Mock for unit tests, real for integration |
| Third-party libraries | Wrap and mock the wrapper |
See Also
- Testing Overview - Testing philosophy and types
- Vitest - Vitest mocking APIs
- Jest - Jest mocking APIs
- MSW Documentation - Mock Service Worker
- TDD - Test-driven development with mocking