Locators & UI Testing

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:

#LocatorWhat it targetsWhy it's good
1getByRoleARIA role + accessible nameMirrors how assistive tech sees the page. Survives CSS/structure changes.
2getByLabelForm controls by their visible labelMaps to user intent ("the Email field"). Refactor-proof.
3getByPlaceholderInputs by placeholder textFalls back when no label exists.
4getByTextElements by their visible textBest for non-interactive content (paragraphs, error messages).
5getByAltTextImages by alt attributeFor media; also a11y-friendly.
6getByTitleElements by title tooltipRarely needed; good for tooltipped buttons.
7getByTestIddata-testid attributeLast-resort, dev-controlled, completely stable.
8locator('css') / locator('xpath=...')Raw CSS or XPathEscape hatch. Fragile because CSS/structure change often.
Rule of thumbAsk "how would a user describe this element?" — if they would say "the Submit button", use 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

RoleHTML
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');
Custom test-id attributeConfigure once in 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
Why XPath is fragileXPath usually encodes the document structure (e.g. 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();
Why narrowing beats indexingnth(3) breaks the moment someone inserts a row. Filtering by content stays correct.

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]');
RelationshipPlaywright idiom (preferred)CSS fallbackXPath fallback
Child ofparent.locator(child) (chain)parent > childparent/child
Descendant ofChain locatorsparent 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 siblingUse CSS or XPathprev + nextnode/following-sibling::tag[1]
Any following siblingUse CSS or XPathprev ~ siblingnode/following-sibling::tag
Previous siblingUse XPathnode/preceding-sibling::tag[1]
Rule of thumbIf both options exist, prefer chaining + filter over CSS/XPath axes. The chain locator stays readable and Playwright re-resolves each segment independently — more robust to mid-page changes.

6.7 Stability ranking — when to use what

StabilityApproachNotes
★★★★★role + accessible nameSurvives styling, restructuring, language changes if i18n keys are stable
★★★★☆getByLabel / getByPlaceholderLabels rarely change for working forms
★★★★☆getByTestIdStable if team treats data-testid as a contract
★★★☆☆getByTextBreaks when copy changes
★★☆☆☆CSS class / structureDesigners refactor classes; risky
★☆☆☆☆XPath with positionsBreaks 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');
Choosing fill vs typeUse 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']);
Custom dropdownsIf it's not a real <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:

  1. Subscribe to the event before you trigger it — otherwise you miss it.
  2. Use Promise.all to 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();
});
Bug magnetDon't do 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();
One-time handlerIf a dialog only fires once: 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
Assert on the locator, not on a value you extracted. 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?"

ActionAttachedVisibleStableReceives eventsEnabledEditable
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?
A Locator is a description of how to find an element, not a reference to it. The DOM is re-queried every time you act, so locators never go stale — they sidestep 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?
getByRole → getByLabel → getByPlaceholder → getByText → getByAltText → getByTitle → getByTestId → raw CSS/XPath. Higher priority = closer to how a user describes the element = more stable across refactors.
What is an accessible name?
The string a screen reader would announce for an element. Usually the visible text inside the element, or the value of 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?
Strict mode requires action-method locators to resolve to exactly one element. If they match many, Playwright throws a clear error listing the matches. Selenium silently picks the first match, which silently masks ambiguity bugs — strict mode forces you to be specific.
How do you handle "found multiple elements" errors without using nth?
Narrow by content (preferred) or by structure. Examples: .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)?
You can't use 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?
Because the event you're waiting for can fire before you start listening. 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?
Register a 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?
Use 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?
Use the filechooser event: 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?
Promise.all the click with 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?
It skips actionability checks — Playwright clicks the element without verifying it's visible/stable/enabled. Use it as a last resort when actionability is wrong (e.g. a custom overlay that hit-tests fail on but the click would still work). Prefer fixing the locator first.
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?
Use a web-first assertion: 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?
You usually don't need to — action methods scroll automatically. To do it manually: 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?
Don't use nth(2) — it breaks when rows reorder. Use: 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?
Hover the parent, then click the now-visible child: 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?
Open the dropdown, then await a web-first assertion that an expected option is present (which retries until network resolves): 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?
Almost always one of: (1) you used 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.