Why Test Doubles Overcomplicate Python Unit Tests
Mock-heavy unit tests often produce brittle, misleading results. This article argues for simpler, behavior-focused testing using real substitutes and clear boundaries.
Why Python's Test Doubles Overcomplicate Unit Tests (And What To Do Instead)
We've all been there. You're writing a unit test for a function that calls an external API, queries a database, or reads from a file. Your first instinct? Reach for unittest.mock and create a patch, a MagicMock, or a Fake. Before you know it, your test file is longer than the code it's testing, full of assert_called_once_with chains and side_effect dictionaries that only make sense to you at 2 AM.
Let's talk about something that needs to be said in the Python testing community: we've normalized an overcomplicated testing approach that does more harm than good.
The Four Test Doubles Problem
In theory, test doubles (mocks, stubs, fakes, and spies) are elegant. In practice, most Python codebases I've seen at PythonSkillset follow a pattern that goes like this:
@patch('myapp.services.email_service.send')
def test_order_confirmation(mock_send):
order = create_test_order()
mock_send.return_value = {'status': 'sent'}
result = process_order(order)
mock_send.assert_called_once_with(
to=order.email,
template='confirmation',
order_id=order.id
)
This works. But what does it actually test? It tests that send was called with specific arguments. It does not test that an email was actually sent. It doesn't test that the email service works. It tests... the mock.
The Hidden Cost
Every time you use mock.patch, you're making a deal with the devil. Here's what that deal actually costs:
-
Brittle tests: Change the internal implementation of a function? Your mock breaks. Change the parameter name? Your mock breaks. Refactor to use a different service? Your mock breaks.
-
False confidence: Green tests that pass even when the real integration would fail. I've seen entire test suites pass while the production code was silently failing because the mocks didn't match reality.
-
Maintenance nightmare: When a team member joins your project, they don't just need to understand your code. They need to understand your elaborate mock configurations, your
autospecsettings, and yourPropertyMockchain.
The Real Solution: Test Through Boundaries
Here's what PythonSkillset recommends instead. Instead of mocking internals, test through clear boundaries:
# Don't do this:
def send_email(user, message):
# 100 lines of email logic
pass
# Do this instead:
class EmailSender:
def send(self, user, message):
# implementation
pass
# Then test with a real substitute:
class TestEmailSender(EmailSender):
def __init__(self):
self.sent_emails = []
def send(self, user, message):
self.sent_emails.append((user, message))
return True
This TestEmailSender is a genuine fake. It has real behavior. Your tests can ask it "how many emails did you send?" and the answer will be accurate because the fake actually ran.
When Mocking Makes Sense
To be fair, there are times when mocking is appropriate. The rule of thumb at PythonSkillset: only mock things you don't own. This means:
- Third-party packages you don't control
- External APIs that cost money per request
- System resources like filesystem or network
If it's your own code, don't mock it. Refactor it so you can test it directly.
The Simpler Approach
Here's a practical example. Instead of this 12-line test with mocks:
@patch('myapp.cache.redis_client')
@patch('myapp.services.user_service.get_user')
@patch('myapp.services.notification_service.send')
def test_user_notification(mock_send, mock_get_user, mock_redis):
mock_get_user.return_value = User(id=1, email='test@test.com')
mock_redis.get.return_value = None
result = notify_user(1)
mock_send.assert_called_once()
assert result is True
Write this:
def test_user_notification():
# Use real substitutes
user_repo = InMemoryUserRepository()
user_repo.add(User(id=1, email='test@test.com'))
notifier = FakeNotificationService()
service = NotificationService(user_repo=user_repo, notifier=notifier)
result = service.notify_user(1)
assert result is True
assert len(notifier.sent_notifications) == 1
The second version tests actual behavior. If you change how NotificationService works internally, the test still passes as long as the behavior is correct. That's the test you want.
What This Means For Your Codebase
Start looking at your tests with fresh eyes. If a test takes you more than 10 minutes to write, something is wrong. If a test has more mock setup than actual assertions, something is wrong. If a test breaks because you renamed a class method, something is wrong.
The best test doubles are the ones you barely notice. And the simplest path to that? Write code that's testable by design, not code that needs clever mocking to be tested at all.
If you're maintaining a large Python project, this shift will save you more time than any testing framework feature ever could. Trust me on this one.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.