Mocking and Patching in Python Tests

Mastering the art of isolating code for effective testing

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.

mindmap root((Mocking)) What is it? Simulating behavior Replacing real objects Controlling responses Why use it? Isolate code under test Test hard-to-replicate conditions Speed up tests Avoid external dependencies When to use it? External services calls Database interactions File system operations Time-dependent code Network requests

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

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.

graph TD A[Original Code] --> B[patch object/function] B --> C{Test Executes} C --> D[Cleanup] D --> E[Original Code Restored]

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

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:

  1. Write tests for the WeatherService class, mocking all external dependencies
  2. Test the error handling when the API returns non-200 status codes
  3. Use different mocking techniques (patch as decorator, context manager, etc.)
  4. Verify that API calls are made with the correct parameters
  5. Test the file saving functionality by mocking the file operations

Summary

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:

  1. Build a UrlShortenerService class 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)
  2. 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
  3. 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
  4. 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.