← All posts

How to Test Stripe Card Payments with Elements (and Why Hosted Checkout Fights Back)

Title card: How to Test Stripe Card Payments with Elements vs Hosted Checkout

How to Test Stripe Card Payments with Elements (and Why Hosted Checkout Fights Back)

If you've ever tried to write an end-to-end test that fills in a card number, clicks "Pay", and asserts that a subscription got created, you've probably discovered something frustrating. The way you integrate Stripe decides whether that test is a five-minute job or a week-long fight.

With Stripe Elements, driving a test card through your checkout in an automated browser test is straightforward. With Stripe's default hosted Checkout (the redirect to checkout.stripe.com), the exact same approach falls apart, and it's not a bug in your test.

This post walks through how to do card testing properly with Elements, why it doesn't work against hosted Checkout out of the box, and what your options are for each.

A note on terminology. "Card testing" also refers to a fraud pattern, where attackers run stolen card numbers through a checkout to find live ones. This article is not about that. Here, "card testing" means the legitimate engineering task of verifying your own payment flow with Stripe's official test cards. If fraud-style card testing is your concern, that's a job for Stripe Radar and rate limiting, not automated tests.

What "card testing" means here

Stripe gives you a set of official test card numbers that only work against your test-mode keys. The classic ones:

  • 4242 4242 4242 4242: succeeds cleanly (Visa)

  • 4000 0000 0000 9995: declined for insufficient funds

  • 4000 0025 0000 3155: requires 3D Secure authentication

  • 4000 0000 0000 0002: a generic card decline

Card testing, in our sense, is a simple question. Can an automated browser fill one of these numbers, submit the form, and produce the payment outcome you expect? You want this in CI so a refactor of your billing code can't silently break the "money actually moves" path.

The whole thing hinges on one detail: whose page is the card field on?

Why Elements makes testing easy

Stripe Elements (including the newer Payment Element) mounts card inputs as Stripe-hosted iframes inside your own page. Your checkout lives on your domain, app.yourcompany.com, and the card fields are <iframe>s embedded in that page.

That distinction is everything. Because the top-level page is yours, your test runner (Playwright, Cypress, Selenium) has full control of the tab. It can reach into the Stripe iframes to type a card number, because those iframes are same-origin from the browser-automation perspective. The automation driver operates on the whole tab, not one origin.

Here's a minimal Payment Element mount:

// checkout.js runs on app.yourcompany.com
const stripe = Stripe(PUBLISHABLE_KEY);
const elements = stripe.elements({ clientSecret });

const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const { error } = await stripe.confirmPayment({
    elements,
    confirmParams: { return_url: "https://app.yourcompany.com/billing/done" },
  });
  if (error) showError(error.message);
});

And here's a Playwright test that drives a real test card through it:

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

test("card payment succeeds", async ({ page }) => {
  await page.goto("/checkout");

  // The Payment Element renders its fields inside a Stripe iframe.
  // Playwright's frameLocator reaches into it because the tab is ours.
  const stripeFrame = page.frameLocator('iframe[title="Secure payment input frame"]');

  await stripeFrame.getByPlaceholder("1234 1234 1234 1234").fill("4242424242424242");
  await stripeFrame.getByPlaceholder("MM / YY").fill("12/34");
  await stripeFrame.getByPlaceholder("CVC").fill("123");

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

  await expect(page).toHaveURL(/\/billing\/done/);
  await expect(page.getByText("Payment successful")).toBeVisible();
});

A few things make this reliable:

  • You control the DOM around the fields, so you can add stable data-testid hooks, wait for your own loading states, and assert on your own success UI.

  • The redirect stays on your domain (return_url points back to you), so post-payment assertions are just normal navigation.

  • 3D Secure test cards open Stripe's authentication iframe, which is still inside your tab, so you can drive it too:

// For a card like 4000 0025 0000 3155 that triggers 3DS
const challenge = page
  .frameLocator("iframe[name^='__privateStripeFrame']")
  .frameLocator("iframe[name='stripe-challenge-frame']");
await challenge.getByRole("button", { name: "Complete" }).click();

The point isn't the exact selectors. Stripe changes iframe titles occasionally, so pin them down against the version you're on. The point is that all of this is reachable because the top-level origin belongs to you.

Why hosted Checkout fights back

