Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 058 / 100

Mocking in Python tests: real-world lessons and examples

Replace slow or external dependencies.

Expona Admin8 min read

Mocking in Python tests: real-world lessons and examples

You're writing a test for an order processing service that calls a payment gateway, sends emails, and updates a database—all in one flow. Running these dependencies every time your test suite runs is slow, unreliable, and defeats the purpose of unit testing. Mocking in Python tests helps you replace slow or external dependencies with controlled stand-ins, letting your tests run fast and predictably. Over 58 days of backend development work, we've learned that mocking done well accelerates both testing velocity and confidence in your code.

The challenge isn't whether to mock—it's how and when to do it effectively. We'll walk through the practical patterns and tools we've settled on across real e-commerce, CRM, and SaaS systems.

unittest.mock.patch — Patch where the object is used, not where it is defined

The most common mistake when mocking is patching at the wrong location. When you patch an object, you must patch it in the module or namespace where it's imported and used, not where it's defined. This subtle distinction trips up many developers.

Imagine a payments module that imports a Stripe client:

python
# payments.py
from stripe import Client

def process_payment(amount, card_token):
    client = Client()
    return client.charge(amount, card_token)

If you write a test that patches stripe.Client, it won't work as expected because your test is patching the definition site, not the usage site:

python
# test_payments.py — WRONG
import unittest
from unittest.mock import patch

class TestPayments(unittest.TestCase):
    @patch('stripe.Client')  # Patches at definition, not usage
    def test_process_payment_wrong(self, mock_client):
        from payments import process_payment
        process_payment(100, 'tok_123')
        # This test may not behave as expected

Instead, patch where Client is used—in the payments module:

python
# test_payments.py — CORRECT
import unittest
from unittest.mock import patch, MagicMock

class TestPayments(unittest.TestCase):
    @patch('payments.Client')  # Patches where it's used
    def test_process_payment_correct(self, mock_client):
        from payments import process_payment
        
        mock_instance = MagicMock()
        mock_instance.charge.return_value = {'id': 'charge_123'}
        mock_client.return_value = mock_instance
        
        result = process_payment(100, 'tok_123')
        
        assert result['id'] == 'charge_123'
        mock_instance.charge.assert_called_once_with(100, 'tok_123')

This pattern applies to any external dependency: database clients, HTTP libraries, file systems. The key is to ask yourself, "Where in my code is this object imported?" and patch there.

Prefer fakes — A simple fake class is often clearer than deep mock chains

Mocks are powerful, but they can become verbose and brittle. When you find yourself chaining .return_value calls or setting up complex assertions, consider writing a simple fake class instead. Fakes are real implementations of an interface that behave in a controlled way, making tests more readable and maintainable.

Consider a caching layer that your order service depends on:

python
# cache.py
class CacheClient:
    def get(self, key):
        raise NotImplementedError
    
    def set(self, key, value, ttl=None):
        raise NotImplementedError

# orders.py
class OrderService:
    def __init__(self, cache_client):
        self.cache = cache_client
    
    def get_order(self, order_id):
        cached = self.cache.get(f'order:{order_id}')
        if cached:
            return cached
        # fetch from DB
        order = {'id': order_id, 'total': 150}
        self.cache.set(f'order:{order_id}', order, ttl=3600)
        return order

With mocks, the test becomes a tangle of setup:

python
# test_orders.py — Mock-heavy approach
from unittest.mock import MagicMock

def test_order_service_with_cache_mock():
    mock_cache = MagicMock()
    mock_cache.get.return_value = None
    mock_cache.set.return_value = None
    
    service = OrderService(mock_cache)
    order = service.get_order(1)
    
    assert order['id'] == 1
    mock_cache.get.assert_called_once_with('order:1')
    mock_cache.set.assert_called_once()
    assert mock_cache.set.call_args[0] == ('order:1', {'id': 1, 'total': 150})

With a fake, the test reads like plain logic:

python
# test_orders.py — Fake class approach
class FakeCacheClient:
    def __init__(self):
        self.store = {}
        self.get_calls = []
        self.set_calls = []
    
    def get(self, key):
        self.get_calls.append(key)
        return self.store.get(key)
    
    def set(self, key, value, ttl=None):
        self.set_calls.append((key, value, ttl))
        self.store[key] = value

def test_order_service_with_cache_fake():
    cache = FakeCacheClient()
    service = OrderService(cache)
    
    order = service.get_order(1)
    
    assert order['id'] == 1
    assert cache.get_calls == ['order:1']
    assert cache.set_calls[0] == ('order:1', {'id': 1, 'total': 150}, 3600)

Fakes also make it easy to test edge cases. Want to test what happens when cache returns stale data? Just override the fake's behavior for that test. This is far cleaner than reconfiguring mock return values.

