Python mocking: advanced side_effect and spec usage
side_effect
and spec
are powerful but often underutilized features of Python's unittest.mock
library. Mastering them is key to writing tests that are both robust and expressive. This article will dive deep into these advanced concepts, showing you how to simulate complex scenarios and prevent common mocking pitfalls.
Using side_effect
to Simulate Successive Calls
The side_effect
attribute allows you to configure a mock to do something more than just return a single value. It can be a function, an exception, or, most powerfully, an iterable (like a list or a tuple) that provides different return values for successive calls to the mock. This is essential for testing functions that make multiple, stateful calls to a dependency [1].
Example 1: Simulating Successive API Calls
Imagine you're testing a function that polls an API until it receives a successful response. The API might return a "pending" status multiple times before finally returning "completed."
order_tracker.py
import requests
def track_order_status(order_id):
"""Polls an API for order status until it's 'completed'."""
status = 'pending'
while status != 'completed':
response = requests.get(f'https://api.example.com/order/{order_id}')
status = response.json().get('status')
return status
Here's how you can use side_effect
to mock this behavior.
test_order_tracker.py
import unittest
from unittest.mock import patch, Mock
import requests
from order_tracker import track_order_status
class TestOrderTracker(unittest.TestCase):
@patch('order_tracker.requests.get')
def test_tracking_multiple_calls_with_side_effect(self, mock_get):
# Configure the mock to return a different response on each call
mock_get.side_effect = [
Mock(json=lambda: {'status': 'pending'}),
Mock(json=lambda: {'status': 'processing'}),
Mock(json=lambda: {'status': 'completed'})
]
status = track_order_status(123)
# We can verify the mock was called the correct number of times
self.assertEqual(mock_get.call_count, 3)
self.assertEqual(status, 'completed')
By setting side_effect
to a list of mocks, we tell mock_get
to return the first mock on the first call, the second on the second call, and so on. This allows us to simulate the exact behavior of a real, asynchronous API.
Example 2: Simulating Exceptions
side_effect
can also be used to raise exceptions, which is crucial for testing error handling logic.
user_data.py
import requests
def get_user_profile(user_id):
"""Fetches user data and handles network errors."""
try:
response = requests.get(f'https://api.example.com/users/{user_id}')
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(f"Network error: {e}")
return None
We can use side_effect
to raise a RequestException
.
test_user_data.py
import unittest
from unittest.mock import patch
import requests
from user_data import get_user_profile
class TestUserData(unittest.TestCase):
@patch('user_data.requests.get')
def test_get_user_profile_network_error(self, mock_get):
# The mock will now raise this exception when called
mock_get.side_effect = requests.exceptions.RequestException('Simulated Network Error')
user_profile = get_user_profile(1)
# We assert that the function correctly handled the exception
self.assertIsNone(user_profile)
Using side_effect
for exceptions is a cleaner and more direct way to test error handling than manually raising exceptions in your test code.
The Power of spec
and autospec
While side_effect
is about behavior, spec
and autospec
are about enforcing an interface. By default, mocks are highly permissive. You can call any method or access any attribute on them, and they won't complain. This is a common pitfall because it can hide bugs, as a test will pass even if your code tries to call a method that doesn't exist on the real object [2].
spec=True
: This enforces a specification on your mock. It ensures that the mock can only accept the methods and attributes that the real object possesses. If your code tries to call a non-existent method, the mock will raise anAttributeError
.autospec=True
: This is the most powerful option. It automatically creates a mock with the exact same signature (methods and attributes) as the object it's replacing. It also inspects a function's arguments, ensuring that your test code calls the mock with the correct number and type of arguments [2, 3].
Example 3: Enforcing Mock Discipline with autospec
Consider a function that uses a Notifier
class.
notification_service.py
class Notifier:
def send_notification(self, message):
print(f"Sending: {message}")
def notify_admin(notifier_client, message):
notifier_client.send_notification(message)
Now, let's write a test that has a typo in the method name.
test_notification_service.py
import unittest
from unittest.mock import patch
from notification_service import notify_admin, Notifier
class TestNotificationService(unittest.TestCase):
@patch('notification_service.Notifier', autospec=True)
def test_typo_caught_by_autospec(self, mock_notifier_class):
mock_instance = mock_notifier_class.return_value
with self.assertRaises(AttributeError):
# This will raise an error because `send_notificaton` doesn't exist on the real Notifier
mock_instance.send_notificaton('System is down')
Without autospec
, the send_notificaton
method call would not have raised an error, and the test would have provided a false positive. autospec
forces your test to be a faithful representation of your code's interactions, providing a much higher degree of confidence [2].
The Bottom Line: Combining Advanced Features
- Use
side_effect
when you need to simulate dynamic behavior over multiple calls, such as successive API responses or a chain of exceptions. - Use
autospec
(orspec
for simpler cases) on every mock you create with@patch
orcreate_autospec
. It is the single best way to prevent your tests from passing when your code is broken. By using it consistently, you enforce "mock discipline" and ensure your tests are brittle in a good way-they break when the interface they rely on changes, which is exactly what you want.