Skip to main content

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 pending and 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

ScenarioEdge caseWhy tests breakApproach
Stripe Elementsiframe name hash rotates nightlyZero frames matchediframe[name^="__privateStripeFrame"] + wait for inner input
Payment ElementMultiple PM types in one mountWrong field targetedScope frameLocator to card section or use getByRole inside frame
Hosted CheckoutRedirect before webhookSuccess URL passes; order pendingPoll probe until status=paid
Payment LinksNo custom success URL controlAssert only on Stripe-hosted pageProbe order by client_reference_id metadata
3DS / SCANested challenge iframeSingle frameLocator insufficientChain two frameLocator calls to hooks.stripe.com
Declined cardToast copy variesFlaky UI assertProbe: no payment_intent.succeeded; optional decline code
Insufficient fundsUser retriesMultiple PIs createdIdempotency key on create; probe single order
Webhook delaySuccess page loads firstTest asserts too earlyexpect.poll probe 20–30s
Webhook signingWrong secret in stagingHandler 400 silentlyHealth probe + Stripe CLI forward in CI
Parallel CIShared test customerPM attach racesPer-run seed customer via Arrange
Subscriptions at checkoutMode subscriptionPI vs Subscription confusionProbe subscription.status not only order
iDEAL / bank redirectsAsync return URLTest ends at redirectSeparate job or API Arrange + webhook probe
Radar rulesTest card blocked in test modeUnexpected declineDocument allowed cards; avoid custom Radar in test
Idempotency replayNetwork retry on createDuplicate chargesSame Idempotency-Key header per run step
TeardownTest subscriptions accumulateCluttered Stripe dashboardCancel 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}
  1. Verify your create-checkout / PaymentIntent endpoint returns 200 in Playwright globalSetup.
  2. Centralize test card numbers in fixtures/stripe-cards.ts—not scattered literals.
  3. When webhook confirmation is part of Assert, run Stripe CLI listen --forward-to localhost:3000/api/webhooks/stripe alongside 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:

  1. Arrange — create session server-side (API preferred over UI).
  2. Act — navigate to session URL; fill test email/card on hosted form.
  3. Assert — poll probe for order paid; redirect to success_url is 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 are Stripe-hosted with limited customization. Testing pattern:

  1. Create link via API or dashboard (stable price in test mode).
  2. Append client_reference_id if your integration supports prefilled metadata via URL params.
  3. Complete payment on Stripe-hosted page.
  4. Assert via probe keyed on client_reference_id or 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 numberBehavior
4242424242424242Success (no 3DS)
4000000000000002Generic decline
4000000000009995Insufficient funds
4000000000000069Expired card
40000000000032203DS2 required (alternate)
4000 0025 0000 31553DS2 challenge (common in docs)
40000027600031843DS2 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.completed
  • payment_intent.succeeded
  • payment_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

PathArrangeActAssert
Generic declineSeed cartCard 4000000000000002Probe no paid order; cart preserved or released per policy
Insufficient fundsSeed cartCard 4000000000009995Probe payment_failed; no inventory decrement
Expired cardSeed cartCard 4000000000000069Probe failure before order create
Double submitSeed cartClick Pay twice quicklyIdempotency: 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-patternWhy it failsBetter approach
waitForTimeout(5000) after payCI speed varianceexpect.poll on probe
Assert redirect URL onlyWebhook may never fireProbe order + handler event
Exact iframe name attributeBreaks on Stripe deployPrefix match name^=
Shared staging customerParallel collisionsSeed per runId
Mock Stripe in E2EMisses webhook/signature bugsTest mode real API
Skip decline scenariosShip silent double-charge edge casesDecline matrix + probe
No idempotency keyFlaky duplicate chargesHeader on server create
Ignore Payment LinksProd marketing uses linksDedicated 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.

  1. Arrange: Seed cart and Stripe customer for runId only; use decline test card 4000000000000002.
  2. Act: Complete Elements form and submit payment.
  3. 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).

External references

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.

Start free on TestChimp · Book a demo