Testing Third-Party Payment Iframes
Every card field on a modern checkout page belongs to somebody else. Stripe Elements, Adyen Secured Fields, Braintree Hosted Fields and Checkout.com Frames all render the card number, expiry and CVC inside an iframe served from the provider's own origin, so the raw pan never touches the merchant's DOM and the merchant stays inside a lighter PCI DSS assessment. That design is correct for security and hostile to test code: the fields are cross-origin, their frame names change on every mount, the provider re-creates the frames when the amount or currency changes, and a successful payment can open a second, deeper iframe for a 3D Secure challenge. This page shows how to drive those fields with frameLocator(), how to reach the nested challenge frame, and how to keep a provider's sandbox from turning your checkout suite into a coin flip.
Root cause: the fields are in someone else's document
A payment iframe is a separate document with a separate origin, so nothing in the merchant page can select, read or type into it — that isolation is the entire product. Playwright is not subject to that rule, because it speaks to the browser over the automation protocol rather than from inside the page, and frameLocator() resolves a frame by its host element and then queries the frame's own document directly. The practical problems are therefore not permission problems; they are identity and lifetime problems. Providers name their frames with a random suffix (__privateStripeFrame1621), mount several invisible controller and metrics frames alongside the visible one, and tear down and recreate the field frames whenever the payment intent, amount or locale changes. Any test that grabs iframe by index, or holds a Frame object across a re-render, is coupled to values the provider changes without notice. The durable approach is a prefix or attribute match on the frame host plus an accessible-name query inside it, which is the same principle applied one level deeper than in Handling Cross-Origin Iframe Restrictions and covered generally in the Iframes & Embedded Content guide.
Minimal reproducible example
The test below shows the naive approach that fails and the resolution that holds. Run it against a Stripe test-mode checkout; the same shape applies to Adyen and Braintree with different attribute names.
import { test, expect } from '@playwright/test';
test('fills a Stripe card field', async ({ page }) => {
await page.goto('/checkout');
// WRONG: Stripe mounts several hidden controller and metrics iframes next
// to the visible field frame, so this throws:
// Error: strict mode violation: locator('iframe') resolved to 4 elements
// await page.frameLocator('iframe').locator('input').fill('4242424242424242');
// RIGHT: match the frame host on the stable name prefix. The numeric
// suffix (__privateStripeFrame1621) changes on every mount, so ^= is required.
const card = page.frameLocator('iframe[name^="__privateStripeFrame"]');
// Inside the frame, query by accessible name rather than Stripe's internal
// ids (#Field-numberInput), which differ between Card and Payment Element.
const number = card.getByRole('textbox', { name: 'Card number' });
// A frameLocator resolves lazily on every action, so this single await
// covers "frame not attached yet" and "SDK still booting" in one wait.
await expect(number).toBeVisible();
// Stripe's input formats on keystrokes; fill() sets the value and dispatches
// an input event, which the SDK accepts. Providers that only listen for
// keydown need pressSequentially() instead — see step 3.
await number.fill('4242424242424242');
await card.getByRole('textbox', { name: 'Expiration' }).fill('12/34');
await card.getByRole('textbox', { name: 'CVC' }).fill('123');
// The Pay button lives in YOUR document, not the frame — do not chain it
// off `card`, or the locator will search the provider's document and time out.
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByText('Payment received')).toBeVisible();
});
Step-by-step fix
-
Anchor the frame on a stable attribute, never on index or full name. Use an attribute-prefix match on the host element:
iframe[name^="__privateStripeFrame"]for Stripe,iframe[name="braintree-hosted-field-number"]for Braintree Hosted Fields, andiframe[title="Iframe for secured card number"]oriframe[data-fieldtype="encryptedCardNumber"]for Adyen Secured Fields. Adyen and Braintree give each field its own frame, so you will hold three frame locators; Stripe's Payment Element puts all three fields in one. Matching onnth(0)works exactly until the provider adds another hidden frame in a minor SDK release. -
Use
frameLocator(), notpage.frame().page.frame({ url: /stripe/ })returns aFramesnapshot captured at the moment you asked, and once the SDK remounts the element that object points at a dead execution context. AFrameLocatorstores only the selector and re-resolves the frame on every action and every assertion retry, which is what makes it survive the remounts described below. The same lazy-resolution argument drives the chaining rules in Automating Elements Inside Nested Iframes. -
Match the provider's input model when typing. Stripe and Adyen inputs react to the
inputevent, sofill()is correct and fast. Braintree Hosted Fields and several older tokenizers format and validate onkeydown, and a bulkfill()leaves their internal state empty even though the field looks populated — the submit button stays disabled and the SDK reports the number as incomplete. For those, usepressSequentially('4242424242424242', { delay: 30 }), which emits a real key sequence per character. Test one provider once, write down which mode it needs, and keep it in the page object. -
Wait on provider readiness, not on a timer. The card frame attaches before the SDK has wired its listeners, so an action fired the instant the frame appears can be silently dropped. Assert the field is both visible and editable —
await expect(number).toBeEditable()— before typing, and where the SDK exposes a ready signal (Stripe'sreadyevent on the element, Adyen'sonReady) have the app set adata-payment-readyattribute in test builds and wait on that. Never insertwaitForTimeout(); the reasoning is laid out in Handling Dynamic Content. -
Chain a frame locator per boundary for 3D Secure. A card that triggers a challenge (Stripe's
4000 0027 6000 3184) opens the issuer's authentication page inside a frame nested two levels below the page. ChainframeLocator()calls in document order rather than trying to reach the button in one query, then click the issuer's completion control. Some providers redirect to a popup window instead; catch that withpage.waitForEvent('popup')and drive the returnedPagenormally. -
Decide per test whether you need the real provider at all. Most checkout tests assert your application's behaviour — validation copy, totals, the confirmation screen — and only a handful assert the integration itself. Stub the provider's script and confirmation endpoint with
page.route()for the first group so they run offline and deterministically, and keep a small tagged suite that hits the provider sandbox for the second. The stubbing mechanics are in Mocking API Responses with Playwright.
Two details in that sequence trip people up. The Pay button belongs to the merchant document even though it sits directly under the card fields, so it must be located from page, not from the frame locator — chaining it off the provider frame produces a timeout that reads like a rendering bug. And the redirect at the end returns control to your origin rather than resolving inside the frame, which means the confirmation assertion is an ordinary page-level assertion; nothing about the payment being third-party changes how you verify your own success screen.
The 3D Secure chain in step 5 looks like this against Stripe's test-mode challenge. Each frameLocator() call descends exactly one boundary, and the whole chain re-resolves on every retry, so a slow issuer page costs time rather than a failure.
import { test, expect } from '@playwright/test';
test('completes a 3D Secure challenge', async ({ page }) => {
await page.goto('/checkout');
const card = page.frameLocator('iframe[name^="__privateStripeFrame"]');
// 4000 0027 6000 3184 is Stripe's card that always forces a 3DS2 challenge.
await card.getByRole('textbox', { name: 'Card number' }).fill('4000002760003184');
await card.getByRole('textbox', { name: 'Expiration' }).fill('12/34');
await card.getByRole('textbox', { name: 'CVC' }).fill('123');
await page.getByRole('button', { name: 'Pay now' }).click();
// The challenge is three documents deep: the Stripe wrapper frame, the
// challenge shell, then the issuer's own ACS page. Chain one call per level.
const acs = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.frameLocator('#challengeFrame')
.frameLocator('iframe[name="acsFrame"]');
// The issuer page loads over a real network hop; give this leg its own budget
// instead of raising the whole test timeout.
const approve = acs.getByRole('button', { name: 'Complete authentication' });
await expect(approve).toBeVisible({ timeout: 20_000 });
await approve.click();
// Control returns to your origin; assert on your own confirmation state.
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Troubleshooting variants
strict mode violation: locator('iframe') resolved to 4 elements
The provider mounted controller frames you did not expect. Stripe adds __privateStripeController and __privateStripeMetricsController hosts with zero height, Adyen adds a hidden encryption frame, and both are real iframe elements. Print the frame inventory once with page.frames().map(f => f.name() + ' ' + f.url()) in a scratch run, then write a selector that matches only the field host — a name prefix, a title, or a data-fieldtype. Resist .first(): the hidden frames often attach before the visible one, so .first() picks the wrong document and every subsequent action times out with an empty-looking frame.
locator.fill: Frame was detached in the middle of a form
The SDK remounted the element while you were typing, usually because the amount, currency or country changed and the provider recreated the payment intent. Because a FrameLocator re-resolves per action, the retry usually succeeds — but only if you are not holding a Frame or ElementHandle from before the remount. Remove any page.frame() or elementHandle usage, complete all address and total-affecting fields before touching the card frame, and if the app updates totals on blur, assert the new total is rendered before entering card data. The same detachment shows up as Execution context was destroyed, most likely because of a navigation.
The card fields fill but the Pay button never enables
The provider's SDK did not register your input. This is the fill() versus pressSequentially() distinction from step 3: a tokenizer that formats on keydown sees no keystrokes and keeps its internal validity flag false, so the merchant page never receives the change event that enables submit. Confirm by watching the provider's own inline error text — a field the SDK considers empty usually shows nothing at all rather than an error. Switch that field to pressSequentially() with a small delay, and verify with the browser console tab in the Playwright Trace Viewer, where the SDK's change events are visible.
Stubbing the provider means intercepting its script and its confirmation endpoint, then rendering a local stand-in for the field. Route handlers registered on the context apply to every page in it, which keeps the setup in one fixture rather than repeated per test. The trade-off is explicit: a stubbed run can never catch a breaking change in the provider's SDK, which is exactly why the sandbox branch of the tree still exists. Size it deliberately — one successful payment, one decline, one 3D Secure challenge is usually enough coverage to notice an upstream change, while everything else about the checkout runs offline in seconds.
import { test, expect } from '@playwright/test';
test('shows the confirmation screen without contacting the provider', async ({ page }) => {
// Replace the provider SDK with a tiny stub that renders a plain input and
// resolves tokenisation immediately. No iframe, no network, no rate limit.
await page.route('https://js.stripe.com/**', route =>
route.fulfill({
contentType: 'application/javascript',
body: 'window.Stripe = () => ({ elements: () => ({ create: () => ({ mount: () => {} }) }) });',
}),
);
// Stub your own server's confirm endpoint so the assertion is about your UI.
await page.route('**/api/payments/confirm', route =>
route.fulfill({ status: 200, json: { status: 'succeeded', id: 'pi_test_123' } }),
);
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Verification
Prove the frame targeting is real, not accidental. First, log the frame tree once — page.frames().forEach(f => console.log(f.name(), f.url())) — and confirm your selector matches exactly one host whose URL is the provider's origin; a match against your own origin means you selected a controller frame. Second, run the card test five times with npx playwright test checkout --repeat-each=5 after clearing state; the random frame-name suffix changes on each mount, so five green runs demonstrate the prefix match is not pinned to one build. Third, open the trace and inspect the action log: each frameLocator step is recorded with the frame it resolved to, and the DOM snapshot lets you confirm the input you typed into is the provider's, not a lookalike in your own markup. Finally, assert on your own confirmation state rather than the provider's UI text, which the provider can restyle at will — the accessible-name discipline from getByRole & Accessibility Selectors applies just as much inside a frame. Tag the sandbox tests and let CI report them separately so a provider outage does not read as a product regression, using the pattern in Quarantining Flaky Tests Without Blocking CI.
Frequently Asked Questions
Does Playwright need a flag to type into a cross-origin payment iframe?
No. The driver communicates with the browser over the automation protocol and addresses each frame's document directly, so the same-origin policy that blocks in-page scripts never applies. You do not need --disable-web-security, a proxy, or any provider-specific bypass — only a selector that identifies the correct frame host.
Can I record and replay the provider's traffic instead of hitting the sandbox?
Yes for read-mostly journeys. Capture the checkout once with Playwright's HAR recording, then replay it so the provider's script and static assets are served from disk; see Recording and Replaying HAR Files. Tokenisation calls carrying one-time nonces will not replay cleanly, so pair HAR replay for assets with a route stub for the confirm endpoint.
Why does the same test pass locally and time out in CI?
Two causes dominate. The provider's sandbox rate-limits shared CI egress addresses, so the SDK script or the tokenisation call returns 429 and the frame never becomes editable — check the network panel in the trace before blaming the locator. The other is contention: a headless CI worker loads the third-party script more slowly, so an action fired at frame-attach time lands before the SDK's listeners exist. Waiting for toBeEditable() fixes the second and reducing sandbox-dependent tests fixes the first.
How do I test Apple Pay or Google Pay buttons?
You cannot drive the payment sheet itself — it is browser or operating-system chrome outside the page, and no locator reaches it. Assert that the wallet button renders under the right conditions, that clicking it calls your app's handler, and stub the resulting payment-token callback. Anything past the sheet belongs in a manual or provider-supplied simulator run.