Understanding Test Doubles
Test doubles are objects that replace real dependencies in tests, allowing you to isolate the code under test and control external factors. Just as stunt doubles replace actors in dangerous scenes, test doubles replace real objects in tests. They're essential for writing unit tests that are fast, reliable, and focused on testing one component at a time without depending on external systems like databases, APIs, or file systems.
The term "test double" is an umbrella term coined by Gerard Meszaros that includes several types: mocks, stubs, spies, fakes, and dummies. Each type serves a different purpose and is used in different scenarios. Understanding when and how to use each type is crucial for writing effective tests.
Why Use Test Doubles?
- Speed: Tests run faster without real database or API calls
- Isolation: Test only the code you care about, not dependencies
- Reliability: Tests don't fail due to external service issues
- Control: Simulate edge cases and error conditions easily
Types of Test Doubles
1. Dummies
Dummies are the simplest test doubles. They're passed around but never actually used, typically just filling parameter lists:
// JavaScript example
class Logger {
log(message) {
// Real implementation would write to file
}
}
class DummyLogger {
log(message) {
// Does nothing - just satisfies interface
}
}
// Usage
const processor = new DataProcessor(new DummyLogger());
2. Stubs
Stubs provide predefined answers to calls. They don't respond to anything they're not programmed to respond to:
// PHPUnit stub example
public function test_calculates_price_with_discount()
{
// Create stub for discount service
$discountService = $this->createStub(DiscountService::class);
// Configure stub to return 10% discount
$discountService->method('getDiscount')
->willReturn(0.10);
$calculator = new PriceCalculator($discountService);
$price = $calculator->calculatePrice(100);
$this->assertEquals(90, $price);
}
// Jest stub example
test('calculates price with discount', () => {
// Create stub object
const discountService = {
getDiscount: () => 0.10
};
const calculator = new PriceCalculator(discountService);
const price = calculator.calculatePrice(100);
expect(price).toBe(90);
});
3. Mocks
Mocks are pre-programmed with expectations about the calls they should receive. They verify behavior rather than state:
// PHPUnit mock example
public function test_sends_welcome_email_on_registration()
{
// Create mock that expects sendEmail to be called
$emailService = $this->createMock(EmailService::class);
$emailService->expects($this->once())
->method('sendEmail')
->with(
$this->equalTo('welcome@example.com'),
$this->stringContains('Welcome')
);
$userService = new UserService($emailService);
$userService->register('john@example.com');
}
// Jest mock example with jest.fn()
test('sends welcome email on registration', () => {
const emailService = {
sendEmail: jest.fn()
};
const userService = new UserService(emailService);
userService.register('john@example.com');
expect(emailService.sendEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendEmail).toHaveBeenCalledWith(
'welcome@example.com',
expect.stringContaining('Welcome')
);
});
4. Spies
Spies record information about how they were called, but unlike mocks, they don't fail the test themselves:
// Jest spy example
test('tracks method calls', () => {
const calculator = {
add: (a, b) => a + b
};
// Spy on existing method
const spy = jest.spyOn(calculator, 'add');
const result = calculator.add(2, 3);
expect(result).toBe(5);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(2, 3);
// Restore original implementation
spy.mockRestore();
});
5. Fakes
Fakes have working implementations, but they take shortcuts that make them unsuitable for production (e.g., in-memory database):
// Real repository uses database
class UserRepository {
save(user) {
// SQL INSERT query
return this.db.insert('users', user);
}
findById(id) {
// SQL SELECT query
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
// Fake repository uses in-memory array
class FakeUserRepository {
constructor() {
this.users = [];
}
save(user) {
this.users.push(user);
return user;
}
findById(id) {
return this.users.find(u => u.id === id);
}
}
When to Use Each Type:
- Dummy: When you need to fill a parameter but won't use it
- Stub: When you need controlled input from dependencies
- Mock: When you need to verify interaction with dependencies
- Spy: When you want to track calls to a real object
- Fake: When you need a working but simplified implementation
Mocking in PHPUnit
Creating Mocks
// Basic mock creation
$mock = $this->createMock(EmailService::class);
// Mock with multiple methods
$mock = $this->createMock(UserRepository::class);
$mock->method('findById')
->willReturn(new User('john@example.com'));
$mock->method('save')
->willReturn(true);
Configuring Return Values
// Simple return value
$mock->method('getUser')
->willReturn($user);
// Different return values for consecutive calls
$mock->method('getNext')
->willReturn(1, 2, 3);
// Return value based on input
$mock->method('calculate')
->willReturnCallback(function($x) {
return $x * 2;
});
// Return argument
$mock->method('echo')
->willReturnArgument(0);
// Return self for fluent interface
$mock->method('where')
->willReturnSelf();
Setting Expectations
// Expect method to be called once
$mock->expects($this->once())
->method('sendEmail');
// Expect method to be called multiple times
$mock->expects($this->exactly(3))
->method('log');
// Expect method to never be called
$mock->expects($this->never())
->method('delete');
// Expect method with specific arguments
$mock->expects($this->once())
->method('sendEmail')
->with(
$this->equalTo('john@example.com'),
$this->stringContains('Welcome')
);
// Expect method at specific invocation
$mock->expects($this->at(0))
->method('log')
->with('Starting process');
$mock->expects($this->at(1))
->method('log')
->with('Finished process');
Throwing Exceptions
$mock->method('connect')
->willThrowException(new ConnectionException('Connection failed'));
// Test exception handling
public function test_handles_connection_failure()
{
$db = $this->createMock(Database::class);
$db->method('connect')
->willThrowException(new ConnectionException());
$service = new DataService($db);
$this->expectException(ConnectionException::class);
$service->fetchData();
}
Mocking in Jest
jest.fn() - Mock Functions
// Create a mock function
const mockFn = jest.fn();
// Mock function with return value
const mockFn = jest.fn(() => 42);
// Mock function with implementation
const mockAdd = jest.fn((a, b) => a + b);
// Usage
const result = mockAdd(2, 3);
expect(result).toBe(5);
expect(mockAdd).toHaveBeenCalledWith(2, 3);
Mock Function Methods
const mockFn = jest.fn();
// Configure return value
mockFn.mockReturnValue(42);
// Chain return values
mockFn
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(3);
console.log(mockFn(), mockFn(), mockFn()); // 1, 2, 3
// Mock implementation
mockFn.mockImplementation((a, b) => a + b);
// Mock implementation once
mockFn.mockImplementationOnce((a, b) => a * b);
// Resolved/rejected promises
mockFn.mockResolvedValue({ id: 1, name: 'John' });
mockFn.mockRejectedValue(new Error('Failed'));
Checking Mock Calls
const mockFn = jest.fn();
mockFn('arg1', 'arg2');
mockFn('arg3');
// Check if called
expect(mockFn).toHaveBeenCalled();
// Check number of calls
expect(mockFn).toHaveBeenCalledTimes(2);
// Check specific call arguments
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');
// Check nth call
expect(mockFn).toHaveBeenNthCalledWith(1, 'arg1', 'arg2');
expect(mockFn).toHaveBeenNthCalledWith(2, 'arg3');
// Access call history
expect(mockFn.mock.calls).toEqual([
['arg1', 'arg2'],
['arg3']
]);
// Access results
expect(mockFn.mock.results[0].value).toBe(expectedValue);
jest.spyOn() - Spying on Methods
const video = {
play() {
return true;
}
};
test('plays video', () => {
const spy = jest.spyOn(video, 'play');
const result = video.play();
expect(spy).toHaveBeenCalled();
expect(result).toBe(true);
spy.mockRestore();
});
// Spy with custom implementation
test('mocks video play', () => {
const spy = jest.spyOn(video, 'play')
.mockImplementation(() => false);
expect(video.play()).toBe(false);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
Mocking Modules
// Mock entire module
jest.mock('./userService');
// With implementation
jest.mock('./userService', () => ({
getUser: jest.fn(() => ({ id: 1, name: 'John' })),
saveUser: jest.fn()
}));
// Partial mock
jest.mock('./userService', () => {
const originalModule = jest.requireActual('./userService');
return {
__esModule: true,
...originalModule,
getUser: jest.fn(() => ({ id: 1, name: 'John' }))
};
});
// Usage in test
import { getUser, saveUser } from './userService';
test('fetches user', () => {
const user = getUser(1);
expect(user).toEqual({ id: 1, name: 'John' });
expect(getUser).toHaveBeenCalledWith(1);
});
Best Practices
1. Mock at the Right Level
// Bad - mocking too much
test('user service', () => {
const db = mock(Database);
const cache = mock(Cache);
const logger = mock(Logger);
const emailer = mock(Emailer);
const notifier = mock(Notifier);
// Too many mocks - testing nothing!
});
// Good - mock only external dependencies
test('user service', () => {
const db = mock(Database);
const userService = new UserService(db);
// Test UserService behavior
});
2. Don't Mock What You Don't Own
// Bad - mocking third-party library directly
const axios = jest.mock('axios');
// Good - create adapter and mock your adapter
class HttpClient {
async get(url) {
return axios.get(url);
}
}
const httpClient = jest.mock('./HttpClient');
3. Prefer Stubs Over Mocks When Possible
// Prefer this (stub - tests state)
test('calculates discount', () => {
const discountService = {
getDiscount: () => 0.10
};
const result = calculatePrice(100, discountService);
expect(result).toBe(90);
});
// Over this (mock - tests interaction)
test('calculates discount', () => {
const discountService = {
getDiscount: jest.fn(() => 0.10)
};
calculatePrice(100, discountService);
expect(discountService.getDiscount).toHaveBeenCalled();
});
Avoid Over-Mocking:
Over-mocking leads to brittle tests that break when implementation details change. Mock only what's necessary - external dependencies and complex collaborators. Don't mock the system under test itself!
4. Clear and Reset Mocks Between Tests
// Jest - clear mock history between tests
beforeEach(() => {
jest.clearAllMocks(); // Clears call history
});
afterEach(() => {
jest.restoreAllMocks(); // Restores original implementations
});
// Clear specific mock
mockFn.mockClear();
// Reset mock (clear + remove implementation)
mockFn.mockReset();
// Restore spy
spy.mockRestore();
5. Use Descriptive Names
// Bad - unclear
const mock1 = jest.fn();
const mock2 = jest.fn();
// Good - descriptive
const mockEmailSender = jest.fn();
const mockUserRepository = {
findById: jest.fn(),
save: jest.fn()
};
Practice Exercise:
- Create an
OrderService that depends on PaymentGateway, InventoryService, and EmailService
- Write tests using stubs for
InventoryService to control product availability
- Write tests using mocks for
PaymentGateway to verify payment processing is called correctly
- Write tests using spies on
EmailService to track confirmation email sending
- Test error scenarios by making mocks throw exceptions
- Ensure mocks are properly cleared between tests
Common Pitfalls
Pitfall 1: Mocking Implementation Details
// Bad - testing implementation
test('user service', () => {
const repo = mock(UserRepository);
repo.findById.mockReturnValue(user);
service.getUser(1);
expect(repo.findById).toHaveBeenCalled(); // Testing how, not what
});
// Good - testing behavior
test('user service returns user', () => {
const repo = { findById: () => user };
const result = service.getUser(1);
expect(result).toEqual(user); // Testing output
});
Pitfall 2: Forgetting to Assert Mock Calls
// Bad - mock created but never verified
test('sends email', () => {
const emailService = { send: jest.fn() };
userService.register(user);
// Missing assertion!
});
// Good - verify mock was called
test('sends email', () => {
const emailService = { send: jest.fn() };
userService.register(user);
expect(emailService.send).toHaveBeenCalled();
});
Summary
Test doubles are powerful tools that enable fast, reliable, isolated unit tests. Understanding the differences between dummies, stubs, mocks, spies, and fakes - and knowing when to use each - is essential for effective testing. Use stubs when you need controlled inputs, mocks when you need to verify interactions, and spies when you want to observe real object behavior.
Remember: test doubles are a means to an end, not the end itself. Use them to make your tests better, but don't let them make your tests brittle. Mock only external dependencies, avoid testing implementation details, and always ensure your tests verify meaningful behavior.