Fakes for the Neighbours
Payrail cannot run inside the test suite, and calling their sandbox on every test is slow at 400 milliseconds a call, shared with every other Stagedoor developer, and rate-limited to a few hundred calls an hour. The suite calls it 400 times a build. So the suite talks to a fake: a class that implements the same interface as the PayrailClient of Topic 53, with a working in-memory behaviour. It charges, it declines the test card ending in 0002, it remembers every charge by idempotency key so a repeat returns the same result, and it records what it was asked so a test can look. The contract test of Topic 57 is what keeps it honest, on a schedule, against the sandbox.
The topic is the difference between a fake that behaves and a mock that asserts, applied to every external dependency Stagedoor has: the provider, the stream, and the clock. Each gets a fake with the same interface as the real thing, each is injected through the constructor of Topic 20 and nothing else, and each has controls for the failures the real thing produces on its own schedule and the fake produces on demand.
Fake, Not Mock
FakePayrail is 40 lines. Its charge takes the same arguments as the real client's, generates a provider_ref, stores the result in a dictionary keyed by the idempotency key, and returns a ChargeResult with a status of charged. Called again with the same key it returns the stored result without creating a second charge, which is Topic 39's idempotency on Payrail's side, imitated. Given the test card ending in 0002 it returns a status of declined with a decline code, as the sandbox does. Its refund looks the charge up by reference and marks it refunded. Every charge it ever made is in fake.charges, and a test that wants to know what happened reads that.
class FakePayrail: # same charge() and refund() as PayrailClient DECLINE_CARD = "0002" def __init__(self): self.charges: dict[str, ChargeResult] = {} # keyed by Idempotency-Key, as Payrail keys them self.next: Exception | ChargeResult | None = None # scripted outcome for the next call self.fail_rate = 0.0 async def charge(self, order, source: str, ctx) -> ChargeResult: key = f"order-{order.public_id}" if key in self.charges: return self.charges[key] # a repeat returns the first answer: one charge if self.next is not None: outcome, self.next = self.next, None if isinstance(outcome, Exception): raise outcome return outcome if random.random() < self.fail_rate: raise PayrailTimeout() if source.endswith(self.DECLINE_CARD): return ChargeResult(status="declined", decline_code="insufficient_funds") result = ChargeResult(status="charged", provider_ref=f"ch_fake_{len(self.charges) + 1}", amount_cents=order.total_cents) self.charges[key] = result return result
The class keeps three things: the charges it has made, keyed the way Payrail keys them; one scripted outcome for the next call, which the failure tests set; and a failure rate for the partial-failure tests of Topic 66. The charge method checks them in order. A repeated key returns the stored result, which is the behaviour the double-charge regression depends on. A scripted outcome is returned or raised once and cleared. A failure rate above zero raises the timeout on that fraction of calls. The decline card declines. Everything else charges and is remembered. A test that uses this class asserts on outcomes, the order is paid, fake.charges has one entry, and never on the fact that charge was called once with these exact arguments. When Topic 56 renamed the source argument, no test built on the fake changed; the 30 tests that had been built on a mock all did.
Injecting It
build_service(config, payrail=FakePayrail()). There is nothing more to it. The constructor of Topic 20 takes the Payrail client as a dependency and the fake satisfies the same Protocol, so the handler under test receives a service whose payrail attribute is the fake and does not know. No monkeypatching of a module, no environment flag that switches the client into test mode, no if TESTING in production code. The same constructor with a different argument, and the domain and transport code paths are the production ones, byte for byte.
One layer sits between the fake and the sandbox, and it is where the client's own behaviour is tested. The fake replaces the whole PayrailClient, so a test built on it never runs the client's retry wrapper, its breaker or its response parser; those are the client's behaviour and they need a test of their own. That test runs the real client against a scripted HTTP transport, respx in Stagedoor's case, which intercepts httpx at the transport and answers each request from a script: a 201 with this body, a 503 with Retry-After, a read timeout, a 200 whose body is HTML. Twenty tests of that shape cover Topic 53's decision table, and they are the only tests in the suite that know Payrail's URL.
Scripting Failures
fake.next = PayrailTimeout() makes the next charge time out. fake.next = ChargeResult.unknown() makes it return the outcome the parser produces for a body it cannot read. fake.fail_rate = 0.05 makes one call in twenty time out at random. Each control tests what the code above the client does with an outcome, without Payrail's cooperation: the unknown-outcome path of Topic 53 leaves the order pending and extends the hold; the degrade of Topic 42 answers 503 with Retry-After when the breaker is open. The retry itself and the breaker itself live inside the client, so their tests use the scripted transport, and the demonstrated one is the regression for the double charge.
@respx.mock(base_url="https://api.payrail.example") async def test_retried_checkout_charges_once(respx_mock, order, ctx): route = respx_mock.post("/v1/charges").mock(side_effect=[ httpx.ReadTimeout("no bytes in 3 s"), # attempt 1: the answer is lost httpx.Response(201, json={"id": "ch_1", "status": "charged", "amount_cents": 12800, "currency": "EUR"}), ]) client = PayrailClient("https://api.payrail.example", key=test_key, timeout_ms=3000) result = await client.charge(order, source="pm_test", ctx=ctx) assert result.status == "charged" assert route.call_count == 2 # exactly one retry keys = {c.request.headers["Idempotency-Key"] for c in route.calls} assert keys == {f"order-{order.public_id}"} # one key on both: Payrail records one charge
The transport is scripted with two answers in order: the first request gets a read timeout, the second gets a 201. The real client is built with the production timeout and pointed at the scripted address. It charges once, from the caller's point of view, and the assertions state the incident's fix as facts: the result is charged, the transport saw exactly two requests, and both carried the same Idempotency-Key. That last line is the one that matters, because a key that is the same on every attempt is what makes Payrail's side record one charge, and the bug of the spring on-sale was a key that did not exist. Change the client to generate a key per attempt and the set has two members; remove the retry and the count is one and the result is unknown. Either change fails the test with the line that names why.
Recorded Webhooks
The inbound side, the payment.settled webhook of Topic 54, is tested with recorded payloads rather than written ones. Marek ran a charge against the sandbox once, captured the webhook it sent, raw body and Payrail-Signature header together, and saved it under tests/fixtures/payrail/. The test replays it through the real webhook handler with the sandbox's signing secret in the test config, and asserts the outcome: the webhook_events row exists, the order moved to paid, a second replay of the same file changes nothing. Six recordings cover settled, refunded, disputed, a malformed signature and two orderings of settled and refunded for one order.
A hand-written payload tests the author's understanding of Payrail. A recorded one tests Payrail. The difference showed up the first week: the amount_cents field Marek had typed as an integer arrives from Payrail as a string, "12800", and the strict model of Topic 53 would have rejected every real webhook on the first day in production while the hand-written test stayed green. Recordings go stale, and the contract test of the next section is what notices; when it does, the recording is re-captured, not edited by hand, because an edited recording is a hand-written payload with a misleading name.
Keeping the Fake Honest
The fake is a belief about Payrail written in Python, and beliefs drift. The contract test of Topic 57 runs the real client against the sandbox on a schedule, nightly at 06:00, and asserts the shapes the fake produces: a valid card parses into charged with a reference of the expected form, the card ending in 0002 produces a 402 that parses into declined with a decline code, a second charge with the same key returns the first charge's reference, and a refund of that charge parses. When Payrail changes a field, that test fails first, on a Tuesday morning, and the fake is updated second. The suite that used the fake was never wrong about Stagedoor's code. It was wrong about Payrail, and the contract test is the only thing that can tell it so.
The order of repair matters. The contract test fails; Marek reads Payrail's changelog; the strict model and the fake change in one pull request; the recordings are re-captured; the suite is green again against a fake that is true again. A team that skips the contract test learns about the change from the reconciliation of Topic 55 disagreeing with Payrail on a Sunday, which is the expensive version of the same information.
The Fake for the Stream and the Clock
Every external dependency gets the same treatment, and Stagedoor has two more. MemoryStream is a list with the add, read and ack methods of the Redis stream client of Topic 44, so a test of the enqueue path asserts that the outbox relay added one entry of kind render_tickets with the order's id, and a test of the worker reads it back, processes it and acknowledges it, all in memory. FrozenClock is Topic 20's clock with a set method, and it is what lets a hold expire in 2 milliseconds of test time instead of 10 minutes of wall time. The domain tests use all three fakes at once, through one build_service call, and that is what makes 1,050 of them run in 4 seconds.
The rule that produces all three is the same: an interface small enough to fake, defined by what the domain needs and not by what the library offers. A stream client with 40 methods cannot be faked in an afternoon; one with 3 can, and the domain never needed the other 37.
The fake is fast, 400 calls in well under a second, deterministic, and scriptable for every failure Topic 66 needs. It is exactly as accurate as the contract test keeps it and not one field more. The suite uses it, on every run.
The sandbox is Payrail's own, so it is accurate by definition, and it is slow, shared between every developer and every CI run, rate-limited, and unable to time out on demand. The contract test uses it, nightly, and nothing else does.
Neither replaces the other. A suite on the sandbox alone is slow and flaky; a suite on the fake alone is green for months after Payrail changes. Stagedoor runs both, on different schedules, and reads the contract test's failure as the fake's bug report.
- A mock that asserts
chargewas called with exact arguments — the refactor that renamessourcebreaks 30 tests and changes no behaviour, and the team learns to ignore red. - Monkeypatching
httpxinside the client — the test knows the client's internals, passes when the internals are wrong in the same way, and breaks when they change in any way. - A fake that always succeeds — the retry, the breaker, the unknown-outcome path and the extended hold are untested until Payrail's first slow night in production.
- Hand-written webhook payloads — the
amount_centsfield Payrail actually sends as a string, tested as an integer, and every real webhook rejected by the strict model on the first day. - No contract test — the fake and Payrail diverged in March, the suite has been green since, and the divergence is found by the reconciliation job in June.
- Write one fake per external dependency, implementing the same small interface with working behaviour and a memory, and assert on outcomes rather than calls.
- Inject every fake through
build_serviceand script failures through the fake's own controls, never through monkeypatching or an environment flag. - Test the client's own retry, breaker and parser separately, with the real client against a scripted transport such as respx.
- Record real payloads for inbound fixtures, replay them through the real handler, and re-capture rather than edit when they go stale.
- Keep every fake honest with a scheduled contract test against the real thing, and repair the fake in the same pull request that repairs the model.
httpx and requestsVCR.py record and replay of real HTTP exchanges as cassettesWireMock and MockServer the fake as a standalone HTTP server, for suites in any languagestripe-mock the provider's own fake, published by the providerPact consumer-driven contracts, the contract test as a shared artifactKnowledge Check
A test asserts that charge was called once with the order and the key. A second test asserts that fake.charges has one entry and the order is paid. Why does the book keep only the second?
- The second is faster, because reading a dictionary costs less than recording each call
- The second checks the result against Payrail's sandbox, so it catches drift in the fake
- The second asserts an outcome, so a refactor that changes the call's shape leaves it green
- The second belongs to the integration layer and the first to the domain, and each layer needs one
How does the fake reach the handler under test?
- Through build_service, as the payrail argument
- Through a TESTING flag the client checks at startup
- By monkeypatching the payrail module's client object
- Through a FastAPI Depends override on the route
The retry wrapper and the breaker live inside PayrailClient. Why can a test built on FakePayrail not exercise them?
- Because the fake cannot raise a timeout, only return a declined or charged result
- Because the fake replaces the whole client, so nothing inside the real client runs
- Because the breaker needs 20 real calls to open and the fake only records one call per test
- Because the retry depends on the frozen clock advancing, which the fake never touches
Why does a recorded payment.settled payload make a better fixture than one written by hand from the documentation?
- A recording carries a valid signature, so the handler can skip verification in the test
- A recording is smaller than a hand-written payload, so the fixture files stay readable
- A recording never goes stale, because Payrail guarantees the shape of past events
- A recording tests what Payrail actually sends rather than what the author believed it sends
Payrail renames a response field on a Wednesday. What tells Stagedoor, and in what order are things repaired?
- The nightly contract test fails; then the model, the fake and the recordings change
- The tests built on FakePayrail fail; then the fake is updated to match the new field
- The reconciliation job disagrees with Payrail on Sunday; then the fake and the model are fixed
- The recorded webhooks fail to replay; then the recordings are edited by hand to match
You got correct