Testing & TDD

Test Doubles: Mocks, Stubs, and Spies

22 min Lesson 7 of 35

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:
  1. Create an OrderService that depends on PaymentGateway, InventoryService, and EmailService
  2. Write tests using stubs for InventoryService to control product availability
  3. Write tests using mocks for PaymentGateway to verify payment processing is called correctly
  4. Write tests using spies on EmailService to track confirmation email sending
  5. Test error scenarios by making mocks throw exceptions
  6. 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.