How to test Stripe checkout before release

Test mode and test cards, the cross-origin card frame, 3D Secure and webhooks in CI, then how a QA agent turns your purchase and decline checklist into browser tests that rerun on every release.

Test your checkout

Why this is hard to test

  • •Stripe Elements renders the card fields inside an iframe served from js.stripe.com, so the parent page’s locators cannot reach them.
  • •A 3D Secure challenge opens another Stripe-hosted frame on top of the checkout, with its own load timing.
  • •Test mode uses Stripe’s test cards and sandbox events; live mode involves real issuers and your Radar rules, so a green test-mode run is evidence, not proof.
  • •A visible confirmation page does not show that your webhook handler received and verified the event.
  • •Totals, discounts and tax depend on fixture data that has to stay stable between runs.

Approach 1: Playwright with Stripe test mode

  1. 1.Confirm Stripe test mode, then create a fresh test identity and fixed sandbox product and price.
  2. 2.Create sandbox products, prices and a test customer so the expected total is fixed.
  3. 3.Cross into the Stripe iframe with page.frameLocator() and wait for the card input to be visible before filling it.
  4. 4.Use Stripe’s documented test cards: 4242 4242 4242 4242 succeeds, 4000 0025 0000 3155 requires authentication for on-session payments.
  5. 5.For a separate Playwright 3D Secure test, inspect the nested challenge frames and verify the outcome. Zerocheck does not claim 3D Secure support.
  6. 6.Assert the confirmation and exact total in the browser. Verify webhook handling separately with integration assertions. For decline, wait for the error before checking that paid confirmation is absent.
  7. 7.In CI, forward sandbox events with stripe listen and pass its signing secret to the app under test.

Approach 2: Zerocheck for the checkout flow your team describes

  1. 1.Use Add a test or Import checklist in the web app, or write repository YAML for the local CLI.
  2. 2.Leave allowed_origins unset unless you want to restrict the agent to exact origins; ordinary navigation and embedded frames, including the payment frame, work without it.
  3. 3.Review named-field Enter and Click steps and the expected app outcome; inspect the actual result before saving.
  4. 4.Rerun the saved test after an application change. Evaluate hosted and local execution separately.

Make the release checklist specific

A useful success item is: buy Pro with a public Stripe test card and confirm the account shows Pro. A decline item is: use a fresh account and a declined test card, confirm the error, then confirm paid access was not granted. Include the exact visible outcome for your app. These are checklist examples to adapt, not a verified recipe for every checkout.

Choose the runner for your actual flow

Web-authored tests run in Zerocheck's hosted browser. The optional CLI runs on your machine or CI. Both can encounter bot protection; a local pass does not establish a hosted pass. We prepare the local evaluation path and discuss permitted test-tenant exceptions during assisted setup. Checking your own machine and network before the call is optional.

What is verified today

Exact origin permissions, nested-frame interaction, input masking and bounded recovery from changing frames are released and covered by controlled browser fixtures. End-to-end Stripe success and decline compatibility is still under evaluation, so we do not yet claim those outcomes for an arbitrary checkout. 3D Secure, automated challenge solving and webhook testing are outside this initial scope.

Testing Stripe checkout with a QA agent

A QA agent removes the selector work, not the test-mode discipline above. You describe the purchase in plain English; Zerocheck grounds each step on the live page, including the embedded card frame, which needs no origin configuration, and records a screenshot and a verdict per step. The tests are YAML you keep in your repository and rerun locally, in GitHub Actions or in a hosted browser. The example below is the release checklist item "a new patient can request a certificate and pay" from a leading Australian telehealth company, written for staging with Stripe's public test card and a fresh identity on every run. The final step names the app's own confirmation label. The card step is being verified against the embedded frame:

# zerocheck.yaml
environments:
  staging:
    url: https://staging.example.com/request/medical-certificate

