Static pages are easy. The real challenge is dynamic elements -- lists that load from APIs, tables with pagination, elements inside shadow DOM, content inside iframes. These are the elements that make QA engineers pull their hair out. Let me show you how to handle each one.
Lists that load from an API may not be present immediately. The locator auto-waits, but you need to be smart about assertions.
import { test, expect } from '@playwright/test';
test('handle dynamic product list', async ({ page }) => {
await page.goto('/shopping');
// Wait for at least 1 product to load
await expect(page.locator('.product-card').first()).toBeVisible();
// Assert total count after API loads
await expect(page.locator('.product-card')).toHaveCount(12);
// Find a specific item in the list
const laptop = page.locator('.product-card').filter({ hasText: 'Laptop' });
await expect(laptop).toBeVisible();
// Iterate over all items and verify each has a price
const cards = page.locator('.product-card');
const count = await cards.count();
for (let i = 0; i < count; i++) {
await expect(cards.nth(i).locator('.price')).toBeVisible();
}
});import { test, expect } from '@playwright/test';
test('interact with a data table', async ({ page }) => {
await page.goto('/banking');
// Find a specific row by cell content
const johnRow = page.getByRole('row').filter({ hasText: 'John Doe' });
// Get a specific cell in that row
const balance = johnRow.getByRole('cell').nth(2);
await expect(balance).toHaveText('$5,000');
// Click a button in a specific row
await johnRow.getByRole('button', { name: 'View' }).click();
// Verify table headers
const headers = page.getByRole('columnheader');
await expect(headers).toHaveText(['Name', 'Account', 'Balance', 'Actions']);
// Count rows (excluding header)
const dataRows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(dataRows).toHaveCount(10);
});Shadow DOM creates an encapsulated DOM tree inside an element. Web components use it. In Selenium, shadow DOM is a nightmare. In Playwright, it just works -- locators pierce shadow DOM boundaries by default.
import { test, expect } from '@playwright/test';
test('interact with shadow DOM elements', async ({ page }) => {
await page.goto('/topics/shadow-dom');
// Playwright pierces shadow DOM automatically!
// No special API needed. Just use normal locators.
await page.getByRole('button', { name: 'Shadow Button' }).click();
await expect(page.getByText('Shadow content loaded')).toBeVisible();
// CSS selectors also pierce shadow DOM by default
await page.locator('.shadow-input').fill('Hello from light DOM');
});Playwright pierces shadow DOM automatically. You do not need to call element.shadowRoot or use any special API. Just write your locator as if the shadow boundary does not exist. This is a massive advantage over Selenium.
Iframes are separate documents embedded inside a page. You cannot use page.getByRole() to find elements inside an iframe. You need to get the frame object first, then use locators on the frame.
import { test, expect } from '@playwright/test';
test('interact with iframe content', async ({ page }) => {
await page.goto('/topics/iframes');
// Method 1: Get frame by name or URL
const frame = page.frameLocator('iframe[name="payment-frame"]');
// Now use locators on the frame -- same API as page
await frame.getByLabel('Card Number').fill('4111111111111111');
await frame.getByLabel('Expiry').fill('12/25');
await frame.getByRole('button', { name: 'Pay' }).click();
// Method 2: Get frame by test ID
const chatFrame = page.frameLocator('[data-testid="chat-widget"]');
await chatFrame.getByPlaceholder('Type a message').fill('Hello');
// Nested iframes -- chain frameLocator calls
const innerFrame = page
.frameLocator('#outer-frame')
.frameLocator('#inner-frame');
await innerFrame.getByRole('button', { name: 'Submit' }).click();
});page.locator() does NOT see inside iframes. You must use page.frameLocator() first. This is the #1 mistake people make with iframes in Playwright. If your locator is not finding an element, check if it is inside an iframe.
import { test, expect } from '@playwright/test';
test('wait for dynamic content', async ({ page }) => {
await page.goto('/shopping');
// Wait for element to appear (auto-retries for 5s)
await expect(page.getByText('Products loaded')).toBeVisible();
// Wait for element to disappear
await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
// Wait for specific network response
await page.getByRole('button', { name: 'Load More' }).click();
await expect(page.locator('.product-card')).toHaveCount(24);
// Custom timeout for slow operations
await expect(page.getByText('Report generated'))
.toBeVisible({ timeout: 15000 });
});Q: How do you handle iframes and shadow DOM in Playwright?
A: Shadow DOM is automatic -- Playwright locators pierce shadow DOM boundaries by default, so I use the same getByRole, getByText locators without any special handling. For iframes, I use page.frameLocator() to get a reference to the frame, then call locators on that frame reference. The API is the same as page -- frame.getByRole(), frame.getByLabel(), etc. For nested iframes, I chain frameLocator calls. The key difference: page.locator() cannot see inside iframes, you must use frameLocator().
Key Point: Shadow DOM works automatically. Iframes need frameLocator(). Dynamic lists need assertion-based waiting. Tables use row filtering with getByRole('row').filter().