Mock HTTP — respx for httpx and responses for requests

HTTP calls are common in backend services: payment providers, notification services, third-party APIs. Mocking them properly keeps tests isolated and fast. The standard libraries for this are responses (for the requests library) and respx (for the newer httpx library).

If you're using requests, use responses:

python
# notifications.py
import requests

def send_notification(user_id, message):
    response = requests.post(
        'https://api.notify.example.com/send',
        json={'user_id': user_id, 'message': message},
        timeout=5
    )
    return response.status_code == 200

# test_notifications.py
import responses

@responses.activate
def test_send_notification():
    responses.add(
        responses.POST,
        'https://api.notify.example.com/send',
        json={'status': 'sent'},
        status=200
    )
    
    result = send_notification(123, 'Hello!')
    assert result is True
    assert len(responses.calls) == 1
    assert responses.calls[0].request.body == b'{"user_id": 123, "message": "Hello!"}'

For httpxrespx:

python
# billing.py
import httpx

async def check_subscription(user_id):
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f'https://api.billing.example.com/users/{user_id}/subscription',
            timeout=5
        )
        return response.json()

# test_billing.py
import respx
import pytest

@pytest.mark.asyncio
async def test_check_subscription():
    with respx.mock:
        respx.get('https://api.billing.example.com/users/456/subscription').mock(
            return_value=httpx.Response(
                200,
                json={'plan': 'pro', 'active': True}
            )
        )
        
        result = await check_subscription(456)
        assert result['plan'] == 'pro'

Both libraries handle query parameters, headers, and request matching elegantly. They also let you simulate failures—timeouts, rate limits, 5xx errors—without touching real networks.

Do not mock everything — Use Testcontainers for real databases in integration tests

The temptation to mock everything is strong. Mock the database, mock the ORM, mock the query results. But this creates a false sense of security. Code that passes all mocked tests can still fail against a real database due to schema mismatches, transaction handling, or query logic errors. For integration tests, use real infrastructure—or as close as possible.

Testcontainers is a Python library that spins up Docker containers for your tests: PostgreSQL, Redis, MongoDB, etc. Tests run against actual instances, and containers are torn down when done.

Here's a practical example with PostgreSQL:

python
# models.py
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, Session

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True)
    name = Column(String)

# repository.py
class UserRepository:
    def __init__(self, session: Session):
        self.session = session
    
    def create_user(self, email, name):
        user = User(email=email, name=name)
        self.session.add(user)
        self.session.commit()
        return user
    
    def find_by_email(self, email):
        return self.session.query(User).filter(User.email == email).first()

# test_repository.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

@pytest.fixture(scope='session')
def postgres_container():
    with PostgresContainer('postgres:14') as container:
        yield container

@pytest.fixture
def db_session(postgres_container):
    engine = create_engine(postgres_container.get_connection_url())
    Base.metadata.create_all(engine)
    session = Session(engine)
    yield session
    session.close()

def test_create_and_find_user(db_session):
    repo = UserRepository(db_session)
    
    repo.create_user('alice@example.com', 'Alice')
    user = repo.find_by_email('alice@example.com')
    
    assert user is not None
    assert user.name == 'Alice'

def test_unique_email_constraint(db_session):
    repo = UserRepository(db_session)
    
    repo.create_user('bob@example.com', 'Bob')
    
    with pytest.raises(Exception):  # SQLAlchemy will raise IntegrityError
        repo.create_user('bob@example.com', 'Bob Duplicate')

The test now verifies real database behavior: unique constraints, transactions, and ORM mapping. This catches bugs that mocks never would.

A typical strategy is: unit tests with mocks and fakes for fast feedback, integration tests with Testcontainers for confidence.

Common mistakes

  • Patching at the definition site instead of the usage site. Always ask, "Where is this imported?" and patch there.
  • Over-mocking. Mocking the database, ORM, and query builder in the same test removes all confidence. Mock external services; test logic against real databases in integration tests.
  • Forgetting to assert calls. A mock that doesn't assert on how it was called is just a return value. Use assert_called_with or assert_called_once_with to verify behavior.
  • Not resetting mocks between tests. If using mock decorators, they're reset automatically. If manually creating mocks, clear them between tests or use fixtures.
  • Writing fake classes that are too complex. A fake should be simpler than the real implementation. If it's not, you're testing the fake instead of your code.

Key takeaways

  • Patch where an object is used, not where it's defined—this is the most critical mocking rule.
  • Replace verbose mock chains with simple fake classes for better readability and maintainability.
  • Use responses for requests and respx for httpx to mock HTTP calls consistently.
  • Use Testcontainers for integration tests with real databases—they're faster and more reliable than mock databases.
  • Balance unit tests (fast, mocked) with integration tests (slower, real infrastructure) for both speed and confidence.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles