Pytest mock pitfalls
Writing tests that are effective and maintainable is an art. While mocking is a powerful tool, it's not a silver bullet and can be misused. This final article of the mocking series will cover the best practices and common pitfalls of mocking in Python, ensuring your tests are robust, readable, and provide a high degree of confidence in your code.
When to Mock and When Not to Mock
The golden rule of mocking is: Don't mock what you don't own [1]. You should only mock external dependencies that are outside of your control. Mocking internal, private methods or core business logic is a sign that your tests are too tightly coupled to the implementation details of your code. If the internal logic changes, your tests will break, even if the public behavior of the code remains the same. This leads to brittle tests that are a burden to maintain and provide a false sense of security.
Here's a quick guide:
- Mock external services: APIs, databases, file systems, and third-party libraries. These are slow, unreliable, and have side effects you want to avoid during a unit test.
- Don't mock core domain logic: Your main functions and classes should be tested by passing in real, concrete data. Mocks in this context can hide bugs because they simulate a behavior that might not match reality. It is better to rely on lightweight, in-memory implementations of dependencies where possible, also known as fakes.
A good test asserts on the behavior of your code, not on the internal implementation of its dependencies.
Using autospec
to Create Smarter Mocks
One of the most common pitfalls of mocking is that a test can pass even when the code under test is broken. This happens because mocks are, by default, overly permissive. If you make a typo in a method call, a standard mock will simply create a new, empty mock attribute, and the test will still pass.
The autospec
feature prevents this by creating a mock that has the same interface as the real object [2, 3]. If you try to call a method that doesn't exist on the real object, the mock will raise an AttributeError
, just like the real object would. This ensures that your tests are not only passing but are also correctly interacting with the objects they are testing.
Example: Catching Typos with autospec
Let's assume we have a simple class UserService
that interacts with a DatabaseClient
.
user_service.py
class DatabaseClient:
def get_user_by_id(self, user_id):
# This would be a real database query
return {"name": "Test User"}
class UserService:
def get_user_name(self, db_client, user_id):
user = db_client.get_user_by_id(user_id) # Typo here: should be `get_user_by_id`
return user["name"]
Without autospec
, a test for this typo would pass:
test_no_autospec.py
import unittest
from unittest.mock import Mock
from user_service import UserService, DatabaseClient
class TestUserService(unittest.TestCase):
def test_get_user_name_typo(self):
# This mock will not raise an error if we misspell a method
mock_db = Mock()
mock_db.get_user_by_id.return_value = {"name": "John Doe"}
# This test will pass even though the real code has a bug
user_service = UserService()
user_name = user_service.get_user_name(mock_db, 1)
self.assertEqual(user_name, "John Doe")
With autospec
, the test will correctly fail, helping you find the bug.
test_autospec.py
import unittest
from unittest.mock import patch, create_autospec
from user_service import UserService, DatabaseClient
class TestUserService(unittest.TestCase):
@patch('user_service.DatabaseClient', autospec=True)
def test_get_user_name_typo_with_autospec(self, MockDatabaseClient):
# We can create a smart mock for our class
mock_db = create_autospec(DatabaseClient)
mock_db.get_user_by_id.return_value = {"name": "John Doe"}
user_service = UserService()
with self.assertRaises(AttributeError):
user_service.get_user_name(mock_db, 1)
The autospec=True
argument or the create_autospec
function ensures that our mock behaves like the real DatabaseClient
, catching the typo before it reaches production.
Verifying Mocks with Assertions
A mock is only useful if you verify that your code interacted with it correctly. The unittest.mock
library provides a suite of assertion methods for this purpose. These assertions should be used to confirm that your code made the expected calls with the correct arguments.
mock.assert_called()
: Asserts that the mock was called at least once.mock.assert_called_once()
: Asserts that the mock was called exactly once.mock.assert_called_with(*args, **kwargs)
: Asserts that the mock was called with the specified arguments.mock.assert_called_once_with(*args, **kwargs)
: Combines the two previous assertions. This is one of the most common and useful mock assertions [5].
Example: Asserting on Mock Calls
Let's revisit the email example from a previous article.
email_service.py
def send_welcome_email(email_client, user_email):
email_client.send_email(
to=user_email,
subject="Welcome!",
body="Thanks for signing up."
)
We can write a test to assert that the send_email
method was called with all the correct arguments.
test_email_service.py
import unittest
from unittest.mock import MagicMock
from email_service import send_welcome_email
class TestEmailService(unittest.TestCase):
def test_send_welcome_email_calls_client_correctly(self):
mock_email_client = MagicMock()
send_welcome_email(mock_email_client, "test@example.com")
# Assert that the method was called exactly once with the expected arguments
mock_email_client.send_email.assert_called_once_with(
to="test@example.com",
subject="Welcome!",
body="Thanks for signing up."
)
These assertions provide clarity and confidence that your code is correctly interacting with its dependencies.
By following these best practices, you can leverage the full power of mocking to write tests that are fast, reliable, and easy to maintain.
Sources
- Don't Mock What You Don't Own
- unittest.mock - mock object library
- Mocking in Python with autospec
- Mocks, Stubs, and Fakes
- Python - assert that a mock function was called
This video provides an excellent summary of why autospec
is a must-have for robust Python testing.