Test Doubles: Mocks, Stubs, Fakes, Spies
Understand the differences between mocks, stubs, fakes, and spies — and when to use each for effective testing.
Test doubles replace real dependencies in tests. But “mock” is often used as a catch-all term when there are actually four distinct types, each with a different purpose.
The Four Types
Stubs — Provide Canned Answers
Stubs return predefined data. They don’t verify how they’re called.
// Stub: always returns the same data
const userRepoStub: UserRepository = {
findById: async (id: string) => ({
id, name: 'Alice', email: '[email protected]', role: 'user',
}),
findByEmail: async () => null,
save: async (user) => user,
delete: async () => {},
};
test('greets user by name', async () => {
const service = new GreetingService(userRepoStub);
const greeting = await service.greet('user-123');
expect(greeting).toBe('Hello, Alice!');
});
Mocks — Verify Interactions
Mocks verify that specific methods were called with specific arguments.
test('sends welcome email after registration', async () => {
const emailService = {
sendWelcome: vi.fn().mockResolvedValue(undefined),
};
const service = new RegistrationService(userRepo, emailService);
await service.register({ name: 'Alice', email: '[email protected]' });
// Mock assertion: verify the interaction
expect(emailService.sendWelcome).toHaveBeenCalledWith(
expect.objectContaining({ email: '[email protected]' })
);
expect(emailService.sendWelcome).toHaveBeenCalledTimes(1);
});
Spies — Watch Real Implementations
Spies wrap the real implementation, letting you verify calls while keeping original behavior.
test('logs each API request', async () => {
const logger = new ConsoleLogger();
const logSpy = vi.spyOn(logger, 'info');
const client = new ApiClient(logger);
await client.get('/users');
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('GET /users')
);
logSpy.mockRestore(); // Clean up
});
Fakes — Working Implementations
Fakes are lightweight working implementations used for testing. They actually work, just with simplified behavior.
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async findByEmail(email: string): Promise<User | null> {
return [...this.users.values()].find(u => u.email === email) ?? null;
}
async save(user: User): Promise<User> {
this.users.set(user.id, { ...user });
return user;
}
async delete(id: string): Promise<void> {
this.users.delete(id);
}
// Test helpers
seed(users: User[]): void {
users.forEach(u => this.users.set(u.id, u));
}
clear(): void {
this.users.clear();
}
}
test('user can update their email', async () => {
const repo = new InMemoryUserRepository();
repo.seed([{ id: '1', name: 'Alice', email: '[email protected]', role: 'user' }]);
const service = new ProfileService(repo);
await service.updateEmail('1', '[email protected]');
const user = await repo.findById('1');
expect(user?.email).toBe('[email protected]');
});
When to Use What
| Type | Use When | Verifies |
|---|---|---|
| Stub | You need to control what a dependency returns | State (output) |
| Mock | You need to verify a side effect happened | Behavior (was called?) |
| Spy | You want real behavior + verification | Both |
| Fake | You need a working but lightweight implementation | State (through the fake) |
Best Practices
- Prefer fakes over mocks for repositories and data stores
- Use mocks sparingly — for side effects (email sent, event emitted)
- Don’t mock what you don’t own — mock your adapters, not third-party APIs
- Avoid deep mocking — if you need to mock
a.b.c.d(), your design is too coupled
“Mock objects are like stunt doubles: they look like the real thing but are purpose-built for a specific scene.” — Gerard Meszaros