Mocking and Test Doubles - Isolating Dependencies
Mocking is where many developers either become testing experts or give up entirely. I’ve seen brilliant engineers write tests that mock everything, making their tests brittle and meaningless. I’ve also seen teams avoid mocking altogether, resulting in slow, flaky tests that break when external services are down.
The key insight about mocking is that it’s not about replacing everything—it’s about isolating the specific behavior you want to test. When you mock a database call, you’re not testing the database; you’re testing how your code handles the database’s response.
Understanding When to Mock
Mock external dependencies that you don’t control: APIs, databases, file systems, network calls, and third-party services. Don’t mock your own code unless you’re testing integration points between major components:
import requests
from unittest.mock import patch, Mock
class WeatherService:
def get_temperature(self, city):
response = requests.get(f"http://api.weather.com/{city}")
if response.status_code == 200:
return response.json()["temperature"]
raise ValueError(f"Weather data unavailable for {city}")
# Good: Mock the external API call
@patch('requests.get')
def test_get_temperature_success(mock_get):
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"temperature": 25}
mock_get.return_value = mock_response
service = WeatherService()
temp = service.get_temperature("London")
assert temp == 25
mock_get.assert_called_once_with("http://api.weather.com/London")
This test verifies that your code correctly processes a successful API response without actually making network calls. The mock ensures the test runs quickly and reliably.
Testing Error Conditions with Mocks
Mocks excel at simulating error conditions that are difficult to reproduce with real systems. You can test how your code handles network timeouts, server errors, or malformed responses:
@patch('requests.get')
def test_get_temperature_api_error(mock_get):
mock_response = Mock()
mock_response.status_code = 500
mock_get.return_value = mock_response
service = WeatherService()
with pytest.raises(ValueError, match="Weather data unavailable"):
service.get_temperature("InvalidCity")
@patch('requests.get')
def test_get_temperature_network_timeout(mock_get):
mock_get.side_effect = requests.Timeout("Connection timed out")
service = WeatherService()
with pytest.raises(requests.Timeout):
service.get_temperature("London")
These tests ensure your error handling works correctly without depending on external services to actually fail.
Mock Objects vs Mock Functions
Python’s mock library provides different approaches for different scenarios. Use Mock objects when you need to simulate complex behavior, and patch decorators when you want to replace specific functions:
from unittest.mock import Mock, MagicMock
def test_database_operations():
# Create a mock database connection
mock_db = Mock()
mock_cursor = Mock()
# Set up the mock behavior
mock_db.cursor.return_value = mock_cursor
mock_cursor.fetchone.return_value = ("alice", "[email protected]")
# Test your code that uses the database
user_service = UserService(mock_db)
user = user_service.get_user_by_id(1)
# Verify the interactions
mock_db.cursor.assert_called_once()
mock_cursor.execute.assert_called_once_with(
"SELECT username, email FROM users WHERE id = ?", (1,)
)
assert user.username == "alice"
assert user.email == "[email protected]"
This approach lets you verify not just the return value, but also that your code interacts with the database correctly.
Avoiding Mock Overuse
The biggest mistake I see with mocking is testing implementation details instead of behavior. If you find yourself mocking every method call, step back and consider what you’re actually trying to verify:
# Bad: Testing implementation details
@patch('myapp.user_service.UserService.validate_email')
@patch('myapp.user_service.UserService.hash_password')
@patch('myapp.user_service.UserService.save_to_database')
def test_create_user_bad(mock_save, mock_hash, mock_validate):
# This test is brittle and doesn't test real behavior
pass
# Good: Testing behavior with minimal mocking
@patch('myapp.database.Database.save')
def test_create_user_good(mock_save):
mock_save.return_value = 123 # User ID
service = UserService()
user_id = service.create_user("alice", "[email protected]", "password")
assert user_id == 123
# Verify the user object passed to save has correct properties
saved_user = mock_save.call_args[0][0]
assert saved_user.username == "alice"
assert saved_user.email == "[email protected]"
assert saved_user.password != "password" # Should be hashed
The second approach tests the actual behavior while only mocking the external dependency.
Spy Pattern for Partial Mocking
Sometimes you want to call the real method but also verify it was called correctly. The spy pattern wraps the original function:
from unittest.mock import patch
class EmailService:
def send_email(self, to, subject, body):
# Real email sending logic
return self._smtp_send(to, subject, body)
def _smtp_send(self, to, subject, body):
# Actual SMTP implementation
pass
def test_email_service_with_spy():
service = EmailService()
with patch.object(service, '_smtp_send', return_value=True) as mock_smtp:
result = service.send_email("[email protected]", "Hello", "Test message")
assert result is True
mock_smtp.assert_called_once_with(
"[email protected]", "Hello", "Test message"
)
This pattern lets you test the public interface while controlling the external dependency.
Context Managers and Temporary Mocking
For tests that need different mock behavior in different sections, use context managers to apply mocks temporarily:
def test_retry_logic():
service = ApiService()
with patch('requests.get') as mock_get:
# First call fails
mock_get.side_effect = [
requests.ConnectionError("Network error"),
Mock(status_code=200, json=lambda: {"data": "success"})
]
result = service.get_data_with_retry("http://api.example.com")
assert result["data"] == "success"
assert mock_get.call_count == 2 # Verify retry happened
This approach tests complex scenarios like retry logic without making your test setup overly complicated.
Mock Configuration Best Practices
Keep your mock setup close to your test logic and make the expected behavior explicit:
def test_user_authentication():
# Clear mock setup
mock_auth_service = Mock()
mock_auth_service.authenticate.return_value = {
"user_id": 123,
"username": "alice",
"roles": ["user", "admin"]
}
# Test the behavior
app = Application(auth_service=mock_auth_service)
user = app.login("alice", "password")
# Verify results
assert user.id == 123
assert user.has_role("admin")
# Verify interactions
mock_auth_service.authenticate.assert_called_once_with("alice", "password")
This pattern makes it easy to understand what the test expects and why it might fail.
In our next part, we’ll explore integration testing strategies that combine real components while still maintaining test reliability. We’ll cover database testing, API testing, and techniques for testing complex workflows that span multiple systems.