How do I test a checkout flow without a Stripe account?
Do nothing. With STRIPE_SECRET_KEY unset, Billing.for(account) returns a local fake that runs checkout, entitlement and cancellation in process. You click through the real flow, the subscription row is written, and gated features unlock. Set the key when you want real Stripe, and the same code paths switch over.
Try it now
bin/dev
Sign in, open the pricing page, and buy. There is no card form, because there is no Stripe: the fake
completes the session immediately and redirects back. The account now has an active subscription and
anything behind include RequireEntitlement opens.
How the switch works
app/adapters/billing.rb is the seam. It returns a Stripe client when the credential is present and
a deterministic local implementation when it is not:
Billing.for(account)
Nothing else in the app knows which one it got. Controllers, models and specs all call the same
methods, so the code that runs against the fake is the code that runs against Stripe. The two
implementations sit side by side in app/adapters/billing, which is worth reading once: the fake is
short, and it tells you exactly what the real one is expected to do.
This is the pattern every external service in the kit uses, including bot checking and error reporting, which is why a fresh clone boots with zero external accounts.
What the fake does not simulate
Be clear about the boundary, because it matters before launch:
- Card failures. The fake always succeeds. Declines, 3D Secure and expired cards need Stripe test mode with the published test card numbers.
- Webhook timing. Real Stripe delivers
checkout.session.completedasynchronously, sometimes after the user is already back on your site. One Shot handles that with an idempotent upsert that both the webhook and the checkout return call, so whichever arrives first wins, but the fake never exercises the race. - Proration and plan changes. Stripe's billing engine does that arithmetic and the fake does not.
Moving to real Stripe
Set three variables and nothing in your code changes:
STRIPE_SECRET_KEY=sk_test_... bin/dev
Use a test-mode key first. The flow is identical, and now declines and webhooks are real. See Stripe Setup for the webhook secret and which events to enable.