iframes are everywhere in modern web applications, yet they're one of the trickiest parts of end-to-end testing. When your test encounters an embedded payment form, a third-party widget, or sandboxed content, a naive selector strategy falls apart immediately. Testing iframes: what every Playwright developer should know is essential knowledge because it bridges the gap between testing the main document and navigating isolated document trees—and Playwright handles both with elegant API support.
Understanding iframes in the browser automation context
An iframe creates a separate browsing context within the parent page. From a testing perspective, this means your locators can't simply traverse the boundary between parent and child documents. The iframe's internal DOM is isolated, which is intentional for security and encapsulation. However, it also means your page object models and selector strategies need to account for this boundary.
Many teams encounter iframe testing challenges in production scenarios: Stripe and Adyen embed payment collection in iframes to maintain PCI compliance, chat widgets run in sandboxed frames, and third-party analytics tools operate in isolated contexts. When you write a test that needs to interact with content inside an iframe, you're actually working with a separate Window object and a separate DOM tree.
Playwright abstracts this complexity beautifully, but you need to understand the mechanics. A naive approach—trying to select an input inside an iframe from the parent context—will fail silently or throw a timeout error. The solution is using frameLocator, which is designed precisely for this use case.
frameLocator to target iframes
frameLocator is Playwright's primary tool for targeting content inside iframes. Instead of trying to punch through the iframe boundary with a CSS selector, you explicitly navigate into the frame first, then select elements within it.
The API is straightforward:
const iframeLocator = page.frameLocator('iframe[title="Payment form"]');
const submitButton = iframeLocator.locator('button[type="submit"]');
await submitButton.click();
This tells Playwright: first find the iframe matching the selector, then return a frame context. From that context, you can select elements using the same familiar locator syntax. The frame context handles the document boundary automatically.
You can also chain multiple levels of iframes, though this is rare:
const level1 = page.frameLocator('iframe.outer');
const level2 = level1.frameLocator('iframe.inner');
const input = level2.locator('input[name="card"]');
await input.fill('4242424242424242');
In practice, most applications use a single iframe per interaction. Nesting is uncommon because it creates poor user experience and maintenance headaches.
The selector you pass to frameLocator can be any valid Playwright locator: CSS selector, XPath, test id, or role-based. We recommend using test ids or role-based selectors for iframes just as you would elsewhere, because they're more resilient to DOM changes.
// Good: explicit test id
const iframe = page.frameLocator('[data-testid="payment-iframe"]');
// Acceptable: semantic attribute
const iframe = page.frameLocator('iframe[title="Stripe payment form"]');
// Fragile: relying on position or generic attributes
const iframe = page.frameLocator('iframe'); // Don't do this if you have multiple iframes
Payment forms often use iframes
Payment processors embrace iframes because they provide a security boundary between merchant code and sensitive payment data. Stripe Elements, Adyen, Square, and similar services render their input fields inside iframes so the parent page never has access to raw card data. This is PCI DSS compliance in action.
From a testing perspective, this means your checkout flow test must handle the iframe boundary explicitly. Let's walk through a realistic scenario:
import { test, expect } from '@playwright/test';
test('complete checkout with Stripe payment', async ({ page }) => {
await page.goto('/checkout');
await page.locator('input[name="email"]').fill('test@example.com');
await page.locator('input[name="full_name"]').fill('Jane Doe');
// Stripe renders card input inside an iframe
const stripeFrame = page.frameLocator('iframe[title="Embedded payment form"]');
// Fill card number in iframe context
await stripeFrame.locator('input[placeholder*="card number"]').fill('4242424242424242');
await stripeFrame.locator('input[placeholder*="MM"]').fill('12');
await stripeFrame.locator('input[placeholder*="YY"]').fill('25');
await stripeFrame.locator('input[placeholder*="CVC"]').fill('123');
// Submit button is in parent, not iframe
await page.locator('button:has-text("Complete Purchase")').click();
// Assert payment success
await expect(page).toHaveURL('/order-confirmation');
});
Notice the structure: fill form fields in the parent context, then switch to iframe context for payment details, then return to parent for submission and verification. This mirrors the actual user flow.
A common gotcha: payment forms often use multiple iframes. Adyen, for example, might have separate iframes for card number, expiry, and CVC. You'd need to target each one individually:
const cardNumberFrame = page.frameLocator('[data-testid="card-number-frame"]');
const expiryFrame = page.frameLocator('[data-testid="expiry-frame"]');
const cvcFrame = page.frameLocator('[data-testid="cvc-frame"]');
await cardNumberFrame.locator('input').fill('4242424242424242');
await expiryFrame.locator('input').fill('1225');
await cvcFrame.locator('input').fill('123');
Locators work the same inside
Once you've targeted an iframe with frameLocator, the locator API you use inside the frame is identical to the parent context. This consistency is intentional and powerful. You don't learn a separate API for iframe content.
All standard locator methods work:
const frame = page.frameLocator('[data-testid="widget"]');
// fill() and type() for inputs
await frame.locator('input[type="email"]').fill('user@example.com');
await frame.locator('textarea').type('User comment');
// click() for buttons and interactive elements
await frame.locator('button').click();
await frame.locator('a:has-text("Learn More")').click();
// getAttribute() and textContent() for assertions
const label = await frame.locator('label').textContent();
expect(label).toContain('Required');
const href = await frame.locator('a').getAttribute('href');
expect(href).toBe('/docs');
// all() for multiple matches
const rows = await frame.locator('tr').all();
expect(rows).toHaveLength(5);
// count() for quick counts
const itemCount = await frame.locator('li').count();
expect(itemCount).toBeGreaterThan(0);
Filtering and chaining work identically:
const frame = page.frameLocator('iframe');
// Chaining locators
await frame.locator('form').locator('input[required]').fill('value');
// Filter by text
await frame.locator('button').filter({ hasText: 'Submit' }).click();
// Role-based locators
await frame.locator('role=button[name="Accept"]').click();
This consistency extends to waits and assertions:
const frame = page.frameLocator('iframe');
// Wait for element to be visible
await frame.locator('.success-message').waitFor({ state: 'visible' });
// Assert visibility
await expect(frame.locator('input[type="submit"]')).toBeEnabled();
await expect(frame.locator('.error')).not.toBeVisible();
// Wait for condition
await frame.locator('input').waitFor({ state: 'visible' });
Cross-origin frames are supported
Playwright's iframe support extends even to cross-origin frames—iframes that load content from a different domain. This is powerful for testing integrations where third-party services embed their own content.
From a browser perspective, cross-origin iframes are more restricted than same-origin ones due to the Same-Origin Policy. However, Playwright handles this transparently. If you have access to automate the page, Playwright can interact with cross-origin frame content.
A practical example: your application embeds a map widget from a third-party service at a different domain, or a customer support chat from another provider. Your test can still interact with that content:
test('interact with cross-origin embedded widget', async ({ page }) => {
await page.goto('https://example.com/page-with-widget');
// Widget loads from different domain (e.g., https://widget.provider.com)
const widgetFrame = page.frameLocator('iframe[src*="widget.provider.com"]');
// Interact with it the same way
await widgetFrame.locator('button:has-text("Open")').click();
await widgetFrame.locator('input').fill('search term');
await widgetFrame.locator('button:has-text("Search")').click();
// Wait for results
await expect(widgetFrame.locator('.results')).toBeVisible();
});
Cross-origin frame testing is most robust when you control both domains—meaning you can set up test data and clear state on both sides. When you're testing a third-party widget you don't control, be prepared for variability: the widget might load slowly, its DOM might change without notice, and your selectors may become fragile. Use generous timeouts and defensive assertions.
One limitation: you can't directly access the iframe's JavaScript context across origin boundaries. You can interact with UI elements, but page.evaluate() doesn't reach into cross-origin frames. For same-origin frames, you have more control:
// Same-origin only
const frame = page.frameLocator('iframe'); // same domain as parent
const result = await frame.evaluate(() => {
return window.customFunction();
});
Common mistakes
Forgetting the frameLocator step: Trying to select iframe content directly from page won't work. Always use frameLocator first.
// Wrong
await page.locator('iframe input').fill('value');
// Correct
await page.frameLocator('iframe').locator('input').fill('value');
Incorrect iframe selectors: If your frameLocator selector doesn't match the iframe on the page, you'll get timeout errors. Debug with page.frameLocator('iframe').count() or inspect the page HTML to verify the iframe's attributes.
Selecting the iframe element itself: Remember that frameLocator returns a frame context, not a locator for the iframe DOM element. If you need the iframe element itself (to check attributes), use a regular locator.
// Get the iframe element from parent context
const iframeElement = page.locator('iframe[title="Payment form"]');
// Get frame context to interact with content inside
const frameContext = page.frameLocator('iframe[title="Payment form"]');
Assuming all iframes are the same: Payment processors and widget providers frequently update their iframes' internal structure. Test ids and semantic attributes drift. Build resilience into your selectors and review them regularly.
Not waiting for iframe to load: iframes load asynchronously. If you're on a slow connection or the page has performance issues, the iframe might not be ready when your test tries to interact with it. Use waitFor() to be explicit:
const frame = page.frameLocator('iframe[data-testid="payment"]');
await frame.locator('input').first().waitFor({ state: 'visible' });
await frame.locator('input').first().fill('4242424242424242');
Key takeaways
frameLocatoris the gateway to iframe content; use it before selecting elements inside frames- All standard Playwright locator methods (fill, click, getAttribute, expect) work identically inside frame contexts
- Payment forms and third-party widgets typically use iframes for security isolation; expect them in e-commerce and SaaS applications
- Cross-origin frames are fully supported; Playwright abstracts the Same-Origin Policy complexity away
- Use test ids or role-based selectors for iframes to maintain resilience against DOM changes
- Frame content loads asynchronously; use waitFor() to ensure elements are ready before interaction
- Chain frameLocator calls for nested iframes, though this is rare in practice
- Review iframe selectors regularly because third-party providers update their implementations frequently






Comments
Loading comments…