6Locators & UI Testing (complete)
What you will master here
- Every Playwright locator type and the recommended priority order
- Accessible names, ARIA roles, strict mode, exact matching
- Narrowing locators: filter, chaining, nth, has, hasText
- All UI interactions: click, fill, type, select, hover, keyboard, mouse
- Multi-tab, iframes, dialogs, file upload/download, drag-drop
- Web-first assertions and the "assert on locator, not on extracted value" rule
6.1 What a Locator actually is
A Locator is not a reference to a DOM node — it is a description of how to find one. When you call page.locator('#submit'), no DOM lookup happens yet. Playwright stores the recipe ("find an element with id submit"). The lookup happens fresh every time you act on it, which is why locators never go stale.
Compare this with Selenium's WebElement, which is a handle to a found element. If the DOM re-renders, the handle becomes stale and you get StaleElementReferenceException. Playwright's design sidesteps that entire class of bug.
6.2 The locator priority order (memorize this)
Playwright's team strongly recommends choosing locators in this order:
| # | Locator | What it targets | Why it's good |
|---|---|---|---|
| 1 | getByRole | ARIA role + accessible name | Mirrors how assistive tech sees the page. Survives CSS/structure changes. |
| 2 | getByLabel | Form controls by their visible label | Maps to user intent ("the Email field"). Refactor-proof. |
| 3 | getByPlaceholder | Inputs by placeholder text | Falls back when no label exists. |
| 4 | getByText | Elements by their visible text | Best for non-interactive content (paragraphs, error messages). |
| 5 | getByAltText | Images by alt attribute | For media; also a11y-friendly. |
| 6 | getByTitle | Elements by title tooltip | Rarely needed; good for tooltipped buttons. |
| 7 | getByTestId | data-testid attribute | Last-resort, dev-controlled, completely stable. |
| 8 | locator('css') / locator('xpath=...') | Raw CSS or XPath | Escape hatch. Fragile because CSS/structure change often. |
getByRole('button', { name: 'Submit' }). If they would say "the third row of the table", combine getByRole + nth.6.3 getByRole — the most important one
HTML elements have an implicit ARIA role: a <button> has role button, a <a href> has role link, <h1> has role heading, etc. The accessible name is what a screen reader would read out — usually the visible text, or the value of aria-label / aria-labelledby / title.
// HTML: <button>Sign in</button>
await page.getByRole('button', { name: 'Sign in' }).click();
// HTML: <h2>Recent orders</h2>
await expect(page.getByRole('heading', { name: 'Recent orders' })).toBeVisible();
// HTML: <a href="/help">Help center</a>
await page.getByRole('link', { name: 'Help center' }).click();
// HTML: <input type="checkbox" aria-label="Remember me">
await page.getByRole('checkbox', { name: 'Remember me' }).check();
// Exact match (default is substring, case-insensitive)
await page.getByRole('button', { name: 'Save', exact: true }).click();
// Regex
await page.getByRole('button', { name: /^delete/i }).click();
Common roles you'll use daily
| Role | HTML |
|---|---|
| button | <button>, <input type=button|submit> |
| link | <a href> |
| textbox | <input type=text|email|password|tel|url|search>, <textarea> |
| checkbox / radio | <input type=checkbox|radio> |
| combobox | <select> |
| heading | <h1>…<h6> |
| list / listitem | <ul>, <ol> / <li> |
| row / cell / columnheader | <tr>, <td>, <th> |
| dialog | <dialog>, role="dialog" |
| img | <img alt> (no alt = role="presentation") |
6.4 getByLabel, getByPlaceholder, getByText, getByTestId
// <label>Email</label><input type="email">
await page.getByLabel('Email').fill('user@test.com');
// <input placeholder="Search products">
await page.getByPlaceholder('Search products').fill('shoes');
// Match visible text (substring, case-insensitive)
await page.getByText('Welcome back').click();
await page.getByText('Order placed', { exact: true });
await page.getByText(/total: \$\d+/i);
// Alt text on images
await page.getByAltText('Company logo').click();
// Title attribute
await page.getByTitle('Open menu').click();
// data-testid — last resort
// <div data-testid="cart-count">3</div>
await expect(page.getByTestId('cart-count')).toHaveText('3');
playwright.config.ts: use: { testIdAttribute: 'data-qa' } and your team can use data-qa instead.6.5 CSS and XPath — the escape hatches
// CSS
page.locator('button.primary');
page.locator('div.card > h3');
page.locator('input[name="email"]');
// XPath — prefix with xpath= OR start with / or (
page.locator('//button[contains(text(),"Save")]');
page.locator('xpath=//div[@class="row"][3]');
// Playwright-only CSS extensions
page.locator('button:has-text("Buy")'); // text inside
page.locator('div:has(span.badge)'); // contains another element
page.locator('button:visible'); // visible only
page.locator('text=Hello'); // text engine
page.locator(':light(button)'); // pierce light DOM
page.locator(':nth-match(button, 2)'); // 2nd match
div[2]/span[3]). Any DOM refactor breaks it. Prefer role/label locators that follow semantics, not structure.6.6 Strict mode and narrowing
Every action method (click, fill, etc.) in Playwright requires the locator to resolve to exactly one element. If it matches multiple, you get a clear error. This is strict mode and it is on by default.
// FAILS with strict mode if multiple buttons named "Edit" exist
await page.getByRole('button', { name: 'Edit' }).click();
// Error: strict mode violation: resolved to 3 elements
// 1) <button>Edit</button> (in row 1)
// 2) <button>Edit</button> (in row 2)
// 3) ...
Narrowing strategies
By position
page.getByRole('button', { name: 'Edit' }).first();
page.getByRole('button', { name: 'Edit' }).last();
page.getByRole('button', { name: 'Edit' }).nth(2); // 0-indexed
By filter
// rows containing "Alice"
page.getByRole('row').filter({ hasText: 'Alice' });
// rows NOT containing "deleted"
page.getByRole('row').filter({ hasNotText: 'deleted' });
// rows that contain a checkbox
page.getByRole('row').filter({ has: page.getByRole('checkbox') });
// rows that do NOT contain an "archived" badge
page.getByRole('row').filter({ hasNot: page.getByText('Archived') });
By chaining (best practice for tables and lists)
// Click the Delete button on the row that contains "alice@test.com"
await page
.getByRole('row', { name: /alice@test\.com/ })
.getByRole('button', { name: 'Delete' })
.click();
// Or with filter
await page
.getByRole('row').filter({ hasText: 'alice@test.com' })
.getByRole('button', { name: 'Delete' })
.click();
6.6.5 Parent / Sibling / Child relationships
Unlike Selenium, Playwright has no .parent() method. You express tree relationships through chaining, filter+has, or fall back to CSS / XPath when needed.
Child — chain or CSS
// "Find the Delete button INSIDE the row containing alice"
await page.getByRole('row', { name: /alice@test\.com/ })
.getByRole('button', { name: 'Delete' })
.click();
// CSS direct child
page.locator('ul.menu > li'); // li directly inside ul.menu
page.locator('.card .title'); // title anywhere inside card
Parent — filter by what the parent contains
// "The row that CONTAINS the Delete button"
const row = page.getByRole('row').filter({ has: page.getByRole('button', { name: 'Delete' }) });
await row.click();
// "The row that contains specific TEXT"
page.getByRole('row').filter({ hasText: 'alice@test.com' });
// XPath escape hatch for true "ancestor"
page.locator('xpath=//button[normalize-space()="Delete"]/ancestor::tr');
Sibling — CSS combinators or XPath
// CSS: + adjacent sibling, ~ general sibling
page.locator('label:has-text("Email") + input'); // input directly after label
page.locator('label:has-text("Email") ~ input'); // any input sibling after label
// XPath
page.locator('xpath=//label[normalize-space()="Email"]/following-sibling::input[1]');
page.locator('xpath=//input[@id="pw"]/preceding-sibling::label[1]');
"Closest ancestor that matches" pattern (common interview question)
// Selenium: element.find_element(By.XPATH, "./ancestor::tr")
// Playwright equivalent:
const cell = page.getByText('alice@test.com');
const row = page.getByRole('row').filter({ has: cell });
// or with XPath
const row2 = cell.locator('xpath=ancestor::tr[1]');
| Relationship | Playwright idiom (preferred) | CSS fallback | XPath fallback |
|---|---|---|---|
| Child of | parent.locator(child) (chain) | parent > child | parent/child |
| Descendant of | Chain locators | parent child (space) | parent//child |
| Parent of (containing) | .filter({ has: child }) | parent:has(child) | child/parent::* |
| Ancestor of | .filter({ has: descendant }) | ancestor:has(descendant) | child/ancestor::tag |
| Next sibling | Use CSS or XPath | prev + next | node/following-sibling::tag[1] |
| Any following sibling | Use CSS or XPath | prev ~ sibling | node/following-sibling::tag |
| Previous sibling | Use XPath | — | node/preceding-sibling::tag[1] |
6.7 Stability ranking — when to use what
| Stability | Approach | Notes |
|---|---|---|
| ★★★★★ | role + accessible name | Survives styling, restructuring, language changes if i18n keys are stable |
| ★★★★☆ | getByLabel / getByPlaceholder | Labels rarely change for working forms |
| ★★★★☆ | getByTestId | Stable if team treats data-testid as a contract |
| ★★★☆☆ | getByText | Breaks when copy changes |
| ★★☆☆☆ | CSS class / structure | Designers refactor classes; risky |
| ★☆☆☆☆ | XPath with positions | Breaks on any DOM change |
6.8 The full UI interaction toolkit
Click family
await page.getByRole('button', { name: 'Buy' }).click();
await locator.click({ button: 'right' }); // right click
await locator.click({ clickCount: 2 }); // double click (or .dblclick())
await locator.click({ modifiers: ['Shift'] }); // shift-click
await locator.click({ position: { x: 10, y: 5 }}); // click at offset
await locator.click({ force: true }); // skip actionability checks (last resort)
await locator.click({ trial: true }); // dry-run, validate actionability without clicking
Fill vs type vs press_sequentially
// fill() — clears the field and sets the value in one go. Fast. Preferred for forms.
await page.getByLabel('Email').fill('user@test.com');
// type() — types char by char (legacy alias for pressSequentially). Slower but triggers per-key events.
await page.getByLabel('Search').pressSequentially('hello', { delay: 100 });
// clear()
await page.getByLabel('Email').clear();
// press() — single key combo (does NOT type chars, fires the key)
await page.getByLabel('Search').press('Enter');
await page.keyboard.press('Control+A');
fill 95% of the time. Reach for pressSequentially only when the UI must react per keystroke (autocomplete preview, char counter test, contenteditable rich-text).Checkboxes & radios
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('checkbox', { name: 'Remember me' }).uncheck();
await page.getByRole('radio', { name: 'Express delivery' }).check();
await expect(page.getByRole('checkbox', { name: 'TOS' })).toBeChecked();
Select dropdowns (native <select>)
// by value
await page.getByLabel('Country').selectOption('IN');
// by visible label
await page.getByLabel('Country').selectOption({ label: 'India' });
// by index
await page.getByLabel('Country').selectOption({ index: 2 });
// multiple (multi-select)
await page.getByLabel('Sizes').selectOption(['S', 'M', 'L']);
<select> (which most modern UIs are not), you can't use selectOption. Click the trigger, then click the option: await page.getByRole('combobox').click(); await page.getByRole('option', { name: 'India' }).click();Hover, focus, blur
await page.getByText('Account').hover(); // triggers any hover menu
await page.getByLabel('Email').focus();
await page.getByLabel('Email').blur();
Keyboard & mouse — low level
// Keyboard (no element focus needed, fires at window level)
await page.keyboard.press('Escape');
await page.keyboard.down('Shift');
await page.keyboard.up('Shift');
await page.keyboard.type('hello, world'); // types chars
// Mouse (page coordinates)
await page.mouse.move(100, 200);
await page.mouse.down();
await page.mouse.move(300, 400, { steps: 10 }); // 10 intermediate moves = smooth drag
await page.mouse.up();
await page.mouse.wheel(0, 500); // scroll down 500 px
Drag and drop
// High-level (HTML5 drag-and-drop)
await page.locator('#source').dragTo(page.locator('#target'));
// Manual — for canvas/SVG or non-HTML5 DnD
const src = await page.locator('#source').boundingBox();
const dst = await page.locator('#target').boundingBox();
await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2);
await page.mouse.down();
await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height / 2, { steps: 20 });
await page.mouse.up();
Screenshots
await page.screenshot({ path: 'full.png', fullPage: true });
await page.locator('.invoice').screenshot({ path: 'invoice.png' });
// Visual regression
await expect(page).toHaveScreenshot('home.png'); // full page
await expect(page.locator('.invoice')).toHaveScreenshot('inv.png'); // element
// First run creates baseline. Later runs diff vs baseline.
6.9 Multiple tabs and windows (promise-first pattern)
When a click opens a new tab, Playwright emits a page event on the context. Two important rules:
- Subscribe to the event before you trigger it — otherwise you miss it.
- Use
Promise.allto start listening and acting in one atomic step.
test('opens terms in a new tab', async ({ context, page }) => {
await page.goto('/signup');
const [newPage] = await Promise.all([
context.waitForEvent('page'), // start listening first
page.getByRole('link', { name: 'Terms' }).click(), // then trigger
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/terms/);
await expect(newPage.getByRole('heading')).toHaveText('Terms of Service');
await newPage.close();
});
await page.click('a') first and then await context.waitForEvent('page'). The new page may have already opened and the event has fired. Always Promise.all the two together.6.10 iframes
// Scope locators to inside an iframe
const stripe = page.frameLocator('iframe[name="card-frame"]');
await stripe.getByPlaceholder('Card number').fill('4242 4242 4242 4242');
await stripe.getByPlaceholder('MM / YY').fill('12 / 30');
await stripe.getByPlaceholder('CVC').fill('123');
// Nested iframes
const inner = page.frameLocator('#outer').frameLocator('#inner');
await inner.getByRole('button', { name: 'Pay' }).click();
// All frames on the page
for (const frame of page.frames()) console.log(frame.url());
6.11 Dialogs (alert / confirm / prompt)
// Subscribe BEFORE the action that triggers the dialog.
page.on('dialog', async dialog => {
console.log(dialog.type(), dialog.message());
if (dialog.type() === 'prompt') {
await dialog.accept('the answer');
} else if (dialog.type() === 'confirm') {
await dialog.accept();
} else {
await dialog.dismiss();
}
});
await page.getByRole('button', { name: 'Delete account' }).click();
page.once('dialog', d => d.accept()).6.12 File upload
// Standard <input type="file"> — fastest path
await page.getByLabel('Upload CV').setInputFiles('fixtures/cv.pdf');
// Multiple files
await page.getByLabel('Photos').setInputFiles(['a.jpg', 'b.jpg']);
// Clear the selection
await page.getByLabel('Upload CV').setInputFiles([]);
// Buffer instead of disk file
await page.getByLabel('Upload').setInputFiles({
name: 'invoice.csv',
mimeType: 'text/csv',
buffer: Buffer.from('id,total\n1,99.00\n')
});
// Custom button that opens a file chooser (no <input> in markup)
const [chooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Add file' }).click(),
]);
await chooser.setFiles('fixtures/cv.pdf');
6.13 File download
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export CSV' }).click(),
]);
console.log('suggested name:', download.suggestedFilename());
await download.saveAs('./downloads/export.csv');
// or read the content stream directly:
const stream = await download.createReadStream();
6.14 Web-first assertions and the golden rule
Playwright's expect works on a locator, and it auto-retries the assertion until it passes or the timeout expires. This is what makes flaky tests rare.
The golden rule
await expect(locator).toHaveText('Hello') retries on every poll.expect(await locator.textContent()).toBe('Hello') reads once, then asserts — and flakes if the text appears 1 ms later.The full assertion menu
// Visibility / DOM
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();
await expect(locator).toHaveCount(3);
// Enabled / state
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeEditable();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toBeEmpty();
// Text
await expect(locator).toHaveText('Hello'); // exact
await expect(locator).toHaveText(/Hel/); // regex
await expect(locator).toContainText('ell');
await expect(locator).toContainText(['A','B','C']); // for list of items
// Attribute / value
await expect(locator).toHaveAttribute('href', '/about');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveValue('user@test.com');
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(locator).toHaveJSProperty('checked', true);
// Page-level
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/dashboard/);
// Response
await expect(response).toBeOK();
// Negation
await expect(locator).not.toBeVisible();
// Custom timeout (overrides default)
await expect(locator).toBeVisible({ timeout: 10_000 });
// Soft assertions — collect failures, don't stop
await expect.soft(locator).toHaveText('A');
await expect.soft(locator2).toBeVisible();
// Test fails at the end if any soft assertion failed.
6.15 Actionability deep-dive
Before every action, Playwright checks the conditions in the table. You don't write these — they're automatic. But knowing them helps you debug "Why is my click failing?"
| Action | Attached | Visible | Stable | Receives events | Enabled | Editable |
|---|---|---|---|---|---|---|
| click, dblclick, tap | ✓ | ✓ | ✓ | ✓ | ✓ | |
| fill, type, clear | ✓ | ✓ | ✓ | ✓ | ✓ | |
| check, uncheck | ✓ | ✓ | ✓ | ✓ | ✓ | |
| hover | ✓ | ✓ | ✓ | ✓ | ||
| selectOption | ✓ | ✓ | ✓ | ✓ | ||
| focus, scrollIntoViewIfNeeded | ✓ |
Module 2 — Interview Q&A bank
What is a Locator and why is it different from a Selenium WebElement?
StaleElementReferenceException. Selenium's WebElement is a handle to a found node; if the DOM re-renders, you must re-find it.What is the recommended locator priority order?
What is an accessible name?
aria-label / aria-labelledby / a linked <label> / title. getByRole('button', { name: 'Save' }) matches that name.What is strict mode and why is it the default?
How do you handle "found multiple elements" errors without using nth?
.filter({ hasText: 'Alice' }), .filter({ has: page.getByRole('checkbox') }), or chain locators: page.getByRole('row', { name: /alice/i }).getByRole('button', { name: 'Delete' }). Use .nth(n) only when the position is the actual semantic (e.g. "the first row").Difference between fill, type and pressSequentially?
fill clears and sets the value in one operation — fastest, preferred. pressSequentially (alias type) types char by char and fires per-key events — needed for autocomplete tests or contenteditable. press sends a single key combo and does not type characters (e.g. press('Enter')).How do you select an option from a non-native dropdown (e.g. React Select)?
selectOption — that's only for native <select>. Click the trigger, then click the option by role: await page.getByRole('combobox').click(); await page.getByRole('option', { name: 'India' }).click();Why is "promise-first" mandatory for new pages and downloads?
Promise.all([context.waitForEvent('page'), page.click(...)]) guarantees the listener is attached before the trigger runs. Doing them sequentially can miss the event entirely.How do you handle a JS alert/confirm/prompt?
page.on('dialog', d => d.accept()) handler before the action that opens it. You can read d.type() and d.message(), then call d.accept(value?) or d.dismiss(). If the dialog only fires once, use page.once.How do you interact with an iframe?
page.frameLocator(selector) to scope inside an iframe and continue chaining locators normally: page.frameLocator('iframe[name=card]').getByPlaceholder('CVC').fill('123'). For nested iframes, chain frameLocators.How do you upload a file to a button that doesn't expose an input?
const [chooser] = await Promise.all([page.waitForEvent('filechooser'), page.getByRole('button', { name: 'Add file' }).click()]); await chooser.setFiles('file.pdf');How do you handle file downloads?
page.waitForEvent('download'); the resulting download object exposes suggestedFilename(), saveAs(path), and createReadStream().Explain the "assert on locator not extracted value" rule.
await expect(locator).toHaveText('Hi') auto-retries the lookup + comparison every ~50 ms until it passes or times out. expect(await locator.textContent()).toBe('Hi') snapshots the text once and asserts; if it became 'Hi' a millisecond later, the test flakes. Web-first assertions remove that race.What's the difference between toBeVisible and toBeAttached?
toBeAttached = the element is in the DOM (may be hidden). toBeVisible = in DOM AND rendered (non-zero size, not display:none, not visibility:hidden). For "wait until popup appears", use toBeVisible. For "the row is created in the DOM tree", toBeAttached.What does force: true do, and when is it correct to use?
What's the difference between page.locator and page.$ (deprecated)?
page.locator returns a re-queried, retriable description; page.$ returned a one-time ElementHandle that goes stale. ElementHandle still exists for rare cases (passing DOM nodes into evaluate), but locator is the modern API.How do you wait for an element to become enabled?
await expect(locator).toBeEnabled(). Or, since action methods auto-wait for enabled as part of actionability, often you just call .click() directly and the wait is implicit.How do you scroll an element into view?
await locator.scrollIntoViewIfNeeded(). For arbitrary scrolling: await page.mouse.wheel(0, 500) or await page.evaluate(() => window.scrollBy(0, 500)).What's a soft assertion and when is it useful?
expect.soft(...) records the failure but lets the test continue. At end of test, if any soft assertion failed, the test fails. Useful for "I want to check 5 things on a page and see all the failures at once", instead of fixing one, re-running, fixing the next.How do you target the 3rd row of a table that contains a specific user?
page.getByRole('row').filter({ hasText: 'alice@test.com' }). If you genuinely need positional (e.g. "header row"), then nth(0) is fine.How would you click a button that only appears after hovering on a menu?
await page.getByRole('menu', { name: 'Account' }).hover(); await page.getByRole('menuitem', { name: 'Sign out' }).click();How do you assert that a list contains items in a specific order?
await expect(page.getByRole('listitem')).toHaveText(['Apples', 'Bananas', 'Cherries']) — passing an array compares all elements and the order. toContainText([...]) is the substring variant.How do you handle a dropdown that fetches options over the network?
await page.getByRole('combobox').click(); await expect(page.getByRole('option', { name: 'India' })).toBeVisible(); await page.getByRole('option', { name: 'India' }).click();If a test sometimes passes and sometimes fails on the same element, what's the typical cause?
page.waitForTimeout instead of a web-first assertion; (2) you asserted on an extracted value instead of a locator; (3) animations are still running and the element isn't stable; (4) the locator is ambiguous and matches a different element on slow runs; (5) network call hasn't resolved. Fix the wait/assertion, not by adding sleeps.