Faking External Services
Third-party APIs are slow, rate-limited and occasionally down. Stubs, fakes, recorded responses and local doubles — and how to notice when your fake has drifted from reality.
Third-party APIs are slow, rate-limited and occasionally down. Stubs, fakes, recorded responses and local doubles — and how to notice when your fake has drifted from reality.
A test that calls a real payment provider is slow, needs credentials, fails when the provider is down, cannot produce the error cases you most need to test, and occasionally costs money. So you fake it.
The interesting problem is not how — every language has three ways — but that a fake is a claim about someone else's software, and claims drift. By the end of this lesson you will know which kind of fake to use where, and how to notice when yours has stopped resembling the thing it stands in for.
Several words get used interchangeably, and the distinctions matter because they lead to different tests.
Dummy — passed to satisfy a signature and never used.
Stub — returns a canned answer. No logic. "Ask it for a customer, get this customer."
Fake — a real, simplified implementation. An in-memory repository that genuinely stores and retrieves. Behaves correctly, just not durably.
Mock — a stub that also records how it was called, so a test can assert on the interaction.
Spy — the real thing, wrapped so calls are recorded.
The word test double covers all of them, on the analogy of a stunt double.
Two of these matter most in practice. Stubs and fakes support a test of behaviour: they let the code run so you can assert on the result. Mocks support a test of interaction: they let you assert that something was called. The unit-testing lesson made the case for preferring the first, and it applies with double force at a service boundary.
Where you insert the double determines how much real code runs, and it is the decision that most affects a fake's value.
Simple, fast, and skips your HTTP layer.
Serialisation, headers, retries and timeout handling are all untested, because none of them runs.
Your real client builds and sends the request.
A header you forgot, a field name mismatch, a timeout you never set — all of that runs, and a canned response answers it.
Highest fidelity, most setup.
The provider's own sandbox image, a WireMock instance, MinIO instead of S3, a mock server generated from an OpenAPI spec.
Replace the client object. Pass in something with the same interface. Simple and fast; skips your own HTTP layer entirely, so serialisation, headers, retries and timeout handling are untested.
class FakePaymentGateway:
def __init__(self):
self.charges = []
def charge(self, amount, token):
self.charges.append((amount, token))
return Charge(id="ch_test_1", status="succeeded")
def test_a_successful_charge_marks_the_order_paid():
gateway = FakePaymentGateway()
order = place_order(total=45.00, gateway=gateway)
assert order.status == "paid"
assert gateway.charges == [(45.00, "tok_visa")]Intercept at the HTTP layer. Let your real client build and send the request, and answer it with a canned response. Your serialisation, headers, error handling and retry logic all run.
import respx
@respx.mock
def test_a_successful_charge_marks_the_order_paid():
respx.post("https://api.payments.test/charges").respond(
201, json={"id": "ch_test_1", "status": "succeeded"}
)
order = place_order(total=45.00)
assert order.status == "paid"// the equivalent in JavaScript: Mock Service Worker
server.use(
http.post('https://api.payments.test/charges', () =>
HttpResponse.json({ id: 'ch_1', status: 'succeeded' }, { status: 201 }),
),
);Run a local substitute. A real server on localhost — the provider's own sandbox image, a WireMock instance, MinIO instead of S3, or a mock server generated from an OpenAPI specification. Highest fidelity, most setup.
The main reason to fake an external service is not speed. It is that you cannot make the real one fail on demand, and the failures are where your code is weakest.
@respx.mock
def test_a_declined_card_leaves_the_order_unpaid():
respx.post(CHARGES).respond(
402, json={"error": {"code": "card_declined"}}
)
order = place_order(total=45.00)
assert order.status == "payment_failed"
@respx.mock
def test_a_rate_limit_is_retried():
route = respx.post(CHARGES)
route.side_effect = [
httpx.Response(429, headers={"Retry-After": "1"}),
httpx.Response(201, json={"id": "ch_1", "status": "succeeded"}),
]
order = place_order(total=45.00)
assert order.status == "paid"
assert route.call_count == 2
@respx.mock
def test_a_timeout_does_not_leave_the_order_paid():
respx.post(CHARGES).mock(side_effect=httpx.TimeoutException("timeout"))
order = place_order(total=45.00)
assert order.status == "payment_unknown"
@respx.mock
def test_a_malformed_response_is_handled():
respx.post(CHARGES).respond(200, text="<html>maintenance</html>")
order = place_order(total=45.00)
assert order.status == "payment_failed"That list is the seam-failure list from the previous lesson, made concrete: down, slow, rate-limited, and answering something other than what was agreed. The last one — an HTML error page where JSON was promised — happens in production regularly and crashes clients that assumed a parse would succeed.
Now the real problem with fakes. Your stub returns what the provider's
documentation said in March. In September the provider renamed a field,
added a required parameter, or started returning 409 where it used to
return 400.
Your tests still pass. They are testing your code against your description of a service that has changed — and the failure appears in production, where the code has never met the real response.
Bad — a hand-written stub, written once and never checked:
def fake_charge(amount, token):
return {"id": "ch_1", "status": "succeeded", "amount": amount}Good — the same stub, with a test that compares it against the real service:
def fake_charge(amount, token):
return {"id": "ch_1", "status": "succeeded", "amount": amount}
@pytest.mark.contract # runs nightly, not on every commit
def test_the_real_gateway_still_returns_the_shape_we_fake():
real = live_gateway.charge(amount=1.00, token=SANDBOX_TOKEN)
assert set(real.keys()) >= {"id", "status", "amount"}
assert real["status"] in {"succeeded", "pending", "failed"}The first stub has no mechanism by which anyone could learn it has become wrong. The team's confidence rests on documentation somebody read once, and the discovery happens in production at the worst possible moment.
The second adds one test that touches the real sandbox on a schedule. It does not slow the normal suite down, and when the provider changes something it fails within a day, in a place that names the cause.
Four defences against drift, in ascending order of effort:
Recorded responses. Record real interactions once and replay them —
VCR, pytest-recording, Polly.js. The recording is real, and re-recording
periodically refreshes it. The trap is a recording from two years ago that
nobody has refreshed.
Contract tests against a sandbox. As above: a small suite, run nightly, asserting only on the shape of responses your code depends on.
Schema validation. If the provider publishes an OpenAPI or JSON Schema document, validate your fake's responses against it in a test. Then a published change breaks your build.
A provider-supplied fake. Increasingly common — Stripe's mock server, LocalStack for AWS, MinIO for S3-compatible storage. Maintained by somebody with an interest in fidelity, which is the strongest form available.
One structural habit prevents most fake-related pain: wrap the external service in an interface you own.
class PaymentGateway(Protocol):
def charge(self, amount: Decimal, token: str) -> Charge: ...
def refund(self, charge_id: str) -> Refund: ...Your application depends on PaymentGateway. One implementation calls the
provider; another is a fake for tests. Three benefits follow.
The fake has a defined surface — the interface says exactly what must be imitated, so it cannot quietly diverge in scope.
Provider concepts stay at the edge. Your order code does not know
about payment_intent or charge_id formats, so replacing the provider
is a change in one file.
And the interface is a statement of what you actually use, which is what a contract test should assert on.
Three cases where faking is the wrong answer:
Your own database. It is not external. The previous lesson made the case: substituting it removes the seam you were testing.
When the integration itself is the risk. For a new provider, in the first week, run against the sandbox for real. You do not yet know enough about its behaviour to write an honest fake — and finding out is the point.
In a smoke test after deployment. A post-deployment check exists to confirm the real system works in the real environment. Faking anything there defeats it: credentials, network policy and configuration are exactly what you are checking.
# Vocabulary (all of them are "test doubles")
dummy passed to fill a signature, never used
stub returns a canned answer, no logic
fake a real simplified implementation (in-memory repository)
mock a stub that records calls, so you can assert on them
spy the real thing, wrapped to record calls
# stubs and fakes support testing BEHAVIOUR <- prefer these
# mocks support testing INTERACTION <- when the call IS
# the outcome: "an email was sent" has no return value
# Three places to put the seam
replace the client simple, fast; skips YOUR http layer entirely
intercept HTTP your serialisation, headers, retries all run
<- the sweet spot: respx, MSW, WireMock
run a substitute highest fidelity, most setup: LocalStack,
MinIO, a provider's mock server
# The point of faking is the FAILURES you cannot cause for real
declined / 402 does the order end up unpaid?
rate limited / 429 is it retried, with backoff?
timeout ambiguous! the charge MAY have succeeded
malformed response HTML where JSON was promised — a real event
slow is there a timeout at all?
# Drift is the real risk
# your stub encodes the provider's behaviour as of the day you
# wrote it. Tests keep passing while production breaks.
defences, ascending effort:
recorded responses VCR, pytest-recording, Polly.js — refresh them
contract test nightly assert only the shape your code depends on
schema validation validate the fake against a published schema
a provider's own fake strongest: Stripe mock, LocalStack, MinIO
# Wrap the service in an interface you own
class PaymentGateway(Protocol):
def charge(self, amount, token) -> Charge: ...
# the fake has a defined surface; provider concepts stay at the
# edge; the interface states exactly what a contract test covers
# Do not fake
your own database not external — you lose the seam
a brand-new integration use the sandbox; you cannot fake what you
do not yet understand
a post-deployment smoke the real environment IS the thing checkedThe next two lessons are about the other half of a test's setup: fixtures and factories for building the objects a test needs, and then keeping database state isolated between tests without making the suite slow.
Before that, look at one fake in your own suite and ask what would happen if the service it imitates changed a field name tomorrow. If the answer is "the tests would keep passing", you have found the gap this lesson is about — and a nightly contract test is an hour's work.