Payments Testing Guide
How to Test Stripe Elements with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing Stripe Elements (CardElement and PaymentElement) embedded on your own domain. Iframe handling, 3D Secure with confirmPayment, inline validation errors, SetupIntents for saving cards, and the race conditions that break real test suites.
“Most Stripe integrations use Elements rather than Checkout because they need full control over the payment UI. Testing those embedded iframes is where the majority of Playwright suites silently break.”
Stripe Developer Survey, 2025
1. Why Stripe Elements Testing Is Different from Checkout Testing
Stripe Checkout is a hosted page. Your browser navigates away from your domain to checkout.stripe.com, Stripe collects the payment details, and the user is redirected back. Stripe Elements is the opposite: the card form lives on your page, embedded as one or more iframes rendered by Stripe.js. You never leave your domain. You call stripe.confirmPayment() or stripe.confirmCardPayment() from your own JavaScript, and you handle the result yourself.
This architecture changes the testing surface in four important ways. First, the card input is an iframe on your domain rather than a cross-origin page, so you use frameLocator without a prior cross-domain navigation. Second, error messages appear inline on your page, not on a hosted error page. Third, the PaymentElementdynamically shows different payment methods (card, bank redirect, wallet) based on the customer's location and your Stripe configuration, which means your test must handle a variable UI. Fourth, 3D Secure challenges open as a modal iframe overlaid on your page, not as a redirect inside the Stripe hosted page.
The result is that Elements tests are tightly coupled to your own page structure. You control the form layout, the submit button, and the error display. That gives you more power but also more surface area for things to break. The sections below walk through each scenario with runnable Playwright TypeScript code.
Stripe Elements Payment Flow
Your Page
User fills form
Stripe iframe
Card input captured
confirmPayment()
Client-side call
3DS Challenge
If required
PaymentIntent
Server confirms
Webhook
payment_intent.succeeded
Done
Order fulfilled
Stripe Checkout Flow (for comparison)
Your Page
User clicks buy
Redirect
checkout.stripe.com
Stripe Page
Card input + 3DS
Redirect Back
success_url
Webhook
checkout.session.completed
Done
Order fulfilled
Stripe Elements Payment Sequence
2. Setting Up the Test Environment
Before writing any test, configure your environment for Stripe test mode. Every Stripe Elements integration uses a publishable key on the client and a secret key on the server. Both must be test mode keys. Never run end-to-end tests against live keys.
Test Environment Prerequisites
- Stripe account with test mode API keys (pk_test_ and sk_test_)
- Node.js 18+ and Playwright installed in your project
- Stripe CLI installed for local webhook forwarding
- A running local dev server with your Elements integration
- Test card numbers saved in a fixtures file
- Webhook endpoint configured to handle payment_intent.succeeded
Environment Variables
Creating PaymentIntents from Your Server
Unlike Checkout, where Stripe creates the session, with Elements your server creates a PaymentIntent (or SetupIntent) and passes its client_secret to the frontend. Your test setup must ensure this endpoint works before any browser test runs. A simple Playwright globalSetup that hits your health check endpoint is enough.
Forwarding Webhooks Locally
Even with Elements, the final source of truth for a payment is the webhook. Use the Stripe CLI to forward events to your local server during the test run.
The Test Card Matrix
These are the cards you need for Elements testing. Put them in a fixtures file so every test references the same set.
| Number | Behavior |
|---|---|
| 4242 4242 4242 4242 | Succeeds, no authentication |
| 4000 0025 0000 3155 | Requires 3D Secure, succeeds on approve |
| 4000 0000 0000 9995 | Declined: insufficient funds |
| 4000 0000 0000 0002 | Declined: generic decline |
| 4242 4242 4242 4000 | Succeeds, attaches to customer (for SetupIntent) |
| 4000 0084 0000 1629 | 3D Secure challenge, then decline after auth |
Use any future expiry (for example 12/34), any three digit CVC, and any valid postal code. Stripe does not validate these beyond format in test mode.
3. Scenario: Happy Path with CardElement
The CardElement is the classic Stripe Elements component: a single iframe that contains the card number, expiry, and CVC fields in one combined input. It is the simplest Elements integration and the one most tutorials use. Your page mounts it, the user fills it in, and your JavaScript calls stripe.confirmCardPayment(clientSecret).
The key difference from Checkout is that there is no cross-domain navigation. The iframe is on your page from the start. You need to wait for Stripe.js to load and mount the iframe before you can interact with it.
Happy path: CardElement one-time payment
StraightforwardGoal
Starting from your payment page, fill the CardElement iframe with a test card, submit the form, and confirm the payment succeeds.
Playwright Implementation
Verify Server-Side
The UI assertion proves the user sees success, but your backend may still be processing the webhook. Poll your own API to confirm the payment was recorded.
// After the UI assertion, poll your API
await expect.poll(async () => {
const res = await page.request.get('/api/payments/latest');
const body = await res.json();
return body.status;
}, { timeout: 15_000 }).toBe('succeeded');4. Scenario: PaymentElement with Dynamic Payment Methods
The PaymentElementis the modern replacement for CardElement. Instead of a single card input, it renders a full payment form that dynamically shows different payment methods based on the customer's location, the currency, and your Stripe Dashboard configuration. A user in Germany might see card, SEPA Direct Debit, and Klarna. A user in the US might see card and bank debits.
This dynamic behavior makes testing harder because the iframe content varies. Your test needs to handle the fact that the card tab might not be the first visible option, and the field layout inside the iframe differs from CardElement.
PaymentElement with card payment method
ModeratePlaywright Implementation
test('PaymentElement: complete card payment', async ({ page }) => {
await page.goto('/checkout');
// PaymentElement loads inside an iframe hosted by js.stripe.com
const paymentFrame = page.frameLocator(
'iframe[src*="js.stripe.com"][title*="Secure payment"]'
);
// Wait for the payment form to be ready
await paymentFrame.locator('[name="number"]')
.waitFor({ state: 'visible', timeout: 15_000 });
// If multiple payment methods are shown, click the Card tab first
const cardTab = paymentFrame.getByText('Card', { exact: true });
if (await cardTab.isVisible()) {
await cardTab.click();
}
// Fill card details inside the PaymentElement iframe
await paymentFrame.locator('[name="number"]')
.fill('4242 4242 4242 4242');
await paymentFrame.locator('[name="expiry"]')
.fill('12 / 34');
await paymentFrame.locator('[name="cvc"]')
.fill('123');
// Country and postal code fields may appear depending on configuration
const countrySelect = paymentFrame.locator('[name="country"]');
if (await countrySelect.isVisible()) {
await countrySelect.selectOption('US');
}
const postalField = paymentFrame.locator('[name="postalCode"]');
if (await postalField.isVisible()) {
await postalField.fill('42424');
}
// Submit: the button is on your page
await page.getByRole('button', { name: /pay/i }).click();
// Assert success on your page
await expect(page.getByText(/payment successful/i))
.toBeVisible({ timeout: 30_000 });
});Handling Dynamic Payment Methods
If you want to test that specific payment methods appear for specific regions, create the PaymentIntent with explicit payment_method_typeson your server side, or mock the customer's IP location. For card-only tests, the simplest approach is to configure the PaymentIntent with payment_method_types: ['card'] in your test fixture endpoint. This eliminates the variable UI and lets you focus on the card flow.
5. Scenario: Inline Validation Errors
One of the biggest advantages of Elements over Checkout is that validation errors appear inline on your own page. When a user enters an incomplete card number or an invalid CVC, Stripe.js fires an onChange event with the error, and your code renders it however you choose. Testing this flow requires you to trigger validation inside the iframe and then assert on the error text rendered on your page (outside the iframe).
Inline validation: incomplete card and wrong CVC
StraightforwardPlaywright Implementation
test('validation: incomplete card number shows inline error', async ({ page }) => {
await page.goto('/payment');
const cardFrame = page.frameLocator(
'iframe[src*="js.stripe.com"][title*="Secure card"]'
);
// Type an incomplete card number and tab away to trigger validation
const cardInput = cardFrame.locator('[name="cardnumber"]');
await cardInput.waitFor({ state: 'visible', timeout: 15_000 });
await cardInput.fill('4242 4242');
// Move focus to the next field to trigger Stripe's onChange
const expiryInput = cardFrame.locator('[name="exp-date"]');
await expiryInput.click();
// The error text is rendered on YOUR page, not inside the iframe.
// Your component reads the Stripe error from onChange and displays it.
await expect(page.getByText(/your card number is incomplete/i))
.toBeVisible({ timeout: 5_000 });
// The submit button should either be disabled or clicking it should
// not navigate away from the form.
const submitButton = page.getByRole('button', { name: /pay/i });
await submitButton.click();
// Assert we are still on the payment page
await expect(page).toHaveURL(/\/payment/);
});
test('validation: expired card shows decline error after submit', async ({ page }) => {
await page.goto('/payment');
const cardFrame = page.frameLocator(
'iframe[src*="js.stripe.com"][title*="Secure card"]'
);
const cardInput = cardFrame.locator('[name="cardnumber"]');
await cardInput.waitFor({ state: 'visible', timeout: 15_000 });
await cardInput.fill('4000 0000 0000 0069');
await cardFrame.locator('[name="exp-date"]').fill('12 / 34');
await cardFrame.locator('[name="cvc"]').fill('123');
await page.getByRole('button', { name: /pay/i }).click();
// This error comes from the confirmCardPayment() response,
// which your code renders after the server round trip
await expect(page.getByText(/card has expired/i))
.toBeVisible({ timeout: 15_000 });
});The key insight is that Stripe Elements validation happens in two stages. Client-side validation fires immediately when the user blurs a field (incomplete number, invalid expiry format). Server-side validation fires when you call confirmCardPayment() and the charge is actually declined. Your test suite should cover both. Client-side errors are instant; server-side errors take a network round trip.
6. Scenario: 3D Secure with confirmPayment()
When you call stripe.confirmCardPayment()with a card that requires 3D Secure, Stripe.js opens a modal iframe on your page. This iframe loads the 3DS challenge from hooks.stripe.com, and inside it is a nested iframe from the bank's ACS domain where the approve and fail buttons live. The structure is two iframes deep, overlaid on your own page.
This is the scenario that breaks the most test suites. The modal appears asynchronously after the confirmPayment() call, the outer iframe takes time to load, and the inner ACS iframe takes additional time. Your test must chain two frameLocator calls and wait for the nested content to be ready.
3D Secure challenge on your own page
ComplexPlaywright Implementation
The critical difference from Checkout 3DS testing is that the challenge iframe appears on your page, not on checkout.stripe.com. You do not need to wait for a cross-domain navigation first. The iframe appears as an overlay, and once the user completes or fails the challenge, the confirmPayment() promise resolves with either a successful PaymentIntent or an error object that your code handles.
7. Scenario: SetupIntent for Saving Cards Without Charging
Many applications need to save a card for future use without charging it immediately. A subscription trial, a marketplace that charges sellers later, or a SaaS app that bills at the end of the month all use this pattern. The flow uses a SetupIntent instead of a PaymentIntent, and your frontend calls stripe.confirmCardSetup() instead of confirmCardPayment().
The iframe interaction is identical to the PaymentIntent flow, but the assertions are different. There is no charge, no amount, and no payment_intent.succeeded webhook. Instead, you assert that the SetupIntent status is succeeded and that a PaymentMethod was attached to the customer.
Save card for future use with SetupIntent
ModeratePlaywright Implementation
test('SetupIntent: save card without charging', async ({ page }) => {
await page.goto('/account/add-payment-method');
// The CardElement or PaymentElement iframe loads the same way
const cardFrame = page.frameLocator(
'iframe[src*="js.stripe.com"][title*="Secure card"]'
);
const cardInput = cardFrame.locator('[name="cardnumber"]');
await cardInput.waitFor({ state: 'visible', timeout: 15_000 });
await cardInput.fill('4242 4242 4242 4242');
await cardFrame.locator('[name="exp-date"]').fill('12 / 34');
await cardFrame.locator('[name="cvc"]').fill('123');
// Submit calls stripe.confirmCardSetup() under the hood
await page.getByRole('button', { name: /save card/i }).click();
// Assert your UI confirms the card was saved
await expect(page.getByText(/card saved/i))
.toBeVisible({ timeout: 15_000 });
// Verify server-side: the payment method was attached
await expect.poll(async () => {
const res = await page.request.get('/api/account/payment-methods');
const body = await res.json();
return body.methods.length;
}, { timeout: 10_000 }).toBeGreaterThan(0);
});
test('SetupIntent: 3DS required for saving card', async ({ page }) => {
await page.goto('/account/add-payment-method');
const cardFrame = page.frameLocator(
'iframe[src*="js.stripe.com"][title*="Secure card"]'
);
const cardInput = cardFrame.locator('[name="cardnumber"]');
await cardInput.waitFor({ state: 'visible', timeout: 15_000 });
// This card requires 3DS even for setup
await cardInput.fill('4000 0025 0000 3155');
await cardFrame.locator('[name="exp-date"]').fill('12 / 34');
await cardFrame.locator('[name="cvc"]').fill('123');
await page.getByRole('button', { name: /save card/i }).click();
// Handle the 3DS challenge
const challengeOuter = page.frameLocator(
'iframe[src*="js.stripe.com/v3/authorize-with-url-inner"]'
);
const challengeInner = challengeOuter.frameLocator(
'iframe[name="acsFrame"]'
);
await challengeInner
.getByRole('button', { name: /complete authentication/i })
.click({ timeout: 20_000 });
await expect(page.getByText(/card saved/i))
.toBeVisible({ timeout: 15_000 });
});SetupIntent tests are often overlooked because there is no visible payment. But saving a card is a critical user journey. If the SetupIntent fails silently, the user thinks their card is saved and then sees a charge failure days later when the first real invoice is created. Test this path explicitly.
8. Common Pitfalls That Break Real Test Suites
Stripe.js Load Race Condition
Stripe.js is loaded asynchronously. If your test starts interacting with the page before Stripe.js has finished loading and mounting the Elements iframe, the iframe simply will not exist yet. The most common symptom is a frameLocatorthat matches zero frames and silently times out. Always wait for the iframe's inner input to be visible before filling it. Never assume the iframe is ready just because your page has loaded.
// Wrong: assumes iframe exists immediately
const frame = page.frameLocator('iframe[title*="Secure card"]');
await frame.locator('[name="cardnumber"]').fill('4242...');
// Right: wait for the inner element to be ready
const frame = page.frameLocator('iframe[title*="Secure card"]');
const cardInput = frame.locator('[name="cardnumber"]');
await cardInput.waitFor({ state: 'visible', timeout: 15_000 });
await cardInput.fill('4242 4242 4242 4242');Iframe Selector Brittleness
Stripe periodically changes the exact attributes on its iframes. A selector like iframe[name="__privateStripeFrame5"] will break when Stripe increments the frame number. Match on stable attributes instead: the src domain (js.stripe.com) and the title attribute, which Stripe maintains for accessibility.
The confirmPayment() Promise Trap
When your code calls confirmPayment(), the promise does not resolve until the payment is complete (or fails). If 3DS is required, the promise stays pending while the challenge modal is open. Your test should not set a short timeout on the next assertion after clicking submit, because the 3DS challenge can take several seconds in test mode. Use generous timeouts (20 to 30 seconds) for any assertion that follows a confirmPayment() call.
Testing on the Wrong Element Type
CardElement and PaymentElement have different iframe structures. CardElement uses a single iframe with fields named cardnumber, exp-date, and cvc. PaymentElement uses a single iframe but with fields named number, expiry, and cvc. If you copy code from a CardElement test into a PaymentElement test (or vice versa), the field selectors will silently fail. Know which element your integration uses and write selectors accordingly.
Not Cleaning Up Test Data
Every PaymentIntent and SetupIntent your tests create persists in your Stripe test account. Add a teardown hook that cancels or cleans up test resources. Tag your test PaymentIntents with metadata.e2e_run="true" so you can identify and purge them.
9. Writing These Scenarios in Plain English with Assrt
Every scenario above requires you to know the exact iframe selector, the exact field name inside that iframe, and the exact chain of frameLocator calls for 3DS. When Stripe changes any of these (and they do, regularly), your entire test suite breaks overnight. Assrt lets you describe the scenario in plain English, generates the Playwright TypeScript, and regenerates the selectors automatically when the underlying DOM changes.
The CardElement happy path from Section 3 looks like this in Assrt:
CardElement Happy Path: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('happy path: CardElement payment succeeds', async ({ page }) => {
await page.goto('/payment');
const cardFrame = page.frameLocator(
'iframe[src*="js.stripe.com"][title*="Secure card"]'
);
const cardInput = cardFrame.locator('[name="cardnumber"]');
await cardInput.waitFor({ state: 'visible', timeout: 15_000 });
await cardInput.fill('4242 4242 4242 4242');
const expiryInput = cardFrame.locator('[name="exp-date"]');
await expiryInput.fill('12 / 34');
const cvcInput = cardFrame.locator('[name="cvc"]');
await cvcInput.fill('123');
const postalInput = cardFrame.locator('[name="postal"]');
if (await postalInput.isVisible()) {
await postalInput.fill('42424');
}
await page.getByRole('button', { name: /pay/i }).click();
await expect(page.getByText(/payment successful/i))
.toBeVisible({ timeout: 30_000 });
});The 3D Secure scenario from Section 6 looks like this:
# scenarios/stripe-elements-3ds.assrt
describe: 3D Secure challenge on Elements page
given:
- I am on the payment page
- Stripe.js has loaded and the card form is visible
steps:
- fill the card number with 4000 0025 0000 3155
- fill the expiry with 12 / 34
- fill the CVC with 123
- click the pay button
- complete the 3D Secure authentication challenge
expect:
- the page shows "payment successful" within 30 secondsAssrt compiles each scenario file into the same Playwright TypeScript you saw in the sections above, committed to your repo as a real test you can read, run, and modify. When Stripe renames an iframe attribute or changes a field name inside the Elements component, Assrt detects the failure, analyzes the new DOM, and opens a pull request with the updated locator. Your scenario file stays untouched.
The SetupIntent scenario from Section 7 collapses to six lines:
# scenarios/stripe-elements-setup-intent.assrt
describe: Save card for future use with SetupIntent
given:
- I am on the add payment method page
steps:
- fill the card number with 4242 4242 4242 4242
- fill the expiry with 12 / 34
- fill the CVC with 123
- click "Save card"
expect:
- the page shows "card saved" within 15 seconds
- the payment methods API returns at least one methodStart with the CardElement happy path. Once it is green in your CI, add the PaymentElement test, then 3DS, then validation errors, then the SetupIntent. In a single afternoon you can have complete Stripe Elements coverage that stays green even when Stripe updates their iframe internals. That is the failure mode this entire guide exists to prevent, and it is the reason we built Assrt.
Related Guides
How to Test Apple Pay on Web
A practical, scenario-by-scenario guide to testing Apple Pay on the web with Playwright....
How to Test BigCommerce Checkout
A scenario-by-scenario guide to testing BigCommerce checkout with Playwright. Covers...
How to Test Google Pay on Web
Step-by-step guide to testing Google Pay web integration with Playwright. Covers Payment...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.