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_effectwhen you need to simulate dynamic behavior over multiple calls, such as successive API responses or a chain of exceptions. - Use
autospec(orspecfor simpler cases) on every mock you create with@patchorcreate_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.
