# Test Checkout Locally: Never Use a Real Card Again

> Stripe CLI plus test clocks exercise every money path on localhost: grants, retries, cancels, refunds. No card, no deploy. Drill inside.

Source: https://boringpush.com/blog/test-checkout-local-no-real-card

## The first real customer is your QA department
Launch day. A stranger pays $199 — and the webhook 500s on a header bug your devpasstesting never triggered, because dev testing meant clicking through with your own card in live mode and refunding yourself.

Or the cancel flow you never exercised strands a test subscription in `active` forever. Or the retry storm you never simulated triple-grants the second customer whose payment was merely slow.

Test checkout local discipline means every money path runs green on localhost before any stranger's card is involved. Billing untested is billing broken on someone else's card.

## Live-testing vs local-drilling
| Path | Tested live (own card) | Drilled local (CLI) |
|---|---|---|
| Happy-path grant | Once, manually | Scripted, repeatable |
| Webhook retry storm | Never (hope) | Replayed 5x in seconds |
| Cancel + revoke | Never (real sub!) | Test clock in minutes |
| Refund + dispute | Refund yourself ($$$ fees) | Trigger commands, $0 |
| Expired card dunning | Impossible safely | `4000000000000341`, instant |
| Regression after edits | Manual re-click | One script, every deploy |

Three details make live-testing worse than incomplete:

1. **Live mode hides test-only bugs.** Test clocks, trigger commands, and unsigned-payload drills exercise paths live mode cannot reach without real consequences. The bugs live exactly in untested paths — retries, disputes, expiries.
2. **Refunding yourself costs real fees.** Every live test donates processing margin to the rail and litters the books with test rows that pollute year-one analytics. Local drills cost $0 and write to dev SQLite you reset freely.
3. **Manual clicks don't regress.** The checkout you clicked in March breaks silently in June when a sibling post edits the paywall — and nobody re-clicks. A drill script runs per deploy in seconds; memory runs never.

The Stripe trap post built the handler. This post proves it before strangers arrive.

## Test checkout local drill kit
Three tools, all free: Stripe CLI, test card numbers, test clocks — aimed at the same thin routes prod uses.

**Step 1 — Forward real-shaped events to localhost.**

```bash
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# terminal 2: fire every scenario without a browser:
stripe events trigger checkout.session.completed
stripe events trigger customer.subscription.deleted
stripe events trigger charge.refunded
```

The CLI signs forwarded events with your test webhook secret, so signature verification runs for real — unsigned-payload bugs surface here, not on launch day. Trigger commands cover the lifecycle verbs; manual replays (same event twice, five times) cover the dedupe the idempotency post demands.

**Step 2 — Walk the card-number matrix for declines and edges.**

```bash
4242 4242 4242 4242  # success, always
4000 0000 0000 0341  # attached but charge declines (dunning path)
4000 0000 0000 0002  # generic decline at checkout
4000 0027 6000 3184  # 3D Secure redirect flow
```

Each number exercises a distinct branch: success grants, decline triggers dunning email, 3DS tests the redirect return nobody codes carefully. Run all four through checkout monthly — card branches rot like all untested code, and decline paths rot fastest because success is the only path anyone clicks voluntarily.

**Step 3 — Time-travel subscriptions with test clocks.**

```bash
stripe test_clocks create --frozen-time=$(date +%s)
# attach customer → trial → advance a month → advance a year:
# trialing → active → past_due → canceled, each webhook verified
```

Test clocks compress the subscription lifecycle into minutes: trial expiry grants correctly, failed renewals dun, cancels revoke at period end, sweeps reconcile. The portal post's transitions get proven end to end before any human subscribes. Delete the clock after; the order rows in dev SQLite tell the story for review.

That's the architecture talking — money paths drilled on a laptop, trusted worldwide.

## The pre-launch money matrix
| Scenario | Drill command | Pass looks like |
|---|---|---|
| Grant once | trigger completed | 1 order, 1 receipt |
| Retry storm | replay ×5 | deduped ×4, silence |
| Decline | 0341 card | dunning email, no access |
| 3DS flow | 3184 card | redirect → grant |
| Full lifecycle | test clock, 1 year | trial→active→canceled, books balance |
| Refund | trigger refunded | access revoked, row marked |

Green across the matrix is the launch gate for billing: no ship until every row passes on localhost. Re-run per deploy touching payments, paywall, or webhooks — thirty seconds that guard the only code paths handling other people's money.

The real cost, stated: drills test the machinery, not the humans — a drilled path can still confuse real buyers (checkout copy, pricing clarity, trust signals need live eyes). Pair the matrix with one live test purchase (your card, immediately refunded) before launch: drills prove the pipes, the live dollar proves the human path.

## FAQ

### Can I test checkout local for Dodo and LemonSqueezy too?
Yes with their equivalents: test/sandbox modes plus dashboard-sent test events forwarded to localhost (tunneling via the same Cloudflare Tunnel you serve through, or plain localhost listeners). The matrix is rail-agnostic — grant, retry, cancel, refund, decline — only the trigger mechanism changes. Drill every rail you operate; copy the matrix, swap the commands.

### How do I keep test data out of production analytics?
Physically separate: dev drills write to local SQLite (reset freely), staging uses test-mode keys (real-shaped, unreal money), prod analytics query only live-mode rows. Never share a database across modes — the one time test rows pollute revenue charts, every later number carries doubt. Separation is a topology decision made once, honored forever.

### Should the drill script run in CI on every push?
The fast subset (signature rejection, dedupe replay against ephemeral DB, paywall matrix) runs per push in seconds with Stripe CLI in CI. Full lifecycle clocks run nightly — minutes, not seconds. Split by speed: gate the merge on the fast half, trust the nightly for the slow half. Billing CI is the rare suite worth its runtime twice over.

### What test card covers 3D Secure + saved-card + off-session?
Combine: 3184 for the 3DS redirect, attach to customer, then trigger off-session invoice payment for the saved-card path. Three scenarios, one card family, all local. Off-session failures (expired mandate, declined renewal) are the dunning system's bread and butter — drill the exact sequence your recovery emails claim to handle, or the emails lie.

### How do I drill the nightly sweep without waiting overnight?
Run your reconcile job directly against dev with staged mismatches, plus unit-test the reconcile logic with fixture rows. The sweep's value is coverage of webhook misses — simulate misses by deleting a test grant's webhook trace, then watch the sweep restore truth and scream. Backstops get tested by staging the failure they backstop.

### When is manual live testing still necessary?
Once per rail before launch (your card, immediate refund: proves the human path end to end) and once per checkout redesign (copy, layout, trust signals need eyes, not scripts). Everything between is drills. Live tests are ceremonies with fees attached — rare, deliberate, and never a substitute for the matrix.

## Keep reading

- [Next.js Stripe Trap: The Retry That Double-Charges You](/blog/nextjs-stripe-boilerplate-webhooks) — the handler these drills prove
- [Stripe Portal Truth: Cancel Should Mean Cancelled](/blog/stripe-portal-cancel-revoke-access) — the lifecycle the clocks time-travel
- [requirePaidUser(): One Boolean, Zero Paywall Holes](/blog/requirepaiduser-paywall-one-boolean) — the access side drills assert
