Introduction to Mocking
Mocking is a powerful technique in testing that allows you to replace real objects with simulated ones for the purpose of isolating the code under test. This is particularly useful when dealing with external dependencies like APIs, databases, or file systems.
Real-world analogy: Mocking is like using a flight simulator to train pilots. You're creating a controlled environment that mimics real-world conditions, allowing you to safely test responses to different scenarios without the risks and costs of using actual aircraft.
The Python unittest.mock Library
Python's standard library includes unittest.mock, a powerful framework for creating and using mock objects. It was added to the standard library in Python 3.3, and before that was available as a third-party library.
Key Components of unittest.mock
- Mock: A flexible object that can be configured to behave as needed for tests
- MagicMock: A subclass of Mock that implements default magic methods
- patch: A utility for temporarily replacing objects during testing
- call: An object representing a call to a mock
- sentinel: Unique identifiable objects
from unittest.mock import Mock, MagicMock, patch, call, sentinel
# Basic Mock usage
mock = Mock()
mock.method()
mock.attribute = 'value'
assert mock.method.called
assert mock.attribute == 'value'
# MagicMock with default magic methods
magic_mock = MagicMock()
result = magic_mock + 3 # This would raise TypeError with a regular Mock
result = len(magic_mock) # This too
# patch as a context manager
with patch('module.ClassName') as MockClass:
instance = MockClass.return_value
instance.method.return_value = 'mocked result'
# Code that uses module.ClassName
# patch as a decorator
@patch('module.ClassName')
def test_function(mock_class):
instance = mock_class.return_value
# Test code
# Multiple patches
@patch('module.Class1')
@patch('module.Class2')
def test_function(mock_class2, mock_class1):
# Notice the order of arguments is reversed compared to decorators
Creating and Configuring Mocks
Basic Mock Configuration
from unittest.mock import Mock
# Configure return values
mock = Mock(return_value=42)
result = mock() # Returns 42
# Or set it later
mock.return_value = 100
result = mock() # Now returns 100
# Configure method return values
mock.some_method.return_value = "Hello, world!"
result = mock.some_method() # Returns "Hello, world!"
# Configure attribute values
mock.name = "Test Mock"
mock.is_active = True
Side Effects
Side effects allow you to define more complex behaviors:
from unittest.mock import Mock
# Raise an exception
mock = Mock(side_effect=ValueError("Test error"))
try:
result = mock()
except ValueError as e:
print(f"Caught exception: {e}") # Prints: Caught exception: Test error
# Return different values on successive calls
mock = Mock(side_effect=[1, 2, 3])
print(mock()) # Prints: 1
print(mock()) # Prints: 2
print(mock()) # Prints: 3
# Execute a function
def side_effect_func(arg):
if arg < 0:
raise ValueError("Negative value")
return arg * 2
mock = Mock(side_effect=side_effect_func)
print(mock(5)) # Prints: 10
try:
mock(-1)
except ValueError as e:
print(f"Caught exception: {e}") # Prints: Caught exception: Negative value
Spec and Autospec
Using a spec ensures that your mock has the same interface as the object it's replacing:
from unittest.mock import Mock, create_autospec
# Using a spec
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def birthday(self):
self.age += 1
def greet(self, other_person):
return f"Hello, {other_person}! My name is {self.name}."
# With spec
mock_person = Mock(spec=Person)
mock_person.greet("Alice") # Works - method exists
mock_person.birthday() # Works - method exists
try:
mock_person.dance() # Raises AttributeError - method doesn't exist
except AttributeError:
print("Method 'dance' doesn't exist on Person")
# With autospec
mock_person = create_autospec(Person)
try:
mock_person.greet() # Raises TypeError - wrong number of arguments
except TypeError:
print("Wrong number of arguments to greet()")
Real-world example: At Dropbox, engineers use spec and autospec extensively to ensure that their mocks accurately represent the interfaces they're mocking. This helps catch interface changes early in the development process.
Patching
Patching is a mechanism for temporarily replacing objects during testing. It's one of the most powerful features of the unittest.mock library.
Basic Patching
# example.py
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
if response.status_code == 200:
return response.json()
return None
# test_example.py
import unittest
from unittest.mock import patch
from example import get_user_data
class TestUserData(unittest.TestCase):
@patch('example.requests.get')
def test_get_user_data(self, mock_get):
# Configure the mock
mock_response = unittest.mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_get.return_value = mock_response
# Call the function
data = get_user_data(1)
# Assert the function returned the expected data
self.assertEqual(data, {"id": 1, "name": "John Doe"})
# Assert requests.get was called with the expected URL
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch('example.requests.get')
def test_get_user_data_not_found(self, mock_get):
# Configure the mock for a 404 response
mock_response = unittest.mock.Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
# Call the function
data = get_user_data(999)
# Assert the function returned None
self.assertIsNone(data)
Patching as a Context Manager
def test_get_user_data_context_manager():
# Use patch as a context manager
with patch('example.requests.get') as mock_get:
# Configure the mock
mock_response = unittest.mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_get.return_value = mock_response
# Call the function
data = get_user_data(1)
# Assertions
assert data == {"id": 1, "name": "John Doe"}
mock_get.assert_called_once_with("https://api.example.com/users/1")
Multiple Patches
# example.py
import requests
import logging
def get_user_data(user_id):
logging.info(f"Fetching data for user {user_id}")
response = requests.get(f"https://api.example.com/users/{user_id}")
if response.status_code == 200:
return response.json()
logging.error(f"Failed to fetch user {user_id}: {response.status_code}")
return None
# test_example.py
@patch('example.logging')
@patch('example.requests.get')
def test_get_user_data_multiple_patches(mock_get, mock_logging):
# Configure the mocks
mock_response = unittest.mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_get.return_value = mock_response
# Call the function
data = get_user_data(1)
# Assert the function returned the expected data
assert data == {"id": 1, "name": "John Doe"}
# Assert logging was called
mock_logging.info.assert_called_once_with("Fetching data for user 1")
mock_logging.error.assert_not_called()
Patching Class Attributes and Methods
# example.py
class UserService:
BASE_URL = "https://api.example.com"
def get_user(self, user_id):
response = requests.get(f"{self.BASE_URL}/users/{user_id}")
if response.status_code == 200:
return response.json()
return None
def create_user(self, user_data):
response = requests.post(f"{self.BASE_URL}/users", json=user_data)
return response.status_code == 201
# test_example.py
@patch('example.UserService.BASE_URL', "https://test-api.example.com")
@patch('example.requests.get')
def test_get_user_patched_attribute(mock_get):
# Configure the mock
mock_response = unittest.mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_get.return_value = mock_response
# Create instance and call method
service = UserService()
user = service.get_user(1)
# Assert
assert user == {"id": 1, "name": "John Doe"}
mock_get.assert_called_once_with("https://test-api.example.com/users/1")
# Patching an instance method
@patch.object(UserService, 'get_user')
def test_service_get_user(mock_get_user):
mock_get_user.return_value = {"id": 1, "name": "Mocked User"}
service = UserService()
result = service.get_user(1)
assert result == {"id": 1, "name": "Mocked User"}
mock_get_user.assert_called_once_with(1)
Patching Gotchas and Best Practices
The Importance of Where You Patch
One of the most common mistakes when using patch is not patching the object where it's used, but where it's defined:
# my_module.py
import requests
def get_data(url):
return requests.get(url).json()
# another_module.py
from my_module import get_data
def process_user(user_id):
data = get_data(f"https://api.example.com/users/{user_id}")
return {
"name": data["name"],
"email": data["email"]
}
# WRONG: This won't work!
@patch('requests.get') # Patching in the wrong place
def test_process_user_wrong(mock_get):
# This test will actually make a real HTTP request!
# ...
# RIGHT: Patch where the object is USED, not where it's defined
@patch('my_module.requests.get') # Patch requests.get as used in my_module
def test_process_user_right(mock_get):
# Configure mock...
mock_response = Mock()
mock_response.json.return_value = {"name": "Test User", "email": "test@example.com"}
mock_get.return_value = mock_response
result = process_user(1)
# Assertions...
assert result == {"name": "Test User", "email": "test@example.com"}
Import Issues
Another common issue is how imports affect patching:
# services.py
def get_external_data():
# Implementation...
# APPROACH 1: Import the function
# views.py
from services import get_external_data
def my_view():
data = get_external_data() # Using the imported function
# ...
# In this case, you need to patch 'views.get_external_data'
@patch('views.get_external_data')
def test_my_view(mock_get_data):
# ...
# APPROACH 2: Import the module
# views.py
import services
def my_view():
data = services.get_external_data() # Using the function from the module
# ...
# In this case, you need to patch 'services.get_external_data'
@patch('services.get_external_data')
def test_my_view(mock_get_data):
# ...
Patching Dictionaries and Other Objects
# Using patch.dict to temporarily modify a dictionary
@patch.dict('os.environ', {'API_KEY': 'test_key', 'DEBUG': 'true'})
def test_with_env_vars():
# Inside this test, os.environ will have the patched values
# ...
# Patching multiple items
with patch('module.Class1') as MockClass1, \
patch('module.Class2') as MockClass2:
# Both objects are patched in this context
# ...
Best Practices
- Be specific - Patch at the most specific level possible
- Use autospec - Helps ensure your mocks behave like the real objects
- Clean up after yourself - Using decorators or context managers ensures proper cleanup
- Don't over-mock - Mock only what's necessary to isolate your test
- Test the right thing - Be sure you're testing your code, not just your mocks
Real-world example: When implementing a user authentication feature at Instagram, engineers found that they were making actual API calls in their tests, which made the tests slow and unreliable. By correctly patching the authentication client where it was used rather than where it was defined, they reduced test time from minutes to seconds.
Mock Assertions and Verification
Mocks keep track of how they're used, allowing you to verify that your code under test is interacting with dependencies correctly.
Basic Call Assertions
mock = Mock()
mock(1, 2, key='value')
# Assert that the mock was called
mock.assert_called()
# Assert that it was called once
mock.assert_called_once()
# Assert that it was called with specific arguments
mock.assert_called_with(1, 2, key='value')
# Assert that it was called once with specific arguments
mock.assert_called_once_with(1, 2, key='value')
# Assert that it was never called
mock.assert_not_called()
Call Count and Call Arguments
mock = Mock()
mock(1)
mock(2)
mock(3)
# Check call count
assert mock.call_count == 3
# Check all calls
assert mock.mock_calls == [call(1), call(2), call(3)]
# Check individual calls
assert mock.mock_calls[0] == call(1)
assert mock.mock_calls[1] == call(2)
assert mock.mock_calls[2] == call(3)
# Get call arguments
args, kwargs = mock.call_args
assert args == (3,)
assert kwargs == {}
Method Call Assertions
mock = Mock()
mock.method(1, 2)
mock.method(3, key='value')
# Assert method was called
mock.method.assert_called()
# Assert latest call
mock.method.assert_called_with(3, key='value')
# Assert call history
assert mock.method.mock_calls == [call(1, 2), call(3, key='value')]
# Assert multiple calls with any_order=True
mock.method.assert_has_calls([call(3, key='value'), call(1, 2)], any_order=True)
Complex Call Verification
from unittest.mock import Mock, call
# Create a mock
service_mock = Mock()
# Make several calls
service_mock.process_item(1)
service_mock.process_item(2)
service_mock.finish_batch('batch1')
service_mock.process_item(3)
service_mock.process_item(4)
service_mock.finish_batch('batch2')
# Check the entire call sequence
expected_calls = [
call.process_item(1),
call.process_item(2),
call.finish_batch('batch1'),
call.process_item(3),
call.process_item(4),
call.finish_batch('batch2')
]
assert service_mock.mock_calls == expected_calls
# Check a subset of calls
service_mock.assert_has_calls([
call.process_item(1),
call.finish_batch('batch1')
], any_order=False) # Order matters
Mock Helpers and Special Cases
ANY Matcher
The ANY matcher helps when you don't care about specific argument values:
from unittest.mock import Mock, ANY
mock = Mock()
mock.method(1, 2, name='John')
# Only care about the positional arguments
mock.method.assert_called_with(1, 2, name=ANY)
# Only care about the keyword arguments
mock.method.assert_called_with(ANY, ANY, name='John')
# Don't care about any arguments
mock.method.assert_called_with(ANY, ANY, name=ANY)
Testing Calls That Should Never Happen
mock = Mock()
# ... code that shouldn't call the mock ...
# Verify it was never called
mock.assert_not_called()
# For methods
mock.method.assert_not_called()
Working with AsyncIO
import asyncio
from unittest.mock import AsyncMock, patch
# Original code
async def fetch_data(url):
# ... makes an async HTTP request ...
return await response.json()
# Test using AsyncMock
@patch('module.fetch_data', new_callable=AsyncMock)
async def test_async_function(mock_fetch):
mock_fetch.return_value = {'data': 'test'}
# Call function that uses fetch_data
result = await process_data('https://example.com/api')
# Assertions
mock_fetch.assert_called_once_with('https://example.com/api')
assert result == expected_result
Testing Context Managers
from unittest.mock import MagicMock, patch
# Original code using a context manager
def process_file(filename):
with open(filename, 'r') as f:
data = f.read()
return data.strip()
# Test using mock_open
@patch('builtins.open', new_callable=unittest.mock.mock_open, read_data='file contents\n')
def test_process_file(mock_file):
result = process_file('test.txt')
# Assertions
mock_file.assert_called_once_with('test.txt', 'r')
assert result == 'file contents'
Mocking in Common Scenarios
Mocking HTTP Requests
import requests
from unittest.mock import patch, Mock
def get_user_data(user_id):
response = requests.get(f'https://api.example.com/users/{user_id}')
if response.status_code == 200:
return response.json()
return None
@patch('requests.get')
def test_get_user_data(mock_get):
# Mock successful response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'id': 1, 'name': 'John Doe'}
mock_get.return_value = mock_response
result = get_user_data(1)
mock_get.assert_called_once_with('https://api.example.com/users/1')
assert result == {'id': 1, 'name': 'John Doe'}
# Now test an error response
mock_response.status_code = 404
result = get_user_data(999)
assert result is None
Mocking Database Operations
from sqlalchemy.orm import Session
from unittest.mock import patch, MagicMock
from models import User
def get_user_by_email(email):
with Session() as session:
return session.query(User).filter(User.email == email).first()
@patch('models.Session')
def test_get_user_by_email(mock_session):
# Create mock session and query
mock_session_instance = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
# Configure the chain of calls
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_session_instance.query.return_value = mock_query
mock_query.filter.return_value = mock_filter
# For a successful query
mock_user = MagicMock()
mock_user.email = 'test@example.com'
mock_filter.first.return_value = mock_user
# Call the function
result = get_user_by_email('test@example.com')
# Assertions
mock_session_instance.query.assert_called_once_with(User)
mock_query.filter.assert_called_once()
assert result == mock_user
# For no results
mock_filter.first.return_value = None
result = get_user_by_email('nonexistent@example.com')
assert result is None
Mocking File Operations
import os
from unittest.mock import patch, mock_open
def read_config_file(filename):
if not os.path.exists(filename):
return {}
with open(filename, 'r') as f:
content = f.read()
# Parse content into a config dictionary
config = {}
for line in content.splitlines():
if '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
return config
@patch('os.path.exists')
@patch('builtins.open', new_callable=mock_open, read_data='debug=true\napi_url=https://api.example.com')
def test_read_config_file(mock_file, mock_exists):
# File exists
mock_exists.return_value = True
config = read_config_file('config.ini')
mock_exists.assert_called_once_with('config.ini')
mock_file.assert_called_once_with('config.ini', 'r')
assert config == {
'debug': 'true',
'api_url': 'https://api.example.com'
}
# File does not exist
mock_exists.return_value = False
config = read_config_file('nonexistent.ini')
assert config == {}
Mocking Time
import time
from datetime import datetime
from unittest.mock import patch
def is_weekday():
today = datetime.now()
# Returns 0 for Monday, 6 for Sunday
return today.weekday() < 5
def process_with_timeout(data, timeout=60):
start_time = time.time()
result = perform_processing(data)
elapsed = time.time() - start_time
if elapsed > timeout:
return {'status': 'timeout', 'partial_result': result}
return {'status': 'success', 'result': result}
@patch('datetime.datetime')
def test_is_weekday(mock_datetime):
# Mock Monday (weekday)
monday = datetime(2023, 5, 1) # This was a Monday
mock_datetime.now.return_value = monday
assert is_weekday() is True
# Mock Sunday (weekend)
sunday = datetime(2023, 5, 7) # This was a Sunday
mock_datetime.now.return_value = sunday
assert is_weekday() is False
@patch('time.time')
def test_process_with_timeout(mock_time):
# First call returns start time, second call returns end time
mock_time.side_effect = [1000, 1030] # 30 seconds elapsed
result = process_with_timeout('data')
assert result['status'] == 'success'
# Now mock a timeout
mock_time.side_effect = [2000, 2070] # 70 seconds elapsed
result = process_with_timeout('data', timeout=60)
assert result['status'] == 'timeout'
Real-world example: Netflix uses extensive mocking of time-related functions in their testing suite to ensure that their recommendation algorithms and streaming quality adjustments behave correctly in various time-sensitive scenarios, without having to wait for actual time to pass during tests.
Beyond unittest.mock: Alternative Approaches
pytest-mock
pytest-mock provides a mocker fixture that wraps unittest.mock with some additional conveniences:
import pytest
import requests
def test_get_data(mocker):
# Use mocker instead of patch
mock_get = mocker.patch('requests.get')
# Configure the mock
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'data': 'test'}
mock_get.return_value = mock_response
# Test code that uses requests.get
# No need to clean up - pytest fixtures handle that
Dependency Injection
An alternative to patching is designing code to accept dependencies:
# Instead of this
def function_with_direct_dependency():
client = ApiClient()
return client.get_data()
# Do this
def function_with_injected_dependency(client=None):
if client is None:
client = ApiClient()
return client.get_data()
# Then testing is easier
def test_function():
mock_client = Mock()
mock_client.get_data.return_value = 'mocked data'
result = function_with_injected_dependency(client=mock_client)
assert result == 'mocked data'
mock_client.get_data.assert_called_once()
responses Library
The responses library provides a more intuitive way to mock HTTP requests:
import responses
import requests
@responses.activate
def test_get_user_data():
# Register a mock response
responses.add(
responses.GET,
'https://api.example.com/users/1',
json={'id': 1, 'name': 'John Doe'},
status=200
)
# Call function that makes the request
response = requests.get('https://api.example.com/users/1')
# Assert response was as expected
assert response.status_code == 200
assert response.json() == {'id': 1, 'name': 'John Doe'}
# Verify call count
assert len(responses.calls) == 1
VCR.py
VCR.py records and replays HTTP interactions for tests:
import vcr
import requests
# This will record the interaction if it doesn't exist,
# or replay it if it does
@vcr.use_cassette('fixtures/vcr_cassettes/example.yaml')
def test_get_user_data():
response = requests.get('https://api.example.com/users/1')
assert response.status_code == 200
data = response.json()
assert data['name'] == 'John Doe'
Mocking Anti-Patterns and Pitfalls
Over-Mocking
Mocking everything leads to tests that don't test much:
# Anti-pattern: Over-mocking
@patch('module.ClassA')
@patch('module.ClassB')
@patch('module.ClassC')
@patch('module.ClassD')
def test_function(mock_d, mock_c, mock_b, mock_a):
# So many mocks that the test isn't testing your code anymore
# but just the interaction between your mocks!
Implementation-Detail Testing
Testing how something is implemented rather than what it does:
# Anti-pattern: Testing implementation details
@patch('module.helper_function')
def test_implementation_details(mock_helper):
result = process_data([1, 2, 3])
# This test will break if implementation changes,
# even if the output is still correct
mock_helper.assert_called_with([1, 2, 3])
# Better approach: Test the outcome
def test_outcome():
result = process_data([1, 2, 3])
assert result == expected_result
Mock-Verify-Verify Pattern
Redundant assertions that test both the mock and the outcome:
# Anti-pattern: Redundant verification
@patch('module.get_user')
def test_redundant(mock_get_user):
mock_get_user.return_value = {'name': 'John'}
result = get_user_fullname(1)
# These assertions test the same thing
mock_get_user.assert_called_once_with(1)
assert result == 'John'
# Better: Choose one verification approach
def test_better():
# Either mock and verify the call:
# or
# Use a real dependency and verify the outcome
Mocking the System Under Test
Mocking parts of the code you're trying to test:
# Anti-pattern: Mocking what you're testing
@patch('module.MyClass.method_under_test')
def test_method_under_test(mock_method):
# This doesn't test anything useful!
mock_method.return_value = 'expected'
instance = MyClass()
result = instance.method_under_test()
assert result == 'expected'
Real-world example: Twitter's engineering team discovered a critical bug that slipped through their test suite because they were over-mocking. The tests passed because the mocks were configured to return the expected values, but the actual code had a bug that only appeared in production.
Advanced Mocking Techniques
Recursive Mocking
For complex object hierarchies, configure the mock to return another mock:
client_mock = Mock()
session_mock = Mock()
response_mock = Mock()
# Set up the chain
client_mock.get_session.return_value = session_mock
session_mock.send_request.return_value = response_mock
response_mock.status_code = 200
response_mock.json.return_value = {'data': 'test'}
# Now you can use client_mock in your test
result = function_under_test(client_mock)
# And you can assert at any level
client_mock.get_session.assert_called_once()
session_mock.send_request.assert_called_once_with('GET', '/endpoint')
Custom Mock Classes
Create custom mock classes for complex behavior:
from unittest.mock import Mock
class DatabaseMock(Mock):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.data = {}
def insert(self, table, record):
if table not in self.data:
self.data[table] = []
self.data[table].append(record)
return True
def query(self, table, condition=None):
if table not in self.data:
return []
if condition is None:
return self.data[table]
return [r for r in self.data[table] if condition(r)]
# In your test
@patch('module.get_database', return_value=DatabaseMock())
def test_with_custom_mock(mock_get_db):
# Test code that uses a database
Partial Mocking
Sometimes you only want to mock part of an object:
from unittest.mock import patch, DEFAULT
class ComplexClass:
def method_a(self):
# Complex code
return "result_a"
def method_b(self):
# More code
return "result_b"
def method_c(self):
# Uses method_a and method_b
a_result = self.method_a()
b_result = self.method_b()
return f"{a_result}_{b_result}"
# Patch only method_a
@patch.object(ComplexClass, 'method_a')
def test_partial_mock(mock_method_a):
mock_method_a.return_value = "mocked_a"
instance = ComplexClass()
result = instance.method_c()
assert result == "mocked_a_result_b"
# Multiple partial patches
@patch.multiple(ComplexClass, method_a=DEFAULT, method_b=DEFAULT)
def test_multiple_patches(method_a, method_b):
method_a.return_value = "mocked_a"
method_b.return_value = "mocked_b"
instance = ComplexClass()
result = instance.method_c()
assert result == "mocked_a_mocked_b"
Spies
When you want to track calls but still execute the original method:
from unittest.mock import create_autospec
# Create a real object
calculator = Calculator()
# Create a spy that wraps the real object
calculator_spy = create_autospec(calculator, wraps=calculator)
# Call methods on the spy
result = calculator_spy.add(2, 3)
# The real method is called
assert result == 5
# But calls are still tracked
calculator_spy.add.assert_called_once_with(2, 3)
Mock Strategies for Different Test Types
Unit Tests
In unit tests, mock most external dependencies:
# Unit test with mocks
@patch('module.external_service')
def test_function_unit(mock_service):
mock_service.get_data.return_value = 'test data'
result = function_under_test()
assert result == expected_result
mock_service.get_data.assert_called_once()
Integration Tests
In integration tests, use fewer mocks:
# Integration test with minimal mocking
@patch('module.external_api') # Only mock external API
def test_function_integration(mock_api):
mock_api.get_data.return_value = 'test data'
# Internal dependencies are not mocked
result = function_under_test()
assert result == expected_result
End-to-End Tests
In end-to-end tests, avoid mocks when possible:
# End-to-end test with no mocks
def test_function_e2e():
# Setup test data in real systems
# Call the code under test
result = function_under_test()
# Verify the outcome across the entire system
assert database_contains_expected_data()
assert result == expected_result
Real-world example: Airbnb uses a layered approach to testing their payment processing system. Unit tests with extensive mocking verify each component, integration tests with fewer mocks verify component interactions, and end-to-end tests with minimal mocking verify the entire payment flow.
Practical Exercise
Exercise: Weather Service
Let's practice by mocking dependencies in a weather service application:
# weather_service.py
import requests
import datetime
import json
import os
class WeatherService:
def __init__(self, api_key=None):
if api_key is None:
# Try to get from environment
api_key = os.environ.get('WEATHER_API_KEY')
if not api_key:
raise ValueError("API key is required")
self.api_key = api_key
self.base_url = "https://api.weatherapi.com/v1"
def get_current_weather(self, city):
url = f"{self.base_url}/current.json"
params = {
'key': self.api_key,
'q': city
}
response = requests.get(url, params=params)
if response.status_code != 200:
return {"error": f"Failed to get weather: {response.status_code}"}
data = response.json()
return {
"location": data["location"]["name"],
"country": data["location"]["country"],
"temperature_c": data["current"]["temp_c"],
"condition": data["current"]["condition"]["text"],
"humidity": data["current"]["humidity"],
"updated": data["current"]["last_updated"]
}
def get_forecast(self, city, days=3):
url = f"{self.base_url}/forecast.json"
params = {
'key': self.api_key,
'q': city,
'days': days
}
response = requests.get(url, params=params)
if response.status_code != 200:
return {"error": f"Failed to get forecast: {response.status_code}"}
data = response.json()
forecast = []
for day in data["forecast"]["forecastday"]:
forecast.append({
"date": day["date"],
"max_temp_c": day["day"]["maxtemp_c"],
"min_temp_c": day["day"]["mintemp_c"],
"condition": day["day"]["condition"]["text"],
"chance_of_rain": day["day"]["daily_chance_of_rain"]
})
return forecast
def save_forecast_to_file(self, city, filename=None):
if filename is None:
today = datetime.datetime.now().strftime("%Y-%m-%d")
filename = f"{city}_{today}_forecast.json"
forecast = self.get_forecast(city)
if "error" in forecast:
return False
with open(filename, 'w') as f:
json.dump(forecast, f, indent=2)
return True
Your task:
- Write tests for the WeatherService class, mocking all external dependencies
- Test the error handling when the API returns non-200 status codes
- Use different mocking techniques (patch as decorator, context manager, etc.)
- Verify that API calls are made with the correct parameters
- Test the file saving functionality by mocking the file operations
Summary
- Mocking allows you to replace real objects with simulated ones for testing
- The unittest.mock library provides tools like Mock, MagicMock, and patch
- Mocks can be configured with return values and side effects
- Patching temporarily replaces objects during test execution
- Pay attention to where you patch - patch where the object is used, not where it's defined
- Use mock assertions to verify that mocks were called correctly
- Be aware of common anti-patterns like over-mocking and testing implementation details
- Choose the right mocking strategy for your test type (unit, integration, end-to-end)
Remember: Mocking is a powerful tool, but use it judiciously. The goal is to isolate the code under test, not to avoid testing real behavior.
Assignment
Create a comprehensive test suite for a URL shortener service with the following components:
- Build a
UrlShortenerServiceclass with these features:- Method to shorten URLs (
create_short_url) - Method to retrieve original URL (
get_original_url) - Method to get usage statistics for a URL (
get_url_stats) - Method to export all URLs to a CSV file (
export_to_csv)
- Method to shorten URLs (
- The service should have these dependencies:
- External API for URL validation
- Database for storing and retrieving URLs
- Analytics service for tracking usage statistics
- Logger for logging operations
- Write a comprehensive test suite that:
- Tests all methods with appropriate mocks
- Handles success and error scenarios
- Uses different mocking approaches (decorators, context managers)
- Demonstrates best practices in mock configuration and assertions
- Include a document explaining your mocking strategy and the choices you made
Bonus challenge: Implement an alternative version using dependency injection and compare the two approaches in your testing strategy document.