Mock External Dependencies Cleanly — Test Automation in Practice, Part 4
- Shawn West
- Jul 8
- 3 min read
Updated: Jul 28
Test Automation in Practice · Part 4
Tests that call a real payment API or hit a live database are slow, flaky, and occasionally expensive. Test doubles — fakes, stubs, and mocks — let you exercise your own code's logic without dragging those dependencies along. But reach for the wrong kind and you end up testing your mocks instead of your app, which is worse than not testing at all. This walks through choosing and building the right double for each dependency, cleanly.
Mocking is necessary for tests that touch external services. Done wrong, it produces brittle tests. This tutorial walks through doing it well.
What You'll Build
Test doubles for one or two real external dependencies in your codebase, using the right type for each case.
Step 1: Identify the External Dependencies (10 min)
For your code, list external dependencies:
Third-party APIs (Stripe, Twilio, OpenAI)
Email services
File storage (S3, Azure Blob)
Message queues
Notification services
Analytics services
These are candidates for mocking in tests.
Step 2: Pick the Right Type for Each (10 min)
Default priority:
Real: for things that are cheap and reliable (your own DB, internal services in dev mode)
Fake: for things that need realistic behavior (in-memory storage, in-memory queue)
Stub: for things where return values matter but interactions don't
Mock: when the interaction itself is the contract
For each dependency, pick.
Stripe API → Stub (or use Stripe's test mode)
Email service → Fake (in-memory capture)
S3 → Fake (in-memory file storage)
Internal DB → Real (testcontainers)
Internal services → Real (in test environment)
Webhook delivery → Mock (asserting the call is the contract)
Step 3: Write a Fake (15 min)
For an email service:
class FakeEmailService:
def __init__(self):
self.sent = []
def send(self, to, subject, body):
self.sent.append({
"to": to,
"subject": subject,
"body": body,
"sent_at": datetime.now(),
})
# Test helper methods
def sent_to(self, email):
return [m for m in self.sent if m["to"] == email]
def clear(self):
self.sent = []
The fake mimics the real service's interface but stores state in memory. Tests use it like the real thing:
def test_signup_sends_welcome_email(db_session):
email_service = FakeEmailService()
service = SignupService(email=email_service, db=db_session)
service.signup("Sam", "sam@example.com")
assert email_service.sent_to("sam@example.com")
Step 4: Write a Stub (10 min)
For a Stripe-like service when you don't care about interactions:
class StubPaymentGateway:
def __init__(self, default_response=None):
self.default_response = default_response or {"success": True, "id": "ch_test_123"}
self.responses = {} # Override per-call if needed
def charge(self, amount, card):
# Use override if set, else default
return self.responses.get(amount, self.default_response)
def set_response(self, amount, response):
self.responses[amount] = response
# In a test
def test_failed_payment_handling():
gateway = StubPaymentGateway()
gateway.set_response(100, {"success": False, "error": "card_declined"})
service = CheckoutService(gateway)
result = service.charge_customer(card="...", amount=100)
assert result == "Payment failed"
The stub returns predetermined values. Test verifies what your code does with them.
Step 5: Use a Mock Only When Needed (10 min)
For verifying you sent a webhook with specific data:
def test_webhook_sent_after_order(mocker):
mock_webhook = mocker.patch('app.webhooks.send')
service.complete_order(order_id=42)
mock_webhook.assert_called_once_with(
url="https://customer.example.com/webhook",
body={"event": "order.completed", "order_id": 42}
)
This is a mock because the call itself is the contract. The customer expects this exact webhook.
For most internal logic, you don't need this.
Step 6: Test the Test Doubles Work (5 min)
A quick check that your fake behaves as expected:
def test_fake_email_service_records_sends():
fake = FakeEmailService()
fake.send("a@b.com", "subject", "body")
assert len(fake.sent_to("a@b.com")) == 1
def test_fake_email_service_clear():
fake = FakeEmailService()
fake.send("a@b.com", "subject", "body")
fake.clear()
assert fake.sent == []
Without testing the fake, you might rely on broken test infrastructure.
Step 7: Centralize the Test Doubles (10 min)
Put them in tests/doubles/ (or similar). Share across tests.
tests/
doubles/
email.py # FakeEmailService
payment.py # StubPaymentGateway
storage.py # FakeStorage
unit/
integration/
Each test imports the double from the central location. Updates to the double propagate.
Step 8: Validate Against Reality Periodically (ongoing)
Test doubles drift from real services. Verify periodically:
Run a smoke test against the real service (in a sandbox if possible)
Compare your fake's behavior to the real one for key cases
Update the fake when the real API changes
Drift means tests pass with the fake but the real integration is broken.
What You Just Did
You replaced ad-hoc mocking with deliberate test doubles. Each external dependency has the right type — fake, stub, or mock — based on what matters about it.
Common Failure Modes
Mock everything by default. Tests pass with mocks; production fails.
Fakes that diverge from reality. Behavior in tests doesn't match real service. Validate periodically.
Mocking internal code. Tests assert on calls to your own classes. Brittle. Mock at boundaries.
Setup-heavy mocks. Each test has 20 lines of mock setup. The setup hides what's being tested.
Stale mocks. Real service changed; mocks didn't. Tests lie.
Continue the Test Automation in Practice path
Part of the Test Automation in Practice learning path.


