Table of Contents
- Understanding TDD and the Need for Isolation
- What Are Test Doubles?
- Types of Test Doubles
- Practical Example: Testing an E-Commerce Order System
- Best Practices for Using Test Doubles
- Common Pitfalls to Avoid
- Conclusion
- References
1. Understanding TDD and the Need for Isolation
At its core, TDD follows three steps:
- Red: Write a failing test that defines the desired behavior.
- Green: Write the minimal code required to make the test pass.
- Refactor: Improve the code while keeping tests passing.
For TDD to work effectively, tests must be:
- Fast: To run frequently during development.
- Reliable: To avoid false positives/negatives from external systems.
- Focused: To validate only the UUT, not its dependencies.
The problem arises when the UUT depends on external components like databases, APIs, or other services. These dependencies can:
- Slow down tests (e.g., waiting for a database round-trip).
- Make tests flaky (e.g., an API being temporarily unavailable).
- Prevent testing (e.g., a dependency that hasn’t been built yet).
Test doubles solve these problems by replacing real dependencies with controlled substitutes, allowing you to test the UUT in isolation.
2. What Are Test Doubles?
A test double is a generic term for any object used in testing to stand in for a real object. The term was coined by Gerard Meszaros in his book xUnit Test Patterns, drawing an analogy to stunt doubles in film.
Test doubles are not one-size-fits-all; different types exist to serve specific testing goals (e.g., providing data, verifying interactions, or simulating behavior). The key is to choose the right double for the job.
3. Types of Test Doubles
Below are the five primary types of test doubles, along with their use cases and Python examples.
3.1 Dummies
Definition: Objects passed around but never used. They exist only to satisfy method signatures (e.g., to fill in required parameters).
Use Case: When a function requires a parameter that isn’t used in the test scenario.
Example:
Suppose we have a UserNotifier that sends emails. It requires a Logger instance, but we’re testing a path where no logs are generated. We can pass a dummy logger to satisfy the constructor.
class Logger:
def log(self, message: str) -> None:
pass # Real implementation writes to a file
class UserNotifier:
def __init__(self, logger: Logger):
self.logger = logger # Required but unused in this test case
def send_welcome_email(self, email: str) -> None:
# Logic to send email (no logging here)
pass
# Test: send_welcome_email doesn't require the logger to do anything
def test_send_welcome_email():
dummy_logger = Logger() # Dummy: passed but not used
notifier = UserNotifier(dummy_logger)
notifier.send_welcome_email("[email protected]") # No errors = test passes
In practice, you might use unittest.mock.Mock() as a dummy to avoid defining empty classes.
3.2 Stubs
Definition: Objects that provide predefined responses to method calls. They replace dependencies to return specific data, enabling you to test how the UUT handles that data.
Use Case: When the UUT depends on a dependency to retrieve data (e.g., a database, API).
Example:
Testing a UserService that fetches user data from a Database dependency. We’ll stub the database to return a specific user, ensuring the service processes it correctly.
from unittest.mock import Mock
class Database:
def get_user(self, user_id: int) -> dict:
# Real implementation queries a database
raise NotImplementedError()
class UserService:
def __init__(self, db: Database):
self.db = db
def get_user_name(self, user_id: int) -> str:
user = self.db.get_user(user_id)
return f"{user['first_name']} {user['last_name']}"
# Test: get_user_name returns the full name from the database
def test_get_user_name():
# 1. Create a stub database
stub_db = Mock(spec=Database)
stub_db.get_user.return_value = {
"first_name": "John",
"last_name": "Doe"
}
# 2. Inject stub into UserService
user_service = UserService(stub_db)
# 3. Assert the service returns the correct full name
assert user_service.get_user_name(123) == "John Doe"
Here, stub_db is a stub that returns a predefined user. We don’t care if get_user was called—we only care that the service uses the returned data correctly.
3.3 Spies
Definition: Objects that record how they were interacted with (e.g., which methods were called, with which arguments). They help verify if and how a dependency was used.
Use Case: When you need to confirm that a dependency was called (but don’t need to validate the exact arguments upfront).
Example:
Testing that a PaymentProcessor calls a NotificationService after processing a payment. We’ll spy on the notification service to ensure it was invoked.
from unittest.mock import Mock
class NotificationService:
def send_confirmation(self, email: str) -> None:
# Real implementation sends an email
pass
class PaymentProcessor:
def __init__(self, notifier: NotificationService):
self.notifier = notifier
def process_payment(self, email: str, amount: float) -> None:
# Process payment logic...
self.notifier.send_confirmation(email) # Notify user
# Test: process_payment calls send_confirmation with the user's email
def test_process_payment_sends_confirmation():
# 1. Create a spy (Mock records calls by default)
spy_notifier = Mock(spec=NotificationService)
# 2. Inject spy into PaymentProcessor
processor = PaymentProcessor(spy_notifier)
# 3. Trigger the method
processor.process_payment("[email protected]", 99.99)
# 4. Verify the spy was called with the correct email
spy_notifier.send_confirmation.assert_called_once_with("[email protected]")
spy_notifier records the call to send_confirmation, allowing us to assert it was called exactly once with the user’s email.
3.4 Mocks
Definition: Objects preprogrammed with expectations (which methods should be called, with which arguments) and verify that these expectations are met. They combine the roles of stubs (providing responses) and spies (verifying interactions).
Use Case: When you need to enforce that a dependency is called exactly as expected (e.g., validating workflow steps).
Example:
Testing that a OrderFulfillment service calls InventoryService to deduct stock only if payment succeeds. We’ll mock both the payment gateway (to stub success) and inventory service (to verify deduction).
from unittest.mock import Mock
class PaymentGateway:
def charge(self, amount: float) -> bool:
# Real implementation charges a credit card
return True # Success
class InventoryService:
def deduct_stock(self, product_id: int, quantity: int) -> None:
# Real implementation updates inventory
pass
class OrderFulfillment:
def __init__(self, payment_gateway: PaymentGateway, inventory: InventoryService):
self.payment_gateway = payment_gateway
self.inventory = inventory
def fulfill_order(self, product_id: int, quantity: int, amount: float) -> None:
if self.payment_gateway.charge(amount):
self.inventory.deduct_stock(product_id, quantity)
# Test: fulfill_order deducts stock only if payment succeeds
def test_fulfill_order_deducts_stock_on_success():
# 1. Mock payment gateway to stub a successful charge
mock_payment = Mock(spec=PaymentGateway)
mock_payment.charge.return_value = True # Stub: payment succeeds
# 2. Mock inventory service with expectations
mock_inventory = Mock(spec=InventoryService)
# Expectation: deduct_stock(101, 2) is called once
mock_inventory.deduct_stock.expect_call(101, 2)
# 3. Inject mocks into OrderFulfillment
order_fulfillment = OrderFulfillment(mock_payment, mock_inventory)
# 4. Trigger fulfillment
order_fulfillment.fulfill_order(product_id=101, quantity=2, amount=50.0)
# 5. Verify all expectations are met
mock_payment.charge.assert_called_once_with(50.0)
mock_inventory.deduct_stock.assert_called_once_with(101, 2)
Here, mock_payment stubs a successful charge, and mock_inventory verifies that deduct_stock is called with the correct product and quantity. Mocks fail the test if expectations are not met (e.g., if deduct_stock is never called).
3.5 Fakes
Definition: Simplified, functional implementations of real dependencies. They have actual logic but are lightweight and fast (e.g., an in-memory database instead of PostgreSQL).
Use Case: When the UUT needs a working dependency but using the real one is impractical.
Example:
Testing a TodoManager that depends on a TodoStorage (normally a file-based storage). We’ll use a fake in-memory storage to avoid file I/O.
# Fake: In-memory TodoStorage
class FakeTodoStorage:
def __init__(self):
self.todos = {} # In-memory "database"
def save_todo(self, todo_id: int, task: str) -> None:
self.todos[todo_id] = task
def get_todo(self, todo_id: int) -> str:
return self.todos.get(todo_id, "")
class TodoManager:
def __init__(self, storage: FakeTodoStorage):
self.storage = storage
def add_todo(self, todo_id: int, task: str) -> None:
if not task.strip():
raise ValueError("Task cannot be empty")
self.storage.save_todo(todo_id, task)
# Test: add_todo saves a task to storage and validates input
def test_add_todo():
fake_storage = FakeTodoStorage()
todo_manager = TodoManager(fake_storage)
# Test saving a valid task
todo_manager.add_todo(1, "Buy milk")
assert fake_storage.get_todo(1) == "Buy milk"
# Test validation (empty task)
try:
todo_manager.add_todo(2, "")
assert False, "Expected ValueError"
except ValueError:
pass
# Verify empty task was not saved
assert fake_storage.get_todo(2) == ""
FakeTodoStorage is a fake with real logic (saving/retrieving tasks) but uses an in-memory dict instead of a file. This makes tests fast and self-contained.
4. Practical Example: Testing an E-Commerce Order System
Let’s tie it all together with a realistic scenario: testing an OrderProcessor that depends on two external services:
PaymentGateway: Charges the customer.InventoryService: Checks/updates stock.
We’ll use mocks to verify that:
- The payment gateway is charged the correct amount.
- The inventory service is updated with the ordered quantity.
- If payment fails, inventory is not updated.
Step 1: Define the Dependencies and UUT
# dependencies.py
class PaymentGateway:
def charge(self, amount: float) -> bool:
"""Charge customer; return True if successful."""
raise NotImplementedError()
class InventoryService:
def check_stock(self, product_id: int) -> int:
"""Return current stock for a product."""
raise NotImplementedError()
def deduct_stock(self, product_id: int, quantity: int) -> None:
"""Deduct stock for a product."""
raise NotImplementedError()
# order_processor.py
from dependencies import PaymentGateway, InventoryService
class OrderProcessor:
def __init__(self, payment_gateway: PaymentGateway, inventory: InventoryService):
self.payment_gateway = payment_gateway
self.inventory = inventory
def process_order(self, product_id: int, quantity: int, amount: float) -> bool:
"""Process an order: check stock, charge payment, deduct stock. Return success."""
if self.inventory.check_stock(product_id) < quantity:
return False # Insufficient stock
payment_success = self.payment_gateway.charge(amount)
if not payment_success:
return False # Payment failed
self.inventory.deduct_stock(product_id, quantity)
return True # Order processed
Step 2: Write Tests with Mocks
Using pytest and unittest.mock.patch to inject mocks:
# test_order_processor.py
import pytest
from unittest.mock import Mock
from order_processor import OrderProcessor
def test_process_order_success():
# 1. Create mocks for dependencies
mock_payment = Mock(spec=PaymentGateway)
mock_payment.charge.return_value = True # Stub: payment succeeds
mock_inventory = Mock(spec=InventoryService)
mock_inventory.check_stock.return_value = 10 # Stub: 10 units in stock
# 2. Inject mocks into OrderProcessor
order_processor = OrderProcessor(mock_payment, mock_inventory)
# 3. Process a valid order
result = order_processor.process_order(
product_id=100,
quantity=5,
amount=250.0
)
# 4. Verify expectations
assert result is True
mock_inventory.check_stock.assert_called_once_with(100)
mock_payment.charge.assert_called_once_with(250.0)
mock_inventory.deduct_stock.assert_called_once_with(100, 5)
def test_process_order_payment_failure():
# 1. Mocks: payment fails
mock_payment = Mock(spec=PaymentGateway)
mock_payment.charge.return_value = False # Stub: payment fails
mock_inventory = Mock(spec=InventoryService)
mock_inventory.check_stock.return_value = 10
order_processor = OrderProcessor(mock_payment, mock_inventory)
# 2. Process order
result = order_processor.process_order(100, 5, 250.0)
# 3. Verify: payment failed, inventory not deducted
assert result is False
mock_payment.charge.assert_called_once_with(250.0)
mock_inventory.deduct_stock.assert_not_called() # Critical!
Key Takeaways:
- Mocks stub responses (
charge.return_value = True) and verify interactions (assert_called_once_with). - Tests focus on behavior (e.g., “on payment failure, inventory is not deducted”) rather than implementation.
5. Best Practices for Using Test Doubles
To avoid brittle or unmaintainable tests, follow these guidelines:
1. Use the Right Double for the Job
- Stubs: When you need data (e.g., “return this user”).
- Mocks: When you need to verify interactions (e.g., “call this method with these args”).
- Fakes: When you need simplified real logic (e.g., in-memory DB).
- Spies: When you need to inspect calls after the fact (rare—mocks often suffice).
- Dummies: Only to fill unused parameters.
2. Avoid Over-Specifying Mocks
Mocks should verify essential interactions, not every detail. Over-specifying (e.g., checking the order of calls, or irrelevant arguments) makes tests brittle.
Bad:
# Over-specifying: verifies call order unnecessarily
mock_inventory.check_stock.assert_called_once_with(100)
mock_payment.charge.assert_called_once_with(250.0) # Must come after check_stock?
mock_inventory.deduct_stock.assert_called_once_with(100, 5)
Good:
Focus on what must happen, not when:
mock_payment.charge.assert_called_once_with(250.0)
mock_inventory.deduct_stock.assert_called_once_with(100, 5)
3. Don’t Mock Types You Don’t Own
Mocking third-party libraries (e.g., requests, sqlalchemy) can lead to fragile tests if the library’s API changes. Instead, wrap third-party code in your own interfaces and mock those.
4. Keep Tests Focused on Behavior
Tests should validate what the code does, not how it does it. For example, if you refactor process_order to rename check_stock to verify_stock, your mock assertions should not break—unless the behavior (checking stock) changes.
5. Use Fakes for End-to-End (E2E) Tests
For higher-level E2E tests, fakes (e.g., in-memory DB) are better than mocks, as they validate integration without external dependencies.
6. Common Pitfalls to Avoid
1. Brittle Tests Due to Over-Mocking
If your tests rely heavily on mocking implementation details (e.g., private methods, internal method calls), even small code changes will break tests. Focus on public behavior instead.
2. Testing the Mock Instead of the Code
Accidentally asserting on mock setup (e.g., assert mock.charge.return_value is True) instead of the UUT’s behavior. Always verify the UUT’s output and the mock interactions.
3. Overusing Mocks for Simple Dependencies
If a dependency is trivial (e.g., a utility function), use the real implementation instead of mocking. Mocks add complexity; only use them when necessary.
4. Ignoring Edge Cases in Fakes
Fakes are simplified, but they must replicate critical behavior of the real dependency. For example, an in-memory DB fake should enforce unique constraints if the real DB does.
7. Conclusion
Test doubles are indispensable in TDD, enabling you to write fast, isolated, and reliable tests by replacing external dependencies. By choosing the right double for the job—stubs for data, mocks for interactions, fakes for simplified logic—you can validate your code’s behavior without getting bogged down by slow or unreliable dependencies.
Remember: The goal of TDD is to drive design and ensure correctness. Test doubles are a means to that end—use them wisely to keep tests focused, maintainable, and aligned with real-world behavior.
8. References
- Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code. Addison-Wesley.
- Fowler, M. (2007). Mocks Aren’t Stubs.
- Python Documentation: unittest.mock — mock object library.
- pytest Documentation: How to use unittest.mock with pytest.
- Freeman, S., & Pryce, N. (2009). Growing Object-Oriented Software, Guided by Tests. Addison-Wesley.