# checks/certificate-purchase.yaml
version: zerocheck/v1
name: New patient requests a certificate and pays
description: Staging only. Public Stripe test card. Fresh identity on every run.
steps:
  - Navigate to {{app_url}}
  - Verify text "Medical Certificate Request" is visible
  - Click "Sick Leave"
  - Click "Work"
  - Click "Continue"
  - Click "Common cold or flu"
  - Click "Continue"
  - Verify text "Leave Details" is visible
  - Click "Continue"
  - Enter "Automated test request with fictional data." into "Share a few details"
  - Click "Continue"
  - Enter "{{unique_email}}" into "Email"
  - Enter "Zerocheck" into "First Name"
  - Enter "Test" into "Last Name"
  - Enter "01/01/1990" into "Date of Birth"
  - Enter "0491570006" into "Mobile Number"
  - Click "Continue"
  - Enter "1" into "Street Number"
  - Enter "Test Street" into "Street Name"
  - Enter "Sydney" into "City/Suburb"
  - Select "New South Wales" from "State/Territory"
  - Enter "2000" into "Postal Code"
  - Click "Continue"
  - Verify text "Review Your Details" is visible
  - Click "Continue"
  - Verify text "Payment" is visible
  - Verify text "Test Mode" is visible
  - Enter "4242424242424242" into "Card number"
  - Enter "12 / 34" into "Expiration"
  - Enter "123" into "CVC"
  - Enter "Zerocheck Test" into "Cardholder name"
  - Click "Subscribe"
  - Verify text "Request submitted" is visible

What the QA agent proves, and what it does not

Every step gets a screenshot, and the test finishes passed, failed, flaky (passed after a transient retry) or incomplete. If a control on your own pages moves, the heal is recorded with its old and new target and stays pending until the whole test passes again; targets inside the payment frame are re-identified on every run instead. A pass after a transient retry is labeled flaky, never hidden. The run verdict is what your merge policy gates on: the CLI exits non-zero and one pull request comment is updated. Provider processing, 3D Secure challenges, webhooks and settlement stay outside the browser test; keep them in the integration tests described below. Both runner types can meet bot protection, so if your checkout has a captcha, use the captcha provider's test keys or disable it in staging. As of 22 September 2026, complete Stripe success and decline compatibility is still being verified against embedded frames; the recorded run will be published here when it passes.

Stripe Elements renders in a cross-origin iframe

Stripe does not put the card fields in your page’s DOM. Elements creates an iframe served from js.stripe.com that holds the card number, expiry and CVC inputs, so your JavaScript never touches card data. That isolation is part of how Stripe keeps card data out of your systems, and it is also why document.querySelector and page.locator() from the parent page find nothing. Playwright crosses the boundary with page.frameLocator(). Target the iframe by a stable attribute, then chain ordinary locators inside it. At the time of writing the Payment Element’s iframe carries the title “Secure payment input frame” and the legacy Card Element uses titles beginning “Secure card”. Stripe controls these attributes, so inspect your own page rather than trusting a blog post, including this one. A frame can appear before its inputs finish rendering. Playwright locators inside frameLocator retain their normal auto-waiting, so fill already waits for an actionable input. A separate visibility assertion is useful for a clear diagnostic checkpoint or a different deadline. Check the frame identity and field locator before treating a timeout as a timing problem.

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

test('pays with a Stripe test card', async ({ page }) => {
  await page.goto('/checkout');

  // The Payment Element renders inside an iframe served by js.stripe.com.
  // Inspect your page for the exact title; Stripe controls these attributes.
  const stripe = page.frameLocator('iframe[title="Secure payment input frame"]');
  const card = stripe.getByPlaceholder('1234 1234 1234 1234');

  // The iframe exists before Stripe has rendered its inputs. Wait for the field.
  await expect(card).toBeVisible({ timeout: 10_000 });
  await card.fill('4242424242424242');
  await stripe.getByPlaceholder('MM / YY').fill('12/34');
  await stripe.getByPlaceholder('CVC').fill('123');
  // A postal code field appears for some countries and configurations.

  await page.getByRole('button', { name: 'Pay' }).click();

  await expect(page).toHaveURL(/\/order-confirmation/);
  await expect(page.getByText('Payment successful')).toBeVisible();
  await expect(page.getByText('$25.00')).toBeVisible();
});

