Mock the Stripe API in CI

Sep 3, 2026·7 min read

Stripe gives you one test key, and it points at one account. Your team shares it, and so does every CI job, including jobs from different branches running at the same time.

Two of them start together. One creates a customer while the other lists customers and asserts on what comes back, so the second job fails on data that has nothing to do with the branch it is testing. A re-run passes.

The fix is to stop sharing. Stripe publishes an OpenAPI document, you already run something that serves one, and the rest is a workflow file.

The obvious version, and where it stops

Drop the spec into a service folder, commit, push:

services/
  stripe/
    openapi.yml

That is the correct layout and it will deploy. The folder name becomes the service name, so Stripe's /v1/charges comes out at /stripe/v1/charges, and nothing about the setup is wrong.

The spec is the problem. Stripe publishes 419 paths, which the router expands into 594 routes, in a YAML file of 6.1 MB. Running that on my laptop, the process settled at about 881 MB resident, took 805 ms to build its route registry, and answered the first request in 756 ms.

A simulation does not get a laptop. It gets the memory ceiling that comes with your plan, and on the free plan that ceiling is 128 MB with a one second timeout. The full Stripe spec does not fit, and the failure is not subtle.

The free plan is 128 MB of memory, a 1 second timeout, one repository and 1,000 requests a month. Every number here is worth checking against your own plan before you size a spec.

Trim the Stripe spec to the endpoints you call

Your test suite does not touch 419 paths. It probably touches five or six: a customer, a payment intent, a refund, and the two or three reads around them. Everything else is weight you are paying to parse.

The simplify command takes a filter and applies it before it does anything else, so write down what you actually use:

# services/stripe/codegen.yml
filter:
  include:
    paths:
      - /v1/customers
      - '/v1/customers/{customer}'
      - /v1/payment_intents
      - '/v1/payment_intents/{intent}'
      - '/v1/payment_intents/{intent}/confirm'
      - /v1/refunds

Then run it straight over Stripe's published document:

mockzilla simplify \
  --config services/stripe/codegen.yml \
  --output services/stripe/openapi.yml \
  https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml

Commit the result, or run that line in CI and leave it out of the repo. Committing means what deploys is what you read, and it goes stale when Stripe ships. Building it in CI means it never goes stale, and a provider change reaches your simulation without anyone reviewing it. The filter is the file that matters either way.

That took under a second and turned 6.1 MB into 1.19 MB. Six paths, twelve routes, a registry built in 123 ms and a first response in 161 ms. Same endpoints, same schemas, same generated shapes, minus four hundred paths nobody in your suite has ever called.

If it is still too big, the next lever is optional properties. Stripe's schemas carry hundreds of them, and most of your assertions read three or four fields:

mockzilla simplify --config services/stripe/codegen.yml --optional 5 \
  --output services/stripe/openapi.yml <url>

That lands at 0.91 MB, keeping five optional properties per schema. Dropping them entirely with --optional 0 gets you to 0.43 MB, which is a fourteenth of what you started with and still answers every call your tests make.

simplify also strips anyOf and oneOf from optional properties, which is most of what makes a Stripe response schema expensive to work with. Filtering is what saves the memory; the union pass is what makes the output readable when you open it.

There is a spec: simplify: true switch in the service config that does the union pass at load time. It is convenient and it does not solve this problem, because the full document still has to be read before anything can simplify it. Trim the file you commit.

Add the GitHub Actions workflow

With a spec that fits, mocking Stripe in CI is a workflow file and nothing else. The GitHub Action does the rest:

name: mockzilla

on:
  push:
    branches: [main]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: mockzilla/actions@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

No account setup first and no key to paste anywhere: the GITHUB_TOKEN is what proves the repository is yours. Push that, and the simulation comes up at https://api.mockz.io/gh/<org>/<repo>, with your six Stripe endpoints under /stripe.

Point your tests at it

The action hands the address back as an output:

- uses: mockzilla/actions@v1
  id: mockzilla
  with:
    token: ${{ secrets.GITHUB_TOKEN }}

- run: npm test
  env:
    STRIPE_BASE_URL: ${{ steps.mockzilla.outputs.url }}/stripe

Your code then calls ${STRIPE_BASE_URL}/v1/customers where it used to call https://api.stripe.com/v1/customers, which in most codebases is one line in whatever wraps the HTTP client.

Expect the first response to be slow. Something has to build the route registry, and on a simulation sized for a small plan that takes a moment: about 1.8 seconds, against half a second for everything after it. The action absorbs that before it hands you the URL, so it costs your first test nothing.

Make it fail on purpose

A simulation that always says yes teaches your retry code nothing, and the retry code is usually the part you are least sure about. Drop a config.yml next to the spec:

# services/stripe/config.yml
latency: 120ms
errors:
  p2: 500
  p5: 429

Percentiles are cumulative, so p2: 500 sends 2% of requests back as a 500 and p5: 429 sends the next 3% back as a 429. For a single call, a header beats editing the file:

curl -H "X-Mockzilla-Latency: 3s" "$STRIPE_BASE_URL/v1/customers"

That is how you exercise the timeout path without slowing down the rest of the suite.

What this does not do

It is a simulation, not Stripe.

Responses are generated from the spec, so they have the right shape and the right status codes without being your account's data, and there is no state between calls. A customer you create is not waiting for you when you read it back. If your tests depend on that, they are testing Stripe rather than your code, and they belong in a smaller suite that runs against the real sandbox on a schedule.

So split them. Your own code gets checked on every push, against the spec you trimmed. Stripe's behaviour gets checked on a schedule, against the real sandbox, where a slow run costs nothing.

When generated responses are not enough

A charge has to be authorized before it can be captured and refunded after that, and each step has to remember the last one. Stripe refuses some of those combinations, and a stateless simulation will let them through. Our payment sandboxes hold that state and model each PSP's own rules, with its own test values as triggers: charge 4000 0000 0000 9995 and it declines for insufficient funds. Stripe is one of them.

Payment sandboxes

Take the whole thing

All of it is in one repository: mockzilla/articles-stripe-in-ci. The filter, the workflow, the service config, and the tests that run against the deployed simulation on every push to main. The spec itself is not in there: CI rebuilds it from Stripe's published document each time.

Install the CLI to run the same services folder on your machine before you push it.

Was this page helpful?