Mocking Strategies & Test Doubles
Master mocks, stubs, spies, and fakes to isolate units under test while avoiding over-mocking pitfalls in JavaScript testing
Types of Test Doubles
Test doubles are stand-in objects used to replace real dependencies during testing. Understanding when to use each type is key to writing focused, reliable tests without over-mocking.
Stub
Returns predetermined data. Answers questions the code asks.
Mock
Verifies interactions. Checks that functions were called correctly.
Spy
Wraps real implementation but records calls. Lets the real code run.
Fake
Working implementation with shortcuts (e.g., in-memory database).
Jest Mocking Fundamentals
// Spies - observe without replacing
const spy = jest.spyOn(console, 'log');
doSomething();
expect(spy).toHaveBeenCalledWith('expected message');
spy.mockRestore();
// Stubs - return controlled values
const getUser = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });
const result = await getUser(1);
expect(result.name).toBe('Alice');
// Mock return values for different calls
const fetchData = jest.fn()
.mockResolvedValueOnce({ data: 'first' })
.mockResolvedValueOnce({ data: 'second' })
.mockRejectedValueOnce(new Error('Network error'));
// Mock implementation
const calculate = jest.fn().mockImplementation((a, b) => a * b);
// Verify call arguments
expect(fetchData).toHaveBeenCalledTimes(3);
expect(fetchData).toHaveBeenNthCalledWith(1, 'arg1');
Mocking Modules with jest.mock
// Mock an entire module
// __tests__/userService.test.ts
import { getUser } from '../userService';
import { db } from '../database';
jest.mock('../database');
const mockedDb = db as jest.Mocked<typeof db>;
test('getUser fetches from database', async () => {
mockedDb.query.mockResolvedValue([{ id: 1, name: 'Alice' }]);
const user = await getUser(1);
expect(user.name).toBe('Alice');
expect(mockedDb.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = ?',
[1]
);
});
// Partial mock - keep some real implementations
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
sendEmail: jest.fn(), // Only mock sendEmail
}));
// Manual mock in __mocks__/axios.ts
export default {
get: jest.fn(() => Promise.resolve({ data: {} })),
post: jest.fn(() => Promise.resolve({ data: {} })),
create: jest.fn(function () { return this; }),
};
Mocking HTTP with MSW
Mock Service Worker intercepts requests at the network level, providing realistic mocking without changing application code.
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: 'test@example.com',
});
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: '123', ...body },
{ status: 201 }
);
}),
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.ts
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override for specific tests
test('handles server error', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ error: 'Internal error' },
{ status: 500 }
);
})
);
// Test error handling logic...
});
Mocking Timers & Over-Mocking Pitfalls
// Mocking timers
test('debounce waits before calling', () => {
jest.useFakeTimers();
const callback = jest.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
// Mocking Date
test('formats relative time', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00Z'));
expect(timeAgo(new Date('2024-01-15T11:00:00Z'))).toBe('1 hour ago');
jest.useRealTimers();
});
Over-Mocking Pitfalls:
- Mocking what you don't own: Mock the adapter layer, not third-party libraries directly.
- Testing mock behavior: If your test only verifies mocks called mocks, it tests nothing real.
- Tight coupling to implementation: Tests that break when you refactor internals are over-mocked.
- Rule of thumb: If removing the mock makes the test work the same, you don't need it.
Key Takeaways
- Use stubs for inputs and mocks for verifying outputs and interactions
- MSW provides the most realistic HTTP mocking for frontend tests
- Prefer dependency injection to make code testable without heavy mocking
- Over-mocking creates tests that pass but don't catch real bugs
- Always restore mocks in afterEach to prevent test pollution