Webpages contain fields, menus, calendars, tables, and uploads. Identify each control as a user would, act, and verify the result.
Every control is a tool with a name, an action, and an observable outcome.
identify control → realistic action → verify outcome
Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com
Textbook examples — a login form with two fields and a button — are clean. Real applications are messier: dynamic tables that resize as data loads, calendars with dozens of nearly-identical date cells, dropdowns that filter as you type, modals that steal focus from the rest of the page. This part works through the elements that actually cause QA engineers real difficulty in production, using the vocabulary and locator judgment you’ve already built.
Forms, Revisited Properly
A form is really just a collection of the individual actions from Part 8, used together. The one genuinely important habit worth building here: verify the submission result, not just that you clicked submit.
await page.getByPlaceholder("Username").fill("standard_user");
await page.getByPlaceholder("Password").fill("secret_sauce");
await page.getByRole("button", { name: "Login" }).click();
// Don't stop here — clicking submit isn't the same as submitting successfully
await expect(page).toHaveURL(/inventory\.html/);
await expect(page.getByText("Products")).toBeVisible();
This directly echoes Part 9’s warning about actions without assertions — a form test that only performs the fill-and-click sequence, without asserting on the actual outcome, hasn’t verified the form works at all.
Dropdowns, Checkboxes, and Radio Buttons
Covered mechanically in Part 8 (selectOption, check, uncheck), but worth one addition here: custom dropdowns — ones built entirely out of <div>s and JavaScript rather than a real <select> element, common in modern component libraries — don’t work with selectOption() at all, since that method specifically targets native <select> elements. For a custom dropdown, you typically need to click to open it, then click the desired option as a separate, ordinary element:
await page.getByRole("button", { name: "Choose country" }).click(); // opens the custom dropdown
await page.getByRole("option", { name: "India" }).click(); // clicks the option once visible
Analogy: The Self-Serve Kiosk vs. The Store Clerk
- Native Select Dropdown (Self-Serve Kiosk): You tap a single dropdown item, the native browser immediately shows a list, you tap your selection, and it handles everything atomically. Playwright handles this with a single call to
selectOption().- Custom Dropdown (Store Clerk): There is no touch screen. You must first click a button to alert the clerk (triggering a JavaScript dropdown list rendering), wait for the clerk to present the options tray (dynamic DOM loading), and then point to/click the specific item in the tray (
getByRole('option', { name: 'X' }).click()).
Recognizing which kind of dropdown you’re dealing with — inspect it in DevTools, exactly as you learned in Part 1 — is the first real judgment call here, before deciding which approach applies.
Dynamic Tables
A “dynamic” table is one whose rows change — through sorting, filtering, pagination, or live data updates — meaning row position is exactly the kind of unstable thing Part 7 warned about relying on. Consider verifying a specific product’s row in a table, rather than “whatever’s currently in row 3”:
const row = page.getByRole("row").filter({ hasText: "Sauce Labs Backpack" });
await expect(row.getByRole("cell", { name: "$29.99" })).toBeVisible();
This is the exact .filter({ hasText: ... }) pattern from Part 7, applied to a table — find the row by its content, not its position, then assert on a specific cell within that already-uniquely-identified row.
Calendars and Date Pickers
Date pickers are a genuinely common source of frustration, mostly because their exact markup varies wildly between component libraries. The one universal piece of advice: inspect the actual date cells in DevTools first — they’re very often labeled with a full, unambiguous accessible name (like “15 January 2026”) rather than just the visible number “15,” precisely to support screen readers, which makes getByRole('button', { name: '15 January 2026' }) a far more reliable target than trying to guess at a CSS structure.
await page.getByLabel("Choose date").click(); // opens the calendar
await page.getByRole("button", { name: "15 January 2026" }).click(); // selects a specific, unambiguous day
Pagination and Infinite Scrolling
For paginated tables, the pattern is straightforward — click “Next,” then re-assert, since the DOM genuinely changes between pages:
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.getByRole("row")).toHaveCount(10); // re-verify after the page changed
Infinite scroll
is trickier, because new content loads in response to scroll position rather than a click. Playwright can trigger this by scrolling an element into view, which naturally causes the browser to scroll toward it:
await page.getByText("Last visible item").scrollIntoViewIfNeeded();
await expect(
page.getByText("New item that loads after scrolling"),
).toBeVisible();
Auto-Suggestions
Auto-suggest fields combine fill() (to type and trigger the suggestions) with waiting for the resulting dropdown to actually appear, since it loads asynchronously after typing:
await page.getByPlaceholder("Search products").fill("Back");
await expect(
page.getByRole("option", { name: "Sauce Labs Backpack" }),
).toBeVisible();
await page.getByRole("option", { name: "Sauce Labs Backpack" }).click();
Notice there’s no manual wait here at all — the await expect(...).toBeVisible() from Part 9 handles waiting for the suggestion to appear automatically, via auto-retry, exactly as designed.
Modals and Tooltips
Modals genuinely change the DOM — they’re typically rendered as new elements appended to the page — and often need to be explicitly closed before continuing, or a test’s subsequent locators may resolve ambiguously if the same text exists both in the modal and the page behind it:
await page.getByRole("button", { name: "Delete item" }).click();
await expect(page.getByRole("dialog")).toBeVisible(); // modals often carry the 'dialog' role
await page.getByRole("dialog").getByRole("button", { name: "Confirm" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
Note the chained .getByRole('dialog').getByRole('button', ...) — scoping the button search specifically within the dialog, exactly the chaining technique from Part 7, which matters here because a page might well have another “Confirm” button elsewhere that isn’t part of this modal at all.
📊 Visual Flowchart: Scoped Locator Chaining
Here is how Playwright targets elements inside modals without checking the background DOM:
graph TD
subgraph BackgroundPage ["Background Page DOM Tree"]
Btn1["button: 'Confirm' (Background Page)"]
end
subgraph ModalContainer ["Modal Container DOM Tree (dialog)"]
Btn2["button: 'Confirm' (Modal Dialog)"]
end
LocatorChain["page.getByRole('dialog').getByRole('button', {name: 'Confirm'})"] --> ScopeSearch["Restrict Search to Dialog Subtree Only"]
ScopeSearch --> Btn2
ScopeSearch -.->|Bypasses / Ignores| Btn1
Rich Text Editors
Rich text editors (like a comment box supporting bold/italic formatting) are often built on contenteditable elements rather than plain <textarea>s, which changes how you interact with them — fill() doesn’t reliably work on contenteditable regions, so you typically click into them and use keyboard input instead:
await page.locator('[contenteditable="true"]').click();
await page.keyboard.type("This is my review of the product.");
Shadow DOM
Some modern components (especially web components) render inside a Shadow DOM — a genuinely separate, encapsulated mini-DOM tree attached to an element, intentionally isolated from the page’s regular styling and structure. The reassuring news: Playwright’s locators pierce through open shadow roots automatically, by default — page.getByRole(...) and page.locator(...) generally work exactly the same way whether or not an element happens to be inside a shadow root, without any special syntax required on your part.
How It Works in a Real Test Run
Real widgets are usually combinations of DOM elements, state, and events rather than special Playwright objects. First identify the user contract—label, role, selected value, row content, or dialog state—then use the locator and assertion that represent that contract.
For custom calendars, tables, and editors, inspect how state is exposed. Prefer accessible roles and values; use implementation selectors only when the component provides no stable user-facing hook.
Interview Questions
Q: Why doesn’t selectOption() work on every dropdown-looking element on a page?
Ans: selectOption() is specifically built for native HTML <select> elements. Many modern UI components build custom dropdowns out of ordinary <div>s and JavaScript rather than a real <select>, and those need to be interacted with as ordinary clickable elements instead — clicking to open the dropdown, then clicking the desired option directly.
Q: Why is it risky to locate a table row by its position (like “row 3”) in a dynamic table?
Ans: A dynamic table’s row order can change due to sorting, filtering, pagination, or live data updates that have nothing to do with the specific row you actually care about. A locator based on position will silently start pointing at a different row the moment the order changes, without raising any error, since it’s still technically finding a valid row — just not the intended one. Locating by content, like .filter({ hasText: ... }), targets what you actually mean regardless of position.
Q: Why might fill() not work reliably on a rich text editor?
Ans: Many rich text editors are built on contenteditable elements rather than a plain <textarea> or <input>, and fill() is designed around the value-setting behavior of standard form fields. For contenteditable regions, clicking into the element and using keyboard input (page.keyboard.type(...)) is typically the more reliable approach.
Q: What is the Shadow DOM, and does it require special handling in Playwright?
Ans: The Shadow DOM is a separate, encapsulated mini-DOM tree that some components attach to themselves, intentionally isolating their internal structure and styling from the rest of the page. Generally, no special handling is required — Playwright’s locators automatically pierce through open shadow roots, meaning getByRole and locator() work the same way regardless of whether an element happens to live inside a shadow root.
Q: When testing a modal, why might it matter to scope a button locator specifically within the modal, rather than searching the whole page?
Ans: Because the same text or role might exist elsewhere on the page outside the modal — a “Confirm” button inside a delete-confirmation dialog might not be the only “Confirm” button on the page. Scoping the search specifically to within the modal’s own container, using locator chaining, avoids ambiguity and ensures the correct, intended button is the one being interacted with.
Exercises — Part 11
Understand: Explain, in your own words, why locating a table row by its content is more resilient than locating it by position, using a concrete scenario involving sorting.
Simple Practice: Find any public site with a dynamic, sortable table (many demo sites offer one). Write a Playwright test that sorts the table by a column, then locates a specific, known row by its content (not position) and asserts on one of its cell values.
Real-World Scenario: On SauceDemo, open the cart, then click “Checkout,” fill in the required fields, and click “Continue.” A confirmation-style page should appear. Write assertions verifying the correct content appears — treating this multi-step form exactly as a real checkout flow, asserting on the actual outcome at each meaningful step rather than only performing the actions.
Challenge:
Find a real site using a custom (non-native) dropdown component — inspect it in DevTools first to confirm it isn’t a plain <select>. Write a Playwright test that opens it and selects an option using role-based locators, and note in a comment what specifically in the HTML told you it wasn’t a native <select>.
Next: Part 12 — Waiting and Synchronization
— the deep, internal explanation of auto-waiting, actionability, race conditions, and exactly why “arbitrary waits” are one of the most common anti-patterns in browser automation.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed