Opinion

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.

July 2026 5 min read 10 views 0 hearts

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 autospec settings, and your PropertyMock chain.

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.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.