How to Handle iFrames in Selenium and Playwright
Practice iframe automation — switching into frames by testid, name, and title; interacting with nested iframes; waiting for dynamic iframe content; and handling form validation inside frames in Playwright, Selenium & Cypress.
Interactive Scenarios
Each iframe must be located differently. Frame 1 has a testid; Frame 2 has only a name; Frame 3 has only a title.
iframe[title="Inner Frame"]2 levels deepContent loads after 1.5 s. Use expect(frame.getByRole('button')).toBeVisible({ timeout: 5000 }) — don't use fixed waits.
Submit with empty fields to trigger errors. Error messages use role="alert" — locate without testid via role, XPath following-sibling, or partial text.
| ID | Scenario | Type | Priority | |
|---|---|---|---|---|
| IFR_001 | Switch into iframe by data-testid and fill a text input Expected: Input inside the iframe is filled; result text confirms submission. ✅ positivehigh | positive | high | |
| IFR_002 | Switch into iframe by title attribute Expected: Same form interacted with; frameLocator by title resolves correctly. ✅ positivemedium | positive | medium | |
| IFR_003 | Switch into iframe by name attribute Expected: Same form interacted with; frameLocator by name resolves correctly. ✅ positivemedium | positive | medium | |
| IFR_004 | Select a dropdown option inside an iframe Expected: Dropdown reflects chosen value; save button is enabled. ✅ positivehigh | positive | high | |
| IFR_005 | Check a checkbox inside an iframe using getByLabel Expected: Checkbox is in checked state after interaction. ✅ positivehigh | positive | high | |
| IFR_006 | Submit form inside iframe and assert result text Expected: Result text inside iframe confirms the saved preferences. ✅ positivehigh | positive | high | |
| IFR_007 | Locate iframe by name attribute and click button inside (no testid) Expected: Button inside Frame Two clicked; result text confirms. ✅ positivehigh | positive | high | |
| IFR_008 | Locate iframe by title and click button by visible text Expected: Button inside Frame Three clicked by text locator. ✅ positivehigh | positive | high | |
| IFR_009 | Interact with a 2-level nested iframe Expected: Inner iframe input filled and submitted; result propagates to page. ✅ positivehigh | positive | high | |
| IFR_010 | Switch to parent frame after interacting with nested iframe (Selenium) Expected: After switchTo().parentFrame(), back in outer frame context. ✅ positivemedium | positive | medium | |
| IFR_011 | Wait for dynamically loaded content inside an iframe Expected: Button appears after delay; interaction succeeds without timeout error. ✅ positivehigh | positive | high | |
| IFR_012 | Locate dynamic iframe input by aria-label (no data-testid) Expected: Input found and filled using role + aria-label locator. ✅ positivemedium | positive | medium | |
| IFR_013 | Submit invalid form inside iframe and assert error messages Expected: Error spans appear; they have no data-testid — located via role or XPath. ❌ negativehigh | negative | high | |
| IFR_014 | Locate optional field inside iframe by label only (no testid) Expected: Company field filled using getByLabel — no data-testid on that field. ✅ positivemedium | positive | medium |
An iframe (inline frame) is an HTML element that embeds another HTML document inside the current page. For automation testers, iframes require switching the driver or locator context before any interaction. Each framework handles this differently: Playwright uses frameLocator() which returns a scoped locator, Selenium uses driver.switchTo().frame() which changes the active context globally, and Cypress relies on plugins or low-level contentDocument access because its standard commands do not cross frame boundaries.
Playwright's frameLocator() accepts any CSS selector that identifies the iframe element — usually a data-testid, name attribute selector, or title attribute selector. It returns a FrameLocator that behaves like a Page: you can call getByRole, getByLabel, getByTestId, and locator() on it. Multiple frameLocator calls can be chained. The frame must be loaded before interactions will succeed.
// Switch into an iframe by data-testid
const frame = page.frameLocator('[data-testid="iframe-basic"]');
// Fill an input inside the frame
await frame.getByTestId('iframe-name-input').fill('Jane');
// Click a button inside the frame
await frame.getByTestId('iframe-submit-btn').click();
// Assert text inside the frame
await expect(frame.locator('#result')).toContainText('Hello, Jane');
// Locate by role inside the frame
await frame.getByRole('textbox', { name: 'Your name' }).fill('Jane');
await frame.getByRole('button', { name: 'Submit' }).click();Nested iframes require chaining frameLocator calls, one per level. Each call scopes the context into the next level of nesting. Selenium requires calling switchTo().frame() once per level in sequence. After finishing work inside a nested frame, call switchTo().parentFrame() to move up one level or switchTo().defaultContent() to return to the top-level page. Playwright's chained frameLocator handles this automatically — no manual parent switching needed.
// Two-level nested iframes — chain frameLocator calls
const outerFrame = page.frameLocator('[data-testid="iframe-outer"]');
const innerFrame = outerFrame.frameLocator('iframe[title="Inner Frame"]');
// Interact with the innermost content
await innerFrame.getByTestId('iframe-inner-input').fill('secret');
await innerFrame.getByTestId('iframe-inner-submit').click();
// Assert inner result text
await expect(innerFrame.locator('#inner-result')).toContainText('Unlocked');
// Three levels: just add another .frameLocator() to the chain
// const deepFrame = page.frameLocator('…').frameLocator('…').frameLocator('…');When an iframe does not have a data-testid, use the name or title attributes as selectors: page.frameLocator('iframe[name="frame-two"]') or page.frameLocator('iframe[title="Form Iframe"]'). In Selenium, pass the name string directly to driver.switchTo().frame(name) or pass the integer index to switchTo().frame(index). Always prefer name or title over index — index is brittle when page order changes.
// Locate iframe by name attribute
const frameByName = page.frameLocator('iframe[name="frame-two"]');
// Button inside has no data-testid — use role + aria-label
await frameByName.getByRole('button', { name: 'Activate Frame Two' }).click();
// Locate iframe by title attribute
const frameByTitle = page.frameLocator('iframe[title="Frame Three"]');
// Button has no testid or aria-label — use visible text
await frameByTitle.getByRole('button', { name: 'Confirm Action' }).click();
// Locate using the page.frame() API (returns a Frame, not FrameLocator)
const frameRef = page.frame({ name: 'frame-two' });
if (frameRef) {
await frameRef.getByRole('button', { name: 'Activate Frame Two' }).click();
}
// Locate by URL pattern
const frameByUrl = page.frame({ url: /iframe-multi-2/ });Dynamic content inside an iframe loads asynchronously. Use Playwright's expect() with a timeout to wait for the element before interacting: await expect(frame.getByRole('button')).toBeVisible({ timeout: 5000 }). In Selenium, use WebDriverWait with a nested ExpectedCondition that scopes inside the frame context. Avoid hard sleeps — they make tests slow and unreliable.
// Switch into the dynamic iframe
const dynFrame = page.frameLocator('[data-testid="iframe-dynamic"]');
// Wait for the dynamically revealed button (no data-testid)
const revealBtn = dynFrame.getByRole('button', { name: 'Reveal Secret' });
await expect(revealBtn).toBeVisible({ timeout: 5000 });
// Fill input (located by aria-label — no testid)
await dynFrame.getByRole('textbox', { name: 'Reveal code input' }).fill('qaplayer');
// Click the reveal button
await revealBtn.click();
// Assert result (by text content, no testid)
await expect(dynFrame.locator('#dyn-result')).toContainText('Secret revealed');
// XPath alternative for the button
// await dynFrame.locator("//button[@aria-label='Reveal Secret']").click();| Action | Selenium | Playwright JS | Playwright PY | Cypress |
|---|---|---|---|---|
| Switch by element | switchTo().frame(WebElement) | page.frameLocator('[data-testid]') | page.frame_locator('[data-testid]') | .its('0.contentDocument.body') |
| Switch by name | switchTo().frame('name') | frameLocator('iframe[name="…"]') | frame_locator('iframe[name="…"]') | cy.get('iframe[name="…"]').its(…) |
| Switch by title | switchTo().frame(By.cssSelector('iframe[title]')) | frameLocator('iframe[title="…"]') | frame_locator('iframe[title="…"]') | cy.get('iframe[title="…"]').its(…) |
| Nested frames | switchTo().frame() × N levels | frameLocator().frameLocator() | frame_locator().frame_locator() | chain .its('contentDocument') × N |
| Return to parent | switchTo().parentFrame() / defaultContent() | N/A — frameLocator is stateless | N/A — frame_locator is stateless | N/A — callback scope handles it |