How to Test Stripe Payments in Playwright (3DS, Webhooks)
Short answer
Stripe payments are asynchronous: the success redirect, client-side confirmPayment() resolution, and your order row can disagree for seconds—or forever if webhooks fail. Run E2E in Stripe test mode with per-run customers, frameLocator prefix selectors for Elements, nested iframe handling for 3DS, and probe Assert on order/subscription state—not the success URL alone. Use idempotency keys on server-side PaymentIntent creation and tear down test-mode artifacts after CI runs.
Part of Testing Guides by business flow.
Who this is for
Teams shipping Stripe on web—Checkout, embedded Elements, Payment Links, or Payment Element—with Playwright E2E that must survive parallel CI, 3DS challenges, and webhook-driven fulfillment. Typical stacks: Next.js + Stripe.js, Rails/Django backends with webhooks, Shopify-adjacent custom checkout, or Lovable apps that added Stripe late.
If your revenue path is "click Pay → redirect → order confirmed," this guide is for you—not teams who only unit-test Stripe SDK mocks.
Why testing Stripe payments matters
Payment bugs are rarely cosmetic:
- Revenue loss — success page loads while webhook never fires; orders stay
pendingand fulfillment never starts; duplicate PaymentIntents on double-click charge twice. - Support load — 3DS failures show generic errors; customers retry and get multiple holds; declined cards still create orphan checkout sessions.
- Compliance exposure — wrong amount captured vs displayed total; tax line items missing on invoice; refunds not synced to your ledger.
- CI blindness — tests assert redirect URL only; production breaks when Stripe rotates iframe names or webhook signing secret rotates without staging update.
Stripe makes it easy to appear paid on the client (confirmPayment resolves) while your database still shows unpaid—or the opposite: webhook processed but UI never navigates. E2E must assert the full boundary: PaymentIntent/Checkout Session status, your order row, inventory decrement, and email triggers.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Stripe Elements | iframe name hash rotates nightly | Zero frames matched | iframe[name^="__privateStripeFrame"] + wait for inner input |
| Payment Element | Multiple PM types in one mount | Wrong field targeted | Scope frameLocator to card section or use getByRole inside frame |
| Hosted Checkout | Redirect before webhook | Success URL passes; order pending | Poll probe until status=paid |
| Payment Links | No custom success URL control | Assert only on Stripe-hosted page | Probe order by client_reference_id metadata |
| 3DS / SCA | Nested challenge iframe | Single frameLocator insufficient | Chain two frameLocator calls to hooks.stripe.com |
| Declined card | Toast copy varies | Flaky UI assert | Probe: no payment_intent.succeeded; optional decline code |
| Insufficient funds | User retries | Multiple PIs created | Idempotency key on create; probe single order |
| Webhook delay | Success page loads first | Test asserts too early | expect.poll probe 20–30s |
| Webhook signing | Wrong secret in staging | Handler 400 silently | Health probe + Stripe CLI forward in CI |
| Parallel CI | Shared test customer | PM attach races | Per-run seed customer via Arrange |
| Subscriptions at checkout | Mode subscription | PI vs Subscription confusion | Probe subscription.status not only order |
| iDEAL / bank redirects | Async return URL | Test ends at redirect | Separate job or API Arrange + webhook probe |
| Radar rules | Test card blocked in test mode | Unexpected decline | Document allowed cards; avoid custom Radar in test |
| Idempotency replay | Network retry on create | Duplicate charges | Same Idempotency-Key header per run step |
| Teardown | Test subscriptions accumulate | Cluttered Stripe dashboard | Cancel by metadata.e2e_run in globalTeardown |
Test mode setup
Run only against Stripe test mode (pk_test_ / sk_test_). Never use live keys in automation.
# CI secrets (example)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # from `stripe listen` output in CI
RUN_ID=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}
- Verify your create-checkout / PaymentIntent endpoint returns 200 in Playwright
globalSetup. - Centralize test card numbers in
fixtures/stripe-cards.ts—not scattered literals. - When webhook confirmation is part of Assert, run Stripe CLI
listen --forward-to localhost:3000/api/webhooks/stripealongside tests, or use Stripe's test webhook delivery in dashboard for nightly jobs.
Idempotency on server-side creates
Duplicate clicks and network retries must not create duplicate charges:
// POST /api/checkout/create-intent
const idempotencyKey = `e2e-${runId}-checkout`;
const paymentIntent = await stripe.paymentIntents.create(
{ amount: 4999, currency: 'usd', customer: customerId, metadata: { e2e_run: runId } },
{ idempotencyKey },
);
E2E negative: submit payment twice rapidly—probe exactly one succeeded PI for that runId.
Stripe Checkout (hosted)
Hosted Checkout redirects to checkout.stripe.com. Flow:
- Arrange — create session server-side (API preferred over UI).
- Act — navigate to session URL; fill test email/card on hosted form.
- Assert — poll probe for order
paid; redirect tosuccess_urlis necessary but not sufficient.
// Arrange
const { url, sessionId } = await request.post('/api/test/create-checkout', {
data: { runId, lineItems: [{ price: 'price_test_basic', quantity: 1 }] },
}).then(r => r.json());
// Act
await page.goto(url);
await page.getByLabel('Email').fill(`buyer-${runId}@test.local`);
// Hosted Checkout card fields are often in iframes—use frameLocator when present
const cardFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]').first();
await cardFrame.getByPlaceholder('1234 1234 1234 1234').fill('4242424242424242');
await cardFrame.getByPlaceholder('MM / YY').fill('12 / 34');
await cardFrame.getByPlaceholder('CVC').fill('123');
await page.getByRole('button', { name: 'Pay' }).click();
// Assert — NOT stop at success URL
await expect.poll(async () => {
const res = await request.get(`/api/test/probes/orders/by-session/${sessionId}`);
return (await res.json()).status;
}, { timeout: 30_000 }).toBe('paid');
Pass client_reference_id: runId and metadata.e2e_run on session create so probes can correlate without scraping Stripe dashboard.
Stripe Elements and Payment Element (embedded)
Elements mount asynchronously. Wait for inner input visible before fill—otherwise frameLocator matches zero frames and times out with misleading "element not found" on the page root.
const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]').first();
await expect(stripeFrame.getByRole('textbox', { name: /card number/i })).toBeVisible({ timeout: 15_000 });
await stripeFrame.getByLabel(/card number/i).fill('4242424242424242');
await stripeFrame.getByLabel(/expiration/i).fill('1234');
await stripeFrame.getByLabel(/cvc/i).fill('123');
await page.getByRole('button', { name: 'Pay now' }).click();
After stripe.confirmPayment() or form submit, allow 20–30 second timeouts when 3DS may appear—the client promise stays pending during challenge.
For Payment Element with wallets enabled, wallet buttons may not appear in Linux CI—cover wallet flows separately (wallet payments guide) and probe card path every PR.
Payment Links
Payment Links are Stripe-hosted with limited customization. Testing pattern:
- Create link via API or dashboard (stable
pricein test mode). - Append
client_reference_idif your integration supports prefilled metadata via URL params. - Complete payment on Stripe-hosted page.
- Assert via probe keyed on
client_reference_idor checkout session metadata—not "Thanks for your payment" copy alone.
Payment Links still emit checkout.session.completed—your webhook handler must be under test.
3D Secure (nested iframes)
Use test card 4000 0025 0000 3155 (3DS2 authentication required). The challenge modal loads from hooks.stripe.com with a nested iframe for Approve/Fail buttons:
await stripeFrame.getByLabel(/card number/i).fill('4000002500003155');
await stripeFrame.getByLabel(/expiration/i).fill('1234');
await stripeFrame.getByLabel(/cvc/i).fill('123');
await page.getByRole('button', { name: 'Pay now' }).click();
// 3DS challenge — nested frameLocators
const challengeFrame = page
.frameLocator('iframe[src*="hooks.stripe.com"]')
.frameLocator('iframe')
.first();
await expect(challengeFrame.getByRole('button', { name: /complete authentication/i })).toBeVisible({ timeout: 30_000 });
await challengeFrame.getByRole('button', { name: /complete authentication/i }).click();
await expect.poll(async () => {
const res = await request.get(`/api/test/probes/orders/${runId}`);
return (await res.json()).status;
}, { timeout: 45_000 }).toBe('paid');
Also test failed 3DS: use Stripe's test cards for authentication failure and probe order remains unpaid.
Test card matrix
| Card number | Behavior |
|---|---|
4242424242424242 | Success (no 3DS) |
4000000000000002 | Generic decline |
4000000000009995 | Insufficient funds |
4000000000000069 | Expired card |
4000000000003220 | 3DS2 required (alternate) |
4000 0025 0000 3155 | 3DS2 challenge (common in docs) |
4000002760003184 | 3DS2 required (EU) |
Full list and PM-specific numbers: Stripe testing.
Store cards in fixtures; reference by semantic name (CARD_SUCCESS, CARD_DECLINE) in specs.
Webhook confirmation (not redirect)
The success URL is not confirmation of payment. Your handler must process events such as:
checkout.session.completedpayment_intent.succeededpayment_intent.payment_failed
// Probe pattern — authoritative backend state
await expect.poll(async () => {
const res = await request.get(`/api/test/probes/orders/${runId}`);
const body = await res.json();
return { status: body.status, stripeEventId: body.lastWebhookEventId };
}, { timeout: 30_000, intervals: [500, 1000, 2000] }).toMatchObject({ status: 'paid' });
Webhook probe test: complete checkout with valid card while optionally asserting handler received event (test-only endpoint exposing last processed event.id). Never stop at redirect.
For local CI, forward webhooks:
stripe listen --forward-to http://localhost:3000/api/webhooks/stripe &
See Stripe webhooks testing guide for signing secret rotation and replay.
Decline and error paths
| Path | Arrange | Act | Assert |
|---|---|---|---|
| Generic decline | Seed cart | Card 4000000000000002 | Probe no paid order; cart preserved or released per policy |
| Insufficient funds | Seed cart | Card 4000000000009995 | Probe payment_failed; no inventory decrement |
| Expired card | Seed cart | Card 4000000000000069 | Probe failure before order create |
| Double submit | Seed cart | Click Pay twice quickly | Idempotency: one PI; one order |
Prefer probing Stripe PaymentIntent status via your backend over asserting exact error string copy (i18n changes).
CI teardown
Every checkout/subscription test creates rows in Stripe test mode. After each run:
// globalTeardown.ts — cancel subscriptions and detach test customers
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export default async function globalTeardown() {
const runId = process.env.RUN_ID;
if (!runId) return;
for await (const sub of stripe.subscriptions.list({ limit: 100 })) {
if (sub.metadata?.e2e_run === runId) {
await stripe.subscriptions.cancel(sub.id);
}
}
// Optional: delete customers with metadata.e2e_run
}
Cluttered test mode makes debugging production issues harder—teardown is not optional for subscription-heavy suites.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
waitForTimeout(5000) after pay | CI speed variance | expect.poll on probe |
| Assert redirect URL only | Webhook may never fire | Probe order + handler event |
Exact iframe name attribute | Breaks on Stripe deploy | Prefix match name^= |
| Shared staging customer | Parallel collisions | Seed per runId |
| Mock Stripe in E2E | Misses webhook/signature bugs | Test mode real API |
| Skip decline scenarios | Ship silent double-charge edge cases | Decline matrix + probe |
| No idempotency key | Flaky duplicate charges | Header on server create |
| Ignore Payment Links | Prod marketing uses links | Dedicated link scenario |
Example scenario
Situation: A shopper pays with a declined card at Stripe Elements checkout.
Expected outcome: Payment fails and **no order** is created—inventory unchanged.
Why UI-only automation breaks: Error toast shows briefly while webhook still creates a pending order row.
- Arrange: Seed cart and Stripe customer for runId only; use decline test card 4000000000000002.
- Act: Complete Elements form and submit payment.
- Assert: Probe confirms zero paid orders for runId; optional UI error. Verify no inventory decrement.
TestChimp workflow: Instrument checkout events with payment_method, country, and checkout_type; compare prod vs test in TrueCoverage.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Stripe webhooks — signing, replay, idempotent handlers
- Wallet payments — Apple Pay, Google Pay, PayPal
- Subscriptions & billing — trials, dunning, test clocks
- Returns & refunds — partial credits post-payment
- Tax & regional pricing — VAT line items on Checkout
- Ecommerce checkout vertical — cart → pay end-to-end
External references
- Stripe testing
- Stripe CLI webhooks
- Payment Element
- Checkout Session
- Payment Links
- 3D Secure authentication
- Idempotent requests
- Playwright frames
Frequently asked questions
Should I assert on the success URL or wait for the webhook?
Wait for authoritative backend state via probe or webhook handler confirmation. The success URL redirect is not proof of payment—Stripe confirms asynchronously and webhooks can lag 1–5 seconds under load.
How do I test Stripe 3D Secure in Playwright CI?
Use test card 4000 0025 0000 3155, chain two frameLocators for the nested challenge iframe from hooks.stripe.com, and allow 20–30s timeouts after confirmPayment(). See Stripe testing docs for current iframe patterns.
Why do my Stripe iframe selectors break overnight?
Stripe rotates iframe name hashes. Use partial match: page.frameLocator('iframe[name^="__privateStripeFrame"]').first() and wait for inner inputs to be visible before fill—never match the full dynamic name.
Can I mock Stripe in E2E tests?
No for integration E2E—use Stripe test mode (pk_test_/sk_test_) with real API calls. Mock only in unit tests. The point of E2E is verifying your webhook handler and order creation, not bypassing Stripe.
How do I clean up test subscriptions after a run?
Teardown via Stripe API: list subscriptions with metadata e2e_run matching your CI run id and cancel them. Test mode subscriptions accumulate and clutter debugging.
What test cards should I use for decline and insufficient funds?
4242424242424242 succeeds; 4000000000000002 declines; 4000000000009995 insufficient funds; 4000000000000069 expired card. Full list in Stripe testing documentation.
We support six payment methods—how do we know each is covered?
Compare prod vs test-run distribution across payment_method × country × checkout_type in TrueCoverage. When prod users hit slices your suite never exercises, run /testchimp evolve to add scenarios for missing methods. Link SmartTests to markdown scenarios with // @Scenario: links so coverage rolls up by dimension.
Our team has no dedicated QA—can developers maintain Stripe E2E?
Yes—document payment rules in markdown plans and run /testchimp test on PRs so agents repair SmartTests when Elements or webhook handlers change. Probe Assert keeps asserts authoritative without re-recording checkout.
Stripe tests pass locally but fail in parallel CI—why?
Usually shared customers or coupons across workers—not Stripe flakiness. See [parallel CI collisions](/guides/gotchas/parallel-ci-test-data-collisions) and [seed routes](/guides/foundations/testing-seed-routes-and-probe-assert).
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.