How to test Stripe checkout in CI

Stripe checkout involves cross-domain iframes, 3DS challenges, and test-mode quirks. Here’s how to test it properly - with and without code.

Why this is hard to test

  • Stripe Elements renders in a cross-domain iframe that Cypress can’t access without disabling web security
  • 3DS challenge modals are separate browser contexts that break standard automation
  • Test-mode sandbox behavior differs from production - mocking Stripe gives false confidence
  • SDK version updates can silently change UI behavior, breaking tests that worked yesterday

Approach 1: Playwright + Stripe test mode

  1. 1.Configure Playwright to handle cross-origin iframes using page.frameLocator()
  2. 2.Use Stripe test card numbers (4242 4242 4242 4242) with test-mode API keys
  3. 3.For 3DS testing, use the 4000 0025 0000 3155 card that triggers the challenge
  4. 4.Wait for the 3DS iframe to load, then interact with the challenge form
  5. 5.Assert the post-payment confirmation page shows the correct total
  6. 6.Run in CI with a Stripe webhook listener (use stripe-cli or a test endpoint)

Approach 2: Zerocheck (approved smoke coverage)

  1. 1.Author or approve a checkout smoke test for the safe, browser-visible parts of the flow
  2. 2.Keep payment-field and 3DS steps in customer-authored tests until built-in payment helpers ship
  3. 3.Approved tests can run on PRs; generated and discovery suggestions avoid payment fields and final purchase actions until reviewed
  4. 4.Production monitors can run approved, non-destructive checkout smoke tests against your configured production URL

Stripe Elements iframe structure

Stripe Elements does not render payment fields directly into your page’s DOM. Instead, it creates a cross-origin iframe hosted on js.stripe.com that contains the card number, expiry, and CVC inputs. This is a deliberate security measure - Stripe isolates sensitive card data in a separate origin so your JavaScript can never touch it, which is what makes Stripe PCI-compliant for merchants. The consequence for test automation is significant. Standard DOM queries from the parent page cannot reach into the iframe. document.querySelector won’t find the card input, and neither will Playwright’s default page.locator(). You need to explicitly cross the iframe boundary. Playwright handles this relatively well with page.frameLocator(), which lets you target elements inside a specific iframe by its CSS selector. The Stripe iframe typically has a name attribute like __privateStripeFrame followed by a number, or you can target it by its src containing js.stripe.com. Once you have the frame locator, you can chain normal locator calls to find inputs inside it. Cypress is harder. Because Cypress runs inside the browser alongside your app, it’s subject to same-origin policy restrictions. The cy.iframe() command doesn’t exist natively - you need the cypress-iframe plugin or you have to set chromeWebSecurity: false in your Cypress config, which disables security checks entirely and makes your tests less representative of real user behavior. One additional complication: Stripe renders its fields inside nested iframes. The outer iframe contains the Stripe Elements component, and the actual input fields may be inside a further nested structure. Your frame locator chain needs to account for this. The code example below shows the Playwright approach.

import { test, expect } from "@playwright/test";

test("complete Stripe checkout with test card", async ({ page }) => {
  await page.goto("/checkout");

  // Stripe Elements renders inside an iframe on js.stripe.com
  // Target the iframe containing the card number field
  const stripeFrame = page.frameLocator(
    'iframe[src*="js.stripe.com/v3/elements-inner-card"]'
  );

  // Fill card number - Stripe uses a single input with autocomplete
  await stripeFrame
    .locator('[placeholder="Card number"]')
    .fill("4242424242424242");

  // Fill expiry and CVC (separate fields in default layout)
  await stripeFrame
    .locator('[placeholder="MM / YY"]')
    .fill("12/30");
  await stripeFrame
    .locator('[placeholder="CVC"]')
    .fill("123");

  // Fill ZIP if required by your Stripe config
  await stripeFrame
    .locator('[placeholder="ZIP"]')
    .fill("10001");

  // Submit payment
  await page.locator('button[type="submit"]').click();

  // Wait for redirect to confirmation page
  await expect(page).toHaveURL(/\/order-confirmation/);
  await expect(
    page.locator("text=Payment successful")
  ).toBeVisible();
});

