Contract tests, integration tests, and the four test doubles
coreadvancedA **contract test** checks that the request you send and the response you can parse match what the provider actually promises — it runs against a recorded or stubbed response and is fast. An **integration test** talks to the provider's sandbox for real, which is slow and can fail for reasons that have nothing to do with your code. You need both, and they answer different questions: the contract test asks "does my code handle this shape correctly", the integration test asks "is this shape still what they send". A **test double** is whatever you put in the provider's place — a stub returns a canned answer, a fake is a working simplified version, a spy records calls, and a mock asserts on them.
Think of it as
Every external dependency gives you two independent things to get wrong, and one test kind cannot cover both. The first is your handling: given a 402 with this body, does your code refund the reservation and return the right error to the caller? That is entirely about your logic, and running it against a live sandbox makes it slower and flakier without making it stronger — a stub is strictly better, because you can produce the 402 on demand instead of hoping the sandbox produces one. The second is the assumption underneath: is the field still called `charge_id`, does the error still come back as 402, is the signature header still `Stripe-Signature`? No amount of stubbing can tell you this, because your stub is a copy of your own belief. That is why the sandbox test exists, and why it should be small, tagged, and run on a schedule rather than on every commit — its purpose is to *invalidate your stubs*, not to test your branches. Once you see the split that way, the doubles fall into place. Most of the time you want a stub. Reach for a fake when the interaction has state worth modelling — an in-memory storage backend where a `put` is visible to a later `get` catches ordering bugs a stub never will. Use a spy when you care that a call was made with the right arguments, and keep true mocks for the few places where the *absence* of a call is the assertion.
What we're doing: Cover both directions for one payment call: every branch against a stub, and one narrow assumption check against the sandbox.
- 5–7
- `assert_all_requests_are_fired=True` turns a stub nobody called into a failure. Without it, a refactor that stops calling the provider entirely leaves every test green.
- 11–16
- Four provider outcomes, four order states, one table. The 409 row is the interesting one — "already charged" must land on `paid`, not on an error, or a retry corrupts the order.
- 19
- The stub is the point of control. You cannot ask a sandbox for a 500 on demand; you can always ask a stub.
- 28–34
- The assertion that is about *your* system rather than the provider's: a failed charge must not leave stock held. This is exactly the branch a happy-path-only suite skips.
- 37–42
- One narrow test against the real sandbox, tagged so it is deselected by default. It asserts the shape and nothing else — it is not trying to cover the branches above.
Why this works: The parametrized stub test covers every branch cheaply and deterministically, and the one sandbox test covers the assumption the stubs are built on. Neither can do the other's job, and the tag keeps the slow one out of the commit loop.
Running the whole payment suite against the sandbox
Wrong
Better
What you see: CI takes minutes instead of seconds and goes red on days you changed nothing — provider maintenance, a rotated sandbox key, a rate limit. People start re-running CI reflexively, and then stop reading failures at all.
Why: A sandbox is a shared, stateful, network-dependent system, so a suite built on it inherits every one of those properties. Worse, it cannot produce the branches that matter: there is no reliable way to make it return a 500, or time out, or return a 409 on the second call. Stubs give you all of those on demand. Keep the sandbox for the one job stubs structurally cannot do — telling you the shape changed — and run it on a schedule, where a red result is information rather than noise.
- Contract test — stubbed provider
- Runs on every commit, in milliseconds
- You choose the response, including the 402 and the timeout
- Catches: your code mishandling a shape
- Cannot catch: the provider changing that shape
- Deterministic — no network, no rate limits, no card expiry
- Integration test — real sandbox
- Runs nightly, tagged, allowed to fail loudly
- The provider chooses the response
- Catches: a renamed field, a changed status code, a new required header
- Cannot replace contract tests — you cannot make it produce every branch
- Its job is to invalidate your stubs, not to cover your logic
The four doubles, and when each is the right one
Together
Remember: Contract tests answer "does my code handle this shape", integration tests answer "is this still the shape" — you need both, and the second one exists to invalidate the first one's stubs, not to cover your branches. Keep sandbox tests few, tagged and scheduled; keep stubbed tests exhaustive, because only a stub gives you a 500 or a timeout on demand. Put the double at the HTTP boundary rather than on your own client class, or the URL, headers and timeout you actually ship go untested.
See also: testing retries timeouts and idempotency · what to mock and what not to · query count assertions factories and migration tests