Test cards and a separate Playwright 3D Secure example

Stripe’s testing documentation, checked on 21 September 2026, lists cards that exercise each path. In test mode any future expiry is accepted, with any three-digit CVC, or four digits for American Express. 4242 4242 4242 4242 succeeds and is not enrolled in 3D Secure, so the customer is never prompted. 4000 0025 0000 3155 requires authentication for on-session payments. 4000 0027 6000 3184 requires authentication on every transaction. 4000 0000 0000 3220 must complete 3D Secure for the payment to succeed, and Radar requests it by default. 4000 0000 0000 3055 supports 3D Secure but does not require it. 4000 0000 0000 0002 is declined with generic_decline, and 4000 0000 0000 9995 is declined with insufficient_funds. When a card requires authentication, Stripe opens the test challenge in nested iframes over your checkout. At the time of writing the test-mode challenge shows a page with a button to complete the authentication and one to fail it, usually two or three frames deep. Your test has to cross each frame boundary, wait for the button, and allow more time than a normal click because the challenge loads from Stripe. The frame names and ids belong to Stripe and can change; the durable part of the pattern is nested frameLocator calls, an explicit wait and an assertion on the outcome your app shows afterward.

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

test('completes a 3D Secure test challenge', async ({ page }) => {
  await page.goto('/checkout');
  const stripe = page.frameLocator('iframe[title="Secure payment input frame"]');
  const card = stripe.getByPlaceholder('1234 1234 1234 1234');
  await expect(card).toBeVisible({ timeout: 10_000 });

  // Requires authentication for on-session payments (Stripe testing docs).
  await card.fill('4000002500003155');
  await stripe.getByPlaceholder('MM / YY').fill('12/34');
  await stripe.getByPlaceholder('CVC').fill('123');
  await page.getByRole('button', { name: 'Pay' }).click();

  // Stripe opens the test challenge in nested iframes. Inspect the frame names
  // in your integration; they are Stripe's and can change. Many integrations
  // nest modal > #challengeFrame > acsFrame; drop the last hop if yours does not.
  const modal = page.frameLocator('iframe[name^=__privateStripeFrame]').last();
  const challenge = modal.frameLocator('#challengeFrame').frameLocator('iframe[name="acsFrame"]');
  await challenge.getByRole('button', { name: /complete/i }).click({ timeout: 15_000 });

  await expect(page).toHaveURL(/\/order-confirmation/, { timeout: 15_000 });
  await expect(page.getByText('Payment successful')).toBeVisible();
});

test('shows a decline message', async ({ page }) => {
  await page.goto('/checkout');
  const stripe = page.frameLocator('iframe[title="Secure payment input frame"]');
  const card = stripe.getByPlaceholder('1234 1234 1234 1234');
  await expect(card).toBeVisible({ timeout: 10_000 });
  await card.fill('4000000000000002'); // generic_decline
  await stripe.getByPlaceholder('MM / YY').fill('12/34');
  await stripe.getByPlaceholder('CVC').fill('123');
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByText('Your card was declined')).toBeVisible(); // your app's message
});

Test mode is not live mode

Stripe’s sandbox catches integration mistakes: wrong amounts, a broken success redirect, an unhandled decline. It does not reproduce a live issuer, a real bank’s challenge screen or your production Radar rules. Stripe documents separate test cards whose challenge UIs are meant for mobile SDKs rather than browser forms, which is a reminder that the test challenge is a stand-in, not the customer’s bank. Treat a passing test-mode suite as one input to the release decision. Keep automated release checks in the sandbox with test keys and fixtures. A test-mode result establishes the exercised integration behavior under those conditions; it does not establish live issuer or production challenge behavior. Webhooks are where test mode most often misleads. Stripe’s testing docs give two ways to exercise an endpoint: perform an action in the sandbox that emits a real event, such as paying with a successful test card, or trigger a synthetic event with the Stripe CLI. The first proves the whole path; the second proves only the handler.

Separate integration tests with the Stripe CLI