Handling 3DS challenges in CI

3D Secure (3DS) is a Strong Customer Authentication (SCA) protocol required by regulation in the EU, UK, and increasingly expected by card networks worldwide. When a payment triggers 3DS, the customer is redirected to their bank’s authentication page - typically a modal or iframe overlay asking them to confirm the transaction via a code, biometric, or approval in their banking app. In test mode, Stripe simulates 3DS challenges with specific test card numbers. The card 4000000000003220 always triggers the authentication modal. The card 4000000000003063 requires authentication and will fail if the customer doesn’t complete it. The card 4000002500003155 triggers the full challenge flow including a redirect. Each of these exercises a different code path in your checkout, and you should test all three. The 3DS challenge creates a second iframe (or sometimes a popup window) on top of your existing Stripe Elements iframe. This means your test now needs to handle two levels of iframe nesting, or switch context to a popup. In Playwright, the challenge iframe is typically rendered inside a div with the id #challengeFrame or a Stripe-hosted modal. You need to wait for this element to appear, locate the iframe, and then interact with the “Complete authentication” button inside it. Timing is critical in CI. The 3DS iframe may take 2-5 seconds to load in Stripe’s test environment, and CI runners are often slower than local machines. Hard-coded sleeps are fragile - use explicit waits for the iframe to be attached to the DOM and for the button inside it to be visible and clickable. The example below demonstrates the full pattern.

import { test, expect } from "@playwright/test";

test("handle 3DS authentication challenge", async ({ page }) => {
  await page.goto("/checkout");

  // Fill card that always triggers 3DS
  const stripeFrame = page.frameLocator(
    'iframe[src*="js.stripe.com/v3/elements-inner-card"]'
  );
  await stripeFrame
    .locator('[placeholder="Card number"]')
    .fill("4000000000003220");
  await stripeFrame
    .locator('[placeholder="MM / YY"]')
    .fill("12/30");
  await stripeFrame
    .locator('[placeholder="CVC"]')
    .fill("123");

  await page.locator('button[type="submit"]').click();

  // Stripe opens a 3DS challenge in a nested iframe
  // First, locate the outer Stripe modal iframe
  const challengeOuter = page.frameLocator(
    'iframe[name="__privateStripeFrame8"]'
  );
  // Then locate the inner challenge iframe
  const challengeInner = challengeOuter.frameLocator(
    'iframe[id="challengeFrame"]'
  );

  // Wait for and click the "Complete" button
  // Use a generous timeout - 3DS loads slowly in test mode
  await challengeInner
    .locator('#test-source-authorize-3ds')
    .click({ timeout: 15000 });

  // Verify payment succeeded after 3DS
  await expect(page).toHaveURL(/\/order-confirmation/);
  await expect(
    page.locator("text=Payment successful")
  ).toBeVisible({ timeout: 10000 });
});

Test mode vs production differences

Stripe’s test mode is not a perfect replica of production. It’s close enough to catch most integration bugs, but there are specific differences that have burned teams who assumed test mode behavior would carry over exactly. Webhook timing is the biggest divergence. In production, webhooks like payment_intent.succeeded typically fire within 1-3 seconds. In test mode, delivery can be delayed by 5-30 seconds, or arrive in a different order than production. If your app relies on receiving checkout.session.completed before payment_intent.succeeded, test mode may deliver them in reverse order, and your production code might not handle that correctly. Always build webhook handlers that are idempotent and order-independent. Payment method availability differs substantially. Apple Pay and Google Pay do not work in Stripe test mode without registering a verified domain and using HTTPS - which means your localhost or CI environment won’t trigger these flows at all. If your checkout supports wallet payments, you need a separate testing strategy for them. Some teams use a staging environment with a registered domain; others accept the coverage gap and test wallet flows manually. Card decline behavior is another subtle difference. Test mode decline codes (like 4000000000000002 for generic decline) return instantly, while production declines may involve network round-trips to the issuing bank that add latency and occasionally return different decline_code values than test mode suggests. Rate limiting also differs - test mode is more lenient, so if your checkout retries failed payments aggressively, you might not hit rate limits until production. Finally, Stripe Connect and multi-party payment flows can behave differently in test mode around fund availability and transfer timing. Funds in test mode are always immediately available, whereas production transfers follow the normal settlement schedule. The practical takeaway: use test mode for CI to catch integration regressions, but do not treat green tests as proof that production payments work. Run a real transaction through your staging environment with a live-mode test card at least once per release cycle.

