python-mock-strategy
Use this when designing tests for a class with external dependencies, deciding whether to fake or mock a collaborator, reviewing a test that uses bare Mock() without spec=, replacing monkeypatch hacks
SKILL.mdskills/python-mock-strategy/SKILL.md
GitHubpython-mock-strategy (v1.1.0)
Rationale
A test that mocks too much asserts implementation, not behavior. A test that mocks too little turns into an integration test (slow, flaky). The Python pack's middle ground: design the SDK so each external dependency goes through a Protocol-typed seam; the test injects an in-memory Fake at that seam; only assert what the consumer would observe. Reach for unittest.mock.AsyncMock(spec=...) only when you specifically need call-pattern assertions (was-it-called, was-it-called-with) that a behavioral fake can't make.
This skill is cited by code-reviewer-python (test-quality review-criteria), python-pytest-patterns (Rule 14 narrow fakes), python-asyncio-patterns (AsyncMock for async dependencies), python-mypy-strict-typing (Protocol over ABC for fakeable seams), and sdk-convention-devil-python (C-14 testing convention).
Activation signals
- Designing or reviewing tests for any class with external dependencies.
- Test code uses
Mock()withoutspec=. - Test patches a function inside the SDK rather than injecting at the boundary.
- Test asserts call-arg patterns when it could assert observable behavior.
- HTTP / DB / time / random / filesystem appears in code under test.
- Test is slow because every test spins up a real container.
Core decision tree — Fake or Mock?
Does the test care about the SHAPE of internal calls (count, ordering, arguments)?
└── YES → Mock with spec=Class
└── NO → Did the dependency have observable behavior the consumer relies on?
└── YES → in-memory Fake (Protocol-typed)
└── NO → call it directly (it has no behavior worth doubling)
Fake is the default. Mock is the exception.
Why Fake first
A Fake is a small purpose-built class implementing the SAME Protocol as the production dependency. It has BEHAVIOR — when you put then get, you get back what you put. It can be inspected (fake.published, fake.calls) for assertions. Compared to Mock:
- A Mock returns whatever
return_value=says, regardless of internal state. A Fake's return derives from its state. - A Mock's call assertions (
mock.publish.assert_called_once_with(...)) couple the test to method ORDER. A Fake's state assertions test the OUTCOME the consumer observes. - A Mock with
side_effect=Iteratoris a brittle script. A Fake handles re-entry naturally.
Rule 1 — Design for the seam (Protocol)
# motadatapysdk/storage.py
from typing import Protocol
class Backend(Protocol):
"""The seam through which Storage talks to its byte store.
Implementations: HTTPBackend (production), FileBackend, FakeBackend (tests).
"""
async def put(self, key: str, value: bytes) -> None: ...
async def get(self, key: str) -> bytes | None: ...
class Storage:
def __init__(self, config: Config, *, backend: Backend | None = None) -> None:
self._backend = backend or HTTPBackend(config.base_url)
The backend= keyword-only parameter is how tests inject a Fake. In production it's left to default. This is dependency injection without a DI framework — just a constructor parameter.
Without the seam, tests resort to monkeypatch.setattr on the SDK's internals. That couples tests to internal symbol paths and breaks every refactor. The seam is the test-friendliness contract.
Rule 2 — Write the Fake
# tests/fakes.py
class FakeBackend:
"""In-memory Backend for tests.
Tracks every put/get for assertion. Returns what was put; KeyError if absent.
Examples:
>>> fake = FakeBackend()
>>> import asyncio
>>> asyncio.run(fake.put("k", b"v"))
>>> asyncio.run(fake.get("k"))
b'v'
"""
def __init__(self) -> None:
self._store: dict[str, bytes] = {}
self.put_calls: list[tuple[str, bytes]] = []
self.get_calls: list[str] = []
async def put(self, key: str, value: bytes) -> None:
self.put_calls.append((key, value))
self._store[key] = value
async def get(self, key: str) -> bytes | None:
self.get_calls.append(key)
return self._store.get(key)
# Test-only helpers
@property
def items(self) -> dict[str, bytes]:
return dict(self._store)
The Fake declares two test-helper attributes (put_calls, get_calls) that the test asserts against. They are NOT part of the Backend Protocol — they're the Fake's introspection surface.
mypy --strict accepts FakeBackend as a Backend because the Protocol is structurally typed. No class FakeBackend(Backend): needed (and shouldn't — Protocols are duck-typed).
# tests/test_storage.py
async def test_storage_put_and_get() -> None:
fake = FakeBackend()
storage = Storage(Config(base_url="<unused>"), backend=fake)
await storage.put("user:1", b"alice")
result = await storage.get("user:1")
assert result == b"alice"
assert fake.items == {"user:1": b"alice"}
The test asserts OUTCOME (result == b"alice") and STATE (fake.items), not call patterns. If the Storage implementation refactors from backend.put(key, value) to backend.put(value=value, key=key), the test still passes. That's the point.
Rule 3 — When you do reach for Mock, use spec=
from unittest.mock import AsyncMock
async def test_publish_calls_backend_with_topic() -> None:
backend = AsyncMock(spec=Backend)
storage = Storage(Config(base_url="<unused>"), backend=backend)
await storage.put("topic", b"x")
backend.put.assert_awaited_once_with("topic", b"x")
spec=Backend means:
- Calling a method NOT on Backend raises
AttributeError. Withoutspec=,backend.flarbnitz()would silently return a Mock — typos go undetected. - Auto-generated methods inherit the signature.
backend.put("topic")(missing positional) raisesTypeError. Withoutspec=, the Mock accepts anything. - mypy accepts
AsyncMock(spec=Backend)as aBackend— type-checker friendly.
AsyncMock (3.8+) is the async-aware variant. For sync dependencies, use MagicMock (or Mock).
assert_awaited_once_with(...) (NOT assert_called_once_with) — the awaitable variant verifies the method was both CALLED and AWAITED.
For complex specs, create_autospec(Backend, instance=True) builds a mock from the live spec and respects nested attributes:
from unittest.mock import create_autospec
backend = create_autospec(Backend, instance=True)
backend.get.return_value = b"value" # but only for set methods
Use create_autospec when the Protocol has nested objects; use AsyncMock(spec=...) for flat Protocols.
Rule 4 — HTTP: respx (preferred) or aioresponses
For HTTP-bound code that uses httpx, use respx:
import respx
import httpx
@respx.mock
async def test_publish_calls_remote() -> None:
route = respx.post("https://api.example.com/topics/orders").mock(
return_value=httpx.Response(204)
)
async with Client(Config(base_url="https://api.example.com", api_key="k")) as c:
await c.publish("orders", b"payload")
assert route.called
assert route.call_count == 1
assert route.calls.last.request.content == b"payload"
For aiohttp, use aioresponses:
from aioresponses import aioresponses
async def test_publish_aiohttp() -> None:
with aioresponses() as m:
m.post("https://api.example.com/topics/orders", status=204)
async with Client(Config(...)) as c:
await c.publish("orders", b"x")
Both libraries patch the HTTP transport — the Client's actual network code runs (timeouts, retries, header construction), but the wire is mocked. This is the right grain: it tests the SDK's HTTP behavior without booting a real server.
DON'T monkeypatch.setattr(httpx.AsyncClient, "post", ...) directly — fragile, breaks on internal refactors of the SDK or httpx.
Rule 5 — Time: freezegun
from freezegun import freeze_time
@freeze_time("2026-01-01 12:00:00")
async def test_token_expires_after_3600s() -> None:
cfg = Config(...)
async with Client(cfg) as c:
token = await c.fetch_token() # creation_time = 2026-01-01 12:00:00
# ... advance time ...
For dynamic time advance:
with freeze_time("2026-01-01") as frozen:
token = await client.fetch_token()
frozen.tick(delta=timedelta(hours=2))
assert client.token_is_expired(token)
Avoid monkeypatch.setattr(time, "time", lambda: ...) — only patches one module's import, misses imports in other modules.
For asyncio's clock specifically (asyncio.get_event_loop().time()), freezegun is NOT enough — pytest-asyncio's event loop has its own clock. Use:
async def test_with_async_time(monkeypatch) -> None:
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
# Or use anyio.fail_after for deadline-driven tests
Rule 6 — Random / UUID
import uuid
async def test_session_id_format(monkeypatch) -> None:
fake_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
monkeypatch.setattr(uuid, "uuid4", lambda: fake_uuid)
sid = make_session_id()
assert sid == "session-12345678-1234-5678-1234-567812345678"
Or for random.random()-driven jitter, seed at the random.Random instance level:
class Retry:
def __init__(self, *, rng: random.Random | None = None) -> None:
self._rng = rng or random.Random()
def jitter(self) -> float:
return self._rng.uniform(0, 0.1)
Tests inject Retry(rng=random.Random(seed=42)) for deterministic jitter.
Rule 7 — Filesystem: pyfakefs OR tmp_path
For tests that read/write files, tmp_path (pytest built-in) is usually sufficient:
async def test_loads_config(tmp_path: Path) -> None:
cfg_path = tmp_path / "config.json"
cfg_path.write_text('{"base_url": "https://x"}')
cfg = Config.from_file(cfg_path)
assert cfg.base_url == "https://x"
For tests that test the open() call patterns (rare — usually a smell), pyfakefs simulates a full in-memory filesystem:
def test_with_fakefs(fs): # fs fixture from pyfakefs
fs.create_file("/etc/motadata/config.json", contents='{"x": 1}')
cfg = Config.from_default_paths()
assert cfg.x == 1
pyfakefs is heavyweight; use tmp_path first.
Rule 8 — pytest-mock for mocker fixture
async def test_with_mocker(mocker) -> None:
mock_backend = mocker.AsyncMock(spec=Backend)
storage = Storage(Config(...), backend=mock_backend)
# mocker fixture auto-undoes patches at test end
mocker (from pytest-mock) is the pytest-friendly wrapper around unittest.mock. It auto-undoes patches at test end (so monkeypatch.setattr is rarely needed). Same APIs (mocker.patch, mocker.AsyncMock, mocker.create_autospec).
Rule 9 — Don't mock the public API
Mocking the SDK's OWN public class means the test is testing the test, not the code:
# WRONG — mocks the very class under test
mocker.patch("motadatapysdk.client.Client", AsyncMock())
# RIGHT — uses the real Client; mocks the seam (Backend) it depends on
async with Client(Config(...), backend=fake_backend) as c:
await c.publish("topic", b"x")
The SDK's public surface IS the contract; patching it bypasses the contract. Mock the COLLABORATORS the public class depends on, not the class itself.
Rule 10 — unittest.mock.patch with import paths — patch where USED
# motadatapysdk/client.py
from time import time
def fetch():
timestamp = time() # imports time INTO this module
...
# WRONG patch path
mocker.patch("time.time", lambda: 123) # doesn't patch the imported reference
# RIGHT patch path
mocker.patch("motadatapysdk.client.time", lambda: 123)
Patches replace the symbol at the IMPORT path, not the source. If client.py did from time import time, the symbol now lives at motadatapysdk.client.time — patch THAT path.
This is the most common Mock confusion. Prefer the seam pattern (Rule 1) so you never need import-path patching.
Rule 11.5 — Strict-signature mocking when wrapping a real library API (added v1.1.0)
Added in v1.1.0 in response to the motadata-nats-v1 run's B-series root cause: FakeNats / FakeJs / FakeKVStore / FakeObjectStore substrates accepted **kwargs to remain forward-compatible with future nats-py releases. The **kwargs pattern silently swallowed the kwarg-shape drift between nats-py 2.7 (TPRD-cited floor) and nats-py 2.14 (env-installed actual). Unit tests passing replicas=N on a FakeJs StreamConfig PASSed; the same call against real nats-py 2.14 FAILed at integration-test time (T2).
The lesson: when your Fake wraps a class from a real external library and unit tests are intended to also catch kwarg-name typos / kwarg-rename drift, bind the Fake's accepted signature to the real class's signature via inspect.signature, not via permissive **kwargs.
# BAD — overly permissive; hides kwarg-rename drift
class FakeJsStreamConfig:
def __init__(self, *, name, subjects, **kwargs): # ← swallows everything
self.name = name
self.subjects = subjects
for k, v in kwargs.items():
setattr(self, k, v)
# GOOD — bind to the real class's signature; fail fast on kwarg drift
import inspect
from nats.js.api import StreamConfig as _RealStreamConfig
_REAL_STREAM_SIG = inspect.signature(_RealStreamConfig)
class FakeJsStreamConfig:
"""In-memory analogue of nats.js.api.StreamConfig.
Accepts ONLY the kwargs the real class accepts AT THE CURRENT INSTALLED
nats-py minor. Calling with a kwarg the real class would reject (typo,
rename, removed) raises TypeError at test time — same failure mode as
integration tests would, just without spinning up testcontainers.
"""
def __init__(self, **kwargs: object) -> None:
bound = _REAL_STREAM_SIG.bind(**kwargs) # raises TypeError on unknown kwarg
bound.apply_defaults()
for k, v in bound.arguments.items():
setattr(self, k, v)
The inspect.signature(real_class).bind(**caller_kwargs) call introspects the real class's __init__ and raises TypeError if the caller passes a kwarg the real class doesn't accept. The Fake's behavior remains in-memory (no I/O), but its kwarg-acceptance surface is now coupled to the real library's kwarg-acceptance surface at the installed version. A pip install -U <lib> in CI that introduces a kwarg rename will instantly fail unit tests at the affected mock site, the same way integration tests would fail — just without the 10-second testcontainers spin-up cost.
Caveats:
- Cost:
inspect.signatureis reflection-heavy if called per test; cache theSignatureobject at module level (_REAL_STREAM_SIG = inspect.signature(...)). - Forward-compat: this BINDS to the installed minor. A test that has to assert behavior under a kwarg the SDK uses but the real lib doesn't expose yet (a TPRD invariant) needs a different strategy — usually a per-version conditional
inspect.signature(real_class).parameterscheck +pytest.skipon incompatible installs. - Interaction with
python-dependency-vettingv1.1.0: dep-vet skill's V-12 check catches kwarg-rename at design time (H6) before the SDK code is ever generated. This Rule 11.5 is the SECOND line of defense — catches at unit test time if dep-vet was bypassed or if the dep was upgraded between dep-vet and the test run.
When NOT to use strict-signature mocking:
- The Fake is asserting behavior the real library doesn't even attempt (a TPRD-invariant policy layer like tenant-prefix scoping).
- The Fake is for a Protocol-typed seam where there's no real library class to bind against.
- Unit-test budget is dominated by reflection cost (rarely the case; strongly prefer correctness).
Rule 11 — Async fixtures that return AsyncMock
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
async def mock_backend() -> AsyncMock:
backend = AsyncMock(spec=Backend)
backend.get.return_value = b"default-value"
return backend
async def test_storage_uses_backend(mock_backend) -> None:
storage = Storage(Config(...), backend=mock_backend)
result = await storage.get("k")
assert result == b"default-value"
The fixture is async-friendly because pytest-asyncio's asyncio_mode = "auto" (per python-pytest-patterns Rule 6). AsyncMock is constructed synchronously and used in async tests.
GOOD: full test file
# tests/test_storage.py
"""Tests for motadatapysdk.storage.Storage."""
import pytest
import respx
import httpx
from motadatapysdk.storage import Storage
from motadatapysdk import Config
from tests.fakes import FakeBackend
# --- Behavior tests via Fake (preferred) ---
async def test_put_then_get_roundtrip() -> None:
fake = FakeBackend()
storage = Storage(Config(base_url="<unused>", api_key="k"), backend=fake)
await storage.put("user:1", b"alice")
result = await storage.get("user:1")
assert result == b"alice"
assert fake.items == {"user:1": b"alice"}
async def test_get_missing_returns_none() -> None:
fake = FakeBackend()
storage = Storage(Config(base_url="<unused>", api_key="k"), backend=fake)
result = await storage.get("missing")
assert result is None
# --- Call-pattern test via AsyncMock(spec=) (when needed) ---
async def test_put_calls_backend_put_exactly_once(mocker) -> None:
backend = mocker.AsyncMock(spec=Backend)
storage = Storage(Config(base_url="<unused>", api_key="k"), backend=backend)
await storage.put("k", b"v")
backend.put.assert_awaited_once_with("k", b"v")
backend.get.assert_not_called()
# --- HTTP test via respx (when testing the production HTTP path) ---
@respx.mock
async def test_http_backend_puts_to_correct_url() -> None:
route = respx.put("https://api.example.com/keys/k").mock(
return_value=httpx.Response(204)
)
async with Storage(Config(base_url="https://api.example.com", api_key="k")) as storage:
await storage.put("k", b"v")
assert route.called
assert route.calls.last.request.content == b"v"
assert route.calls.last.request.headers["authorization"] == "Bearer k"
The file demonstrates: behavior-first via Fake (preferred), call-pattern via Mock (when needed), HTTP-path via respx (production behavior with mocked wire). Three different grains for three different test goals.
BAD anti-patterns
# 1. Bare Mock without spec
mock = Mock() # accepts any attribute, any call
mock.flarbnitz_with_no_typo_check() # passes; bug undetected
# 2. Mocking the class under test
mocker.patch("motadatapysdk.client.Client", AsyncMock())
async with Client(Config(...)) as c: # never actually runs Client code
# 3. monkeypatch internal helper
monkeypatch.setattr("motadatapysdk.client._build_request", AsyncMock())
# Brittle to refactor; couples to internal symbol path
# 4. Asserting on call ORDER for a behavior test
mock.put.assert_called_once_with("k", b"v")
mock.get.assert_called_once_with("k")
mock.put.assert_called_once_with(...) # Order assertions break on legit refactor
# 5. side_effect = list of values
mock.get.side_effect = [b"a", b"b", b"c"] # IndexError on 4th call
# 6. Patching at the wrong import path
mocker.patch("time.time", ...) # didn't actually patch where used
# 7. AsyncMock without spec on async dep
backend = AsyncMock() # silently accepts any method
backend.put_typoed("k", b"v") # bug invisible
# 8. Real HTTP in unit test
async with httpx.AsyncClient() as c:
await c.get("https://api.example.com/...") # actual network; flaky
# 9. Mock in fixture, return Mock in production seam
@pytest.fixture
def backend(): return Mock() # Production code expects a Backend; Mock isn't typed
# 10. assert_called_with on async method
backend.put.assert_called_with("k", b"v") # for AsyncMock, use assert_awaited_with
When NOT to mock at all
- Pure functions — call them directly with inputs.
parse_topic("a.b") == ("a", "b")doesn't need any mock. - Stdlib types (dict, list, dataclass) — don't mock; use real instances.
- The class under test — use real Client + mocked dependencies.
- Reading test fixtures from disk —
tmp_pathis fine; pyfakefs is overkill.
Tooling configuration
Add dev dependencies:
[project.optional-dependencies]
test = [
"pytest >= 8.0",
"pytest-asyncio >= 0.23",
"pytest-mock >= 3.12",
"respx >= 0.21", # if using httpx
"aioresponses >= 0.7", # if using aiohttp
"freezegun >= 1.4", # if testing time
"pyfakefs >= 5.3", # if testing filesystem heavily
]
pytest-mock is the only one that's nearly universal; the others are situational. Don't pull all of them by default — sdk-dep-vet-devil-python will scrutinize each.
Cross-references
python-pytest-patternsRule 14 — fakes vs mocks decision; Rule 11 — indirect fixture parametrization.python-mypy-strict-typingRule 5 — Protocol over ABC for fakeable seams.python-asyncio-patterns—AsyncMockfor async deps.python-sdk-config-pattern— Config injection through constructor.python-client-shutdown-lifecycle— fakes implement__aenter__/__aexit__if used as async context managers.sdk-convention-devil-pythonC-14 — design-rule enforcement at D3.