Your test server in CI has no public URL, so Stripe cannot deliver events to it directly. The Stripe CLI’s listen command opens a connection to Stripe and forwards sandbox events to a local endpoint. Start it before the tests, wait until it is ready, and pass its signing secret to the app. The secret is the step teams get wrong. Stripe signs every event, and stripe listen prints a temporary signing secret that differs from the one on your dashboard endpoint. If the app verifies with the dashboard secret, every forwarded event fails verification and your tests wait for state that never arrives. The script below captures the secret from the CLI output and exports it before the tests start.

# CI setup step: forward Stripe sandbox events to the app under test.
stripe listen --forward-to localhost:3000/api/webhooks/stripe > stripe-listen.log 2>&1 &
STRIPE_PID=$!

# Wait for the listener. It prints the signing secret once it is ready.
for i in $(seq 1 30); do
  grep -q 'whsec_' stripe-listen.log && break
  sleep 1
done
export STRIPE_WEBHOOK_SECRET=$(grep -o 'whsec_[A-Za-z0-9]*' stripe-listen.log | head -1)

# The app must verify signatures with this secret, not the dashboard endpoint's secret.
npm run test:e2e

# Optional: send one synthetic event to exercise a handler without a checkout.
stripe trigger payment_intent.succeeded

kill $STRIPE_PID

What a browser test proves about a payment

A browser test proves what the customer saw: the total before paying, the challenge if one appeared, and the confirmation afterward. It does not prove that money moved, that the event was verified or that your ledger updated. Keep those assertions in integration tests that read your own database or Stripe’s API in test mode. That boundary is also how to split the work. Cart and summary tests do not need a payment at all: add an item, change a quantity, assert the visible subtotal. They are cheap, fast and cover the part of checkout that changes most often. In Zerocheck they are a good first test because the expected outcome is a quoted total that the explicit text assertion checks without a model. Evaluate the complete purchase and decline flow separately once the fixtures are stable; a passing cart test does not establish payment compatibility.

Common pitfalls

  • —Do not run a live-mode purchase as a casual smoke test. Use test mode, a test customer and Stripe’s test cards.
  • —Do not treat a visible success message as proof of settlement or of webhook handling.
  • —Do not fill the Stripe frame as soon as the iframe exists. Wait for the input inside it to be visible.
  • —Do not mock Stripe in end-to-end tests. Mock the SDK in unit tests; use test mode here.
  • —Do not hard-code Stripe’s frame names and ids as if they were yours. Inspect them again when a test breaks.

FAQ

Does Zerocheck complete payments automatically?

A test uses ordinary Enter and Click steps in a confirmed test environment. Whether your complete purchase and decline flows work must be established in the actual runner. There is no payment-provider adapter or universal Stripe compatibility claim.

Can Cypress test Stripe checkout?

It can, with friction. Cypress runs inside the page and is bound by the same-origin policy, so reaching into Stripe’s iframe needs an iframe plugin or chromeWebSecurity set to false, which makes the test less like a real browser. Playwright’s frameLocator() crosses the boundary directly.

How do I test 3D Secure in test mode?

Use Stripe’s authentication test cards. On 21 September 2026 Stripe’s testing page lists 4000 0025 0000 3155 as requiring authentication for on-session payments and 4000 0000 0000 3220 as requiring 3D Secure to be completed. Complete the test challenge inside its nested frame and assert the outcome your app shows.

Can a QA agent test Stripe checkout?

Yes on your app's own pages, using Stripe test mode and public test cards; interaction inside the embedded card frame, with its origin allowed, is being verified. Provider processing, 3D Secure challenges, webhooks and settlement need separate checks. Zerocheck records a screenshot and verdict per step and reruns the same test on every release.

Should I mock Stripe in end-to-end tests?

No. Test mode exists so the real integration runs against Stripe’s sandbox. Mock the Stripe SDK only in unit tests where speed matters.

Can I test only the cart?

Yes. A narrow test that adds an item and asserts the visible total protects the browser journey without a payment, and it is a good first Zerocheck test.

How to test Stripe checkout before release

Describe a customer flow or paste your release checklist, review the browser result, and save the tests you want to rerun.

Test your checkout