Stripe's default hosted Checkout works differently. You create a Checkout Session server-side and redirect the browser to it:

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: PRICE_ID, quantity: 1 }],
  success_url: "https://app.yourcompany.com/done?session_id={CHECKOUT_SESSION_ID}",
  cancel_url: "https://app.yourcompany.com/pricing",
});
res.redirect(session.url); // -> https://checkout.stripe.com/c/pay/...

The card form now lives on checkout.stripe.com, a page Stripe owns end to end. This is great for you as an integrator, because Stripe handles PCI scope, localization, wallets, SCA, and fraud tooling. It's also exactly why automated card testing breaks:

  1. It's a cross-origin, top-level page you don't own. Your test navigates away from your app to Stripe's domain. The DOM, selectors, and structure are Stripe's, and they can change any time without warning. Your test has no contract with them.

  2. Stripe actively resists automation on that page. Hosted Checkout is a prime target for real card-testing fraud, so Stripe applies bot detection, rate limiting, and challenge flows to it. Automated browsers can trip these defenses and get blocked, throttled, or served a challenge. Your CI run then fails not because your code is broken but because Stripe (correctly) thinks a bot is probing the page.

  3. Selectors and structure are undocumented and unstable. There's no supported data-testid contract for the hosted Checkout DOM. Anything you scrape today can silently break on Stripe's next deploy, giving you a flaky suite that fails on their schedule, not yours.

  4. Some frameworks can't cross the origin at all. Cypress, for example, historically could not visit a second superdomain in one test. Even tools that can (Playwright, Selenium) are still stuck fighting points 1 through 3.

So the honest summary is this. You generally cannot, and shouldn't try to, automate typing a test card into hosted Checkout. It works in manual testing, where you as a human click through test mode, but it is not a stable foundation for CI.

What to do about hosted Checkout instead

You don't have to give up test coverage just because you use hosted Checkout. You just move the boundary of what you test.

1. Test up to the redirect, then stop. Assert that your app creates a Checkout Session with the right parameters (correct price, quantity, mode, success_url, customer) and redirects to a checkout.stripe.com URL. That's the part you own and can break.

test("checkout session is created correctly", async ({ page }) => {
  await page.goto("/pricing");
  await page.getByRole("button", { name: "Subscribe" }).click();
  await page.waitForURL(/checkout\.stripe\.com/);
  // Your job ends here. Stripe's page is Stripe's problem.
});

2. Test the fulfillment side with webhooks, not the UI. The thing that actually matters after payment (the subscription row, the plan upgrade, the entitlement) is driven by the checkout.session.completed webhook. Test that directly by firing a simulated event with the Stripe CLI:

stripe trigger checkout.session.completed

Point the CLI at your local webhook handler and assert your database ends up in the right state. This is faster, deterministic, and covers the logic that genuinely lives in your codebase.

3. Use Stripe's test clocks for lifecycle logic. Renewals, trial expiry, and dunning are all covered by test clocks, which let you advance time and assert on the resulting subscription state without touching a browser at all.

4. If you truly need automated card entry, use Elements. This is the deciding factor for a lot of teams. If end-to-end "fill a card, get a subscription" coverage in CI is a hard requirement, that's a strong reason to embed Elements rather than redirect to hosted Checkout. You keep Stripe's PCI and SCA handling and you keep a testable, same-origin checkout.

The decision, in one table

Stripe Elements

Hosted Checkout

Card field lives on

Your domain (Stripe iframe)

checkout.stripe.com

Automate card entry in CI

Yes, reliably

No (cross-origin + bot defenses)

Post-payment assertions

Normal navigation on your app

Webhook + success_url only

PCI scope

Reduced (Stripe iframe)

Fully offloaded

Build effort

Higher (you own the form UI)

Lower (Stripe owns it)

Best-covered by

Browser E2E

Webhook + test-clock tests

Takeaways

  • "Where does the card field live?" decides everything. Same-origin (Elements) is testable end to end. Cross-origin (hosted Checkout) is not.

  • Don't scrape checkout.stripe.com. It's undocumented, unstable, and defended against exactly the kind of automation you'd be writing. A test that types into it is flaky by construction.

  • For hosted Checkout, test the seams you own: session creation on the way in, webhooks and test clocks on the way out.

  • If automated card-level E2E coverage is non-negotiable, that's a reason to choose Elements. You get the same Stripe safety net with a checkout your tests can actually drive.

Pick your integration with testing in mind up front. Retrofitting testability onto a hosted-Checkout flow after the fact is a lot harder than choosing Elements when card-level automation is a requirement you already know you have.