mocking-and-test-isolation
verified3d6becb3-7f41-42b8-ab19-f29a5454a4f6
Use when writing unit tests that depend on external systems (HTTP, DB, clock, filesystem) — mock at the boundary, never your own internals. Dummy → Stub → Spy → Mock → Fake hierarchy.
Metadata
Skill file
# Mocking and Test Isolation
**Use when** writing unit tests that depend on external systems — HTTP clients, databases, clocks, filesystems, or random number generators. The rule: mock at the boundary, never your own internals.
## The Test Double Hierarchy
| Double | What it does | When to use |
|--------|-------------|-------------|
| **Dummy** | Passed but never used; just satisfies the arg | Required positional args you don't test |
| **Stub** | Returns canned answers | When you need fixed, predictable responses |
| **Spy** | Records calls for later assertion | When you need to verify *that* something was called |
| **Mock** | Pre-programmed expectations (will fail early if unexpected calls happen) | When call order and exact args matter |
| **Fake** | Working implementation, but lightweight (in-memory DB) | When you need realistic behavior without real infrastructure |
## Rule of Thumb: Mock at the Boundary
```python
# BAD: Mocking your own internal function
def test_process_order():
with patch("app.orders.calculate_tax", return_value=5.0):
# Now your test is coupled to the implementation — you can't
# refactor calculate_tax without breaking this test
assert process_order(...) == ...
# GOOD: Mock at the boundary (HTTP client, DB driver, clock)
def test_process_order():
mock_http = Mock()
mock_http.post.return_value = {"status": "ok"}
# process_order calls an external service — mock THAT boundary
result = process_order(order, http_client=mock_http)
assert result.status == "submitted"
```
### The boundary principle in practice
```python
# DO mock: external I/O, clock, randomness, filesystem
# DON'T mock: your own domain objects, service layer, utility functions
# Acceptable mocks (boundary):
with patch("app.gateway.requests.post") # HTTP
with patch("app.repo.sqlalchemy.Session") # Database
with patch("app.utils.datetime.now") # Clock
with patch("app.gateway.stripe.Charge.create") # Payment
# Smelly mocks (internal):
with patch("app.orders.calculate_tax") # Your own function
with patch("app.models.User.is_premium") # Domain model method
```
## Inject Dependencies for Testability
Don't monkey-patch globals. Inject:
```python
# BEFORE — untestable (global dependency)
import requests
def get_weather(city):
resp = requests.get(f"https://api.weather.com/{city}")
return resp.json()["temp"]
# AFTER — testable (dependency injected)
def get_weather(city, http_client=None):
http = http_client or requests # default to real implementation
resp = http.get(f"https://api.weather.com/{city}")
return resp.json()["temp"]
# Test:
def test_get_weather_stub():
stub = Mock()
stub.get.return_value.json.return_value = {"temp": 22}
assert get_weather("london", http_client=stub) == 22
```
## Stub, Spy, Mock — Worked Examples
```python
from unittest.mock import Mock, patch
# STUB: fixed response
def test_send_email_stub():
mailer = Mock()
mailer.send.return_value = True
result = notify_user("alice@example.com", mailer=mailer)
assert result == True
# SPY: verify it was called
def test_send_email_spy():
mailer = Mock()
notify_user("alice@example.com", mailer=mailer)
mailer.send.assert_called_once_with(
to="alice@example.com",
subject="Welcome!",
)
# MOCK: strict expectations (will fail on unexpected calls)
def test_send_email_mock():
mailer = Mock(spec=EmailService) # only allows real methods
mailer.send.return_value = True
result = notify_user("alice@example.com", mailer=mailer)
assert result == True
```
## Fake Example (In-Memory Database)
```python
# A fake that implements the same interface but in memory
class FakeUserRepo:
def __init__(self):
self._users = {}
def save(self, user):
self._users[user.id] = user
return user
def find_by_email(self, email):
for u in self._users.values():
if u.email == email:
return u
return None
# Test uses the Fake — no real DB, but realistic behavior
def test_register_duplicate_email():
repo = FakeUserRepo()
repo.save(User(id="1", email="a@b.com"))
with pytest.raises(DuplicateError):
register("a@b.com", repo=repo)
```
## Guardrails
- **Never mock the thing you're testing.** If you mock `process_order` in a test called `test_process_order`, the test is meaningless.
- **Mock at the I/O boundary**, not at the service layer. `patch("requests.post")` is fine; `patch("app.services.calculate")` is not.
- **Prefer fakes over mocks** when you need realistic behavior. Mocks test implementation details; fakes test behavior.
- **Tests that assert mock calls but not behavior are fragile.** If your test checks `mock.doSomething()` was called but never checks the result, it's coupling to implementation.
## Pitfalls
| Pitfall | Fix |
|---------|-----|
| Mocking the system under test | Only mock dependencies, never the function/class being tested |
| Mocks hiding integration bugs | Pair unit tests with integration tests |
| `patch` decorating the wrong path | Patch where the object is USED, not where it's DEFINED |
| Tests that only assert mock calls | Also assert the return value / side effect |
| Forgetting to reset between tests | Use `autouse=True` fixtures or `mock.reset_mock()` |
## Verify / Checklist
- [ ] Mocks are only at I/O boundaries (HTTP, DB, Clock, FS, Random)
- [ ] No internal functions are mocked
- [ ] At least one test asserts actual return values, not just mock calls
- [ ] Dependencies are injectable (constructor param or default arg)
- [ ] Integration tests exist for the real boundary (don't only mock)
- [ ] No test pollution: mocks don't leak between tests
Attached files
No attached files.