Webhook verification in CI

Stripe webhooks are how your server learns about payment events - charges succeeding, subscriptions renewing, disputes opening. In CI, you need a way to receive these webhooks on your test server, which is typically running on localhost or an ephemeral CI container with no public URL. The Stripe CLI solves this with its listen command. It opens a WebSocket connection to Stripe’s servers and forwards webhook events to a local endpoint you specify. You start it before your tests run, pointed at your test server’s webhook handler, and it will relay all events triggered by your test-mode API calls. This is the recommended approach for CI because it doesn’t require exposing your CI runner to the internet. An alternative for simpler cases is using the Stripe CLI’s trigger command to simulate specific events without making real API calls. Running stripe trigger payment_intent.succeeded will send a synthetic event to your webhook endpoint. This is useful for testing individual handler logic, but it doesn’t exercise the full payment flow the way listen does. Webhook signature verification is the piece most teams get wrong in CI. Stripe signs every webhook with a secret (whsec_...) so your server can verify the event is genuinely from Stripe. When you use stripe listen, the CLI generates a temporary signing secret that differs from your dashboard’s webhook secret. You need to pass this temporary secret to your app’s webhook handler in CI, typically via an environment variable. If you use your production signing secret, every webhook from the CLI will fail signature verification and your tests will timeout waiting for events that your server silently rejected. For reliability in CI, always add a health check that confirms the Stripe CLI listener is connected before running tests. The CLI prints “Ready!” to stdout when the WebSocket is established - wait for that string before proceeding. Without this check, your tests may fire API calls before the listener is ready, and you will miss the resulting webhook events.

# Start Stripe CLI listener in background, capture signing secret
# Run this in your CI setup step before tests execute

# Start listener and capture output
stripe listen \
  --forward-to localhost:3000/api/webhooks/stripe \
  --format JSON \
  > /tmp/stripe-listen.log 2>&1 &

STRIPE_PID=$!

# Wait for the CLI to be ready (max 30 seconds)
for i in $(seq 1 30); do
  if grep -q "Ready!" /tmp/stripe-listen.log 2>/dev/null; then
    break
  fi
  sleep 1
done

# Extract the webhook signing secret from CLI output
WEBHOOK_SECRET=$(grep -o 'whsec_[a-zA-Z0-9]*' \
  /tmp/stripe-listen.log | head -1)

# Export for your test server to use
export STRIPE_WEBHOOK_SECRET=$WEBHOOK_SECRET

echo "Stripe CLI ready. Signing secret: $WEBHOOK_SECRET"
echo "Forwarding webhooks to localhost:3000/api/webhooks/stripe"

# Now run your tests
npm run test:e2e

# Cleanup
kill $STRIPE_PID

Common pitfalls

  • Don’t mock Stripe in E2E tests - the whole point is testing the real integration
  • Test mode and production can behave differently - verify critical flows in both
  • Stripe SDK updates can change element rendering - pin versions and test before upgrading
  • Webhook delivery order in test mode may differ from production - test for out-of-order events

FAQ

Can Cypress test Stripe checkout?

Cypress struggles with Stripe because Elements renders in a cross-domain iframe. You need workarounds like cy.origin() or disabling web security, which compromises test accuracy.

How do I test 3DS in CI?

Use Stripe test cards that trigger the challenge and keep those paths in customer-authored tests. Zerocheck V1 does not ship built-in 3DS helpers or auto-generate tests that enter payment fields.

Should I mock Stripe in E2E tests?

No. Mocking Stripe in E2E tests defeats the purpose. Use Stripe’s test mode with real API calls. Mock only in unit tests for speed.

How to test Stripe checkout in CI

Skip the setup. Zerocheck handles it in plain English.

Get a demo