Mocking & Test Isolation
Mock vs Stub vs Fake vs Spy
| Type | Definition | Returns | Verifies calls |
|---|---|---|---|
| Mock | Pre-programmed with expectations | Configured values | Yes |
| Stub | Canned responses only | Fixed values | No |
| Fake | Working lightweight implementation | Real-ish values | No |
| Spy | Wraps real object, records calls | Real values | Yes |
When to Use Each
Stub — External Service with Known Response
from unittest.mock import patch
def test_user_creation_sends_welcome_email(user_service):
with patch("framework.email.EmailClient.send") as stub:
stub.return_value = {"status": "queued", "id": "msg-123"}
user = user_service.register(email="new@example.com")
assert user["email"] == "new@example.com"
# No verification on stub — just needed to not fail
Mock — Verify Interaction Happened
from unittest.mock import MagicMock, patch
def test_failed_payment_triggers_notification(order_service):
with patch("framework.notifications.NotificationClient") as mock_client:
mock_client.return_value.send_alert = MagicMock()
order_service.process_payment(order_id="ord-001", amount=99.99)
mock_client.return_value.send_alert.assert_called_once_with(
event="payment_failed",
order_id="ord-001",
)
Fake — In-Memory Database or Cache
class FakeCache:
def __init__(self) -> None:
self._store: dict = {}
def get(self, key: str) -> str | None:
return self._store.get(key)
def set(self, key: str, value: str, ttl: int | None = None) -> None:
self._store[key] = value
def delete(self, key: str) -> None:
self._store.pop(key, None)
@pytest.fixture
def cache() -> FakeCache:
return FakeCache()
A fake is a real implementation of an interface that stores data in memory. Appropriate when the real implementation (Redis, Memcached) would slow tests.
pytest-mock Usage
def test_order_created_event_published(order_service, mocker):
mock_publisher = mocker.patch("framework.events.EventPublisher.publish")
order = order_service.create(user_id="u-001", items=["SKU-001"])
mock_publisher.assert_called_once_with(
event="order.created",
payload={"order_id": order["id"], "user_id": "u-001"},
)
mocker.patch from pytest-mock automatically resets after the test.
No manual patch.stop() or context manager needed.
Isolation at Service Boundary
For integration tests, replace only external third-party services. Do not mock your own code in integration tests — that defeats the purpose.
Integration test scope:
┌────────────────────────────────────────┐
│ Your API → Your DB → Your Logic │
│ ↕ (real) ↕ (real) ↕ (real) │
│ [External Payment Gateway] → MOCKED │
│ [External Email Service] → MOCKED │
└────────────────────────────────────────┘
WireMock / respx for HTTP Mocking
import respx
import httpx
@respx.mock
def test_payment_gateway_timeout_handled(order_service):
respx.post("https://payment.external.com/charge").mock(
side_effect=httpx.TimeoutException("timeout")
)
result = order_service.charge(amount=100, token="tok_test")
assert result["status"] == "PAYMENT_TIMEOUT"
assert result["retry_after_seconds"] == 30
Over-Mocking Risk
Over-mocking tests the mock, not the code.
Signs of over-mocking:
- More patch() calls than lines of business logic
- Test passes even when the underlying logic is wrong
- Mocked return values never resemble real data
Rule of thumb:
Mock external dependencies (HTTP, DB, cache, email, queue).
Do NOT mock your own classes, functions, or modules.
If you need to mock your own code to test it, the code needs refactoring — likely a dependency injection problem.
Testcontainers — Real Services in Tests
For integration tests requiring real DB, Redis, or Kafka:
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container():
with PostgresContainer("postgres:16") as pg:
yield pg
@pytest.fixture(scope="session")
def db_url(postgres_container) -> str:
return postgres_container.get_connection_url()
Testcontainers start a real Postgres in Docker, run tests against it, stop it. No mocking. No shared state with other developers. Fully isolated.