SoftwareTestPilot
Module 02 · Lesson 1beginner 12 min read Playwright

Playwright Locators — getByRole, getByLabel & getByText

The single most important skill in Playwright. Learn the priority order senior SDETs use, how to chain and filter locators, and how to fix strict-mode violations without brittle CSS.

Quick answer

A Locator is a lazy reference to element(s) on the page. It auto-waits, auto-retries, and is strict by default. The five modern locators are getByRole, getByLabel, getByPlaceholder, getByText, and getByTestId. Use them in that order.

1. The locator priority order

PriorityLocatorUse for
1getByRole('button', { name })Anything with a semantic role — buttons, links, headings
2getByLabel('Email')Form inputs paired with a <label>
3getByPlaceholder('Search…')Inputs with placeholder-only labels
4getByText('Sign in')Unique visible text, banners, error messages
5getByTestId('cart-badge')Non-semantic elements or noisy text — last resort
locator('css=…') / xpathThird-party widgets only. Avoid otherwise.
Rule of thumb
If a screen reader can find it, getByRole can find it too — and your test doubles as an accessibility check.

2. getByRole — the workhorse

// Buttons — the name is the visible/accessible label
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('button', { name: /save|submit/i }).click();

// Links — even if wrapped in weird markup
await page.getByRole('link', { name: 'Pricing' }).click();

// Headings & landmarks
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await page.getByRole('navigation').getByRole('link', { name: 'Docs' }).click();

// Checkboxes & radios
await page.getByRole('checkbox', { name: 'I agree' }).check();
await page.getByRole('radio', { name: 'Standard shipping' }).check();

// Rows & cells inside a table
const row = page.getByRole('row', { name: /^Alice/ });
await expect(row.getByRole('cell').nth(2)).toHaveText('Admin');

Options you'll actually use

  • { name: "Save" } — exact or regex accessible name.
  • { exact: true } — disable substring/case matching.
  • { level: 2 } — headings only (h1, h2…).
  • { checked: true } — filter by state.
  • { pressed: true } — toggle buttons.

3. Form locators — label wins over placeholder

// Preferred: matches <label for="email">Email</label>
await page.getByLabel('Email').fill('a@b.co');

// Placeholder-only inputs
await page.getByPlaceholder('Search products…').fill('lens');

// Alt text for images
await page.getByAltText('Company logo').click();

// Title attribute (tooltips)
await page.getByTitle('Delete row').click();

4. getByText & filter

// Exact or substring visible text
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('Payment successful', { exact: true })).toBeVisible();

// Regex — dynamic text
await expect(page.getByText(/order #\d{6}/)).toBeVisible();

// Filter matching elements
const rows = page.getByRole('row');
await rows.filter({ hasText: 'Alice' }).getByRole('button', { name: 'Edit' }).click();
await rows.filter({ hasNotText: 'Archived' }).first().click();

5. Chaining & scoping locators

// Scope every action to the "Orders" section — no full-page ambiguity
const orders = page.getByRole('region', { name: 'Orders' });
await orders.getByRole('button', { name: 'Refresh' }).click();
await expect(orders.getByRole('row')).toHaveCount(10);

// Reusable locator variable — DRY, readable
const cartBadge = page.getByTestId('cart-badge');
await addToCart(page);
await expect(cartBadge).toHaveText('1');

// nth / first / last
await page.getByRole('article').first().click();
await page.getByRole('article').nth(2).click();
await page.getByRole('article').last().click();
Assign locators to variables
Locators are lazy — they don't query the DOM until an action or assertion. Assigning to a const is free and keeps tests readable.

6. Strict mode — your friend, not your enemy

When a locator matches 2+ elements, Playwright refuses to guess and throws strict mode violation. The error tells you every match — pick a narrower locator:

// ❌ throws — three buttons named "Delete" on the page
await page.getByRole('button', { name: 'Delete' }).click();

// ✅ scope to the row you actually mean
await page.getByRole('row', { name: 'Milk' })
  .getByRole('button', { name: 'Delete' })
  .click();

// ✅ or filter by proximity
await page.getByRole('button', { name: 'Delete' })
  .and(page.locator(':near(:text("Milk"))'))
  .click();

7. Common mistakes to avoid

CSS selectors that mirror styling

.btn.btn-primary.mt-4 is 4 selectors on one element. Any style tweak breaks the test. Replace with getByRole('button', { name: /save/i }).

Snapshot-hunting the DOM

Don't copy #__next > main > div:nth-child(3) > ul > li:nth-child(2) from DevTools. Ask: what would a user say to identify this element? Turn that into a role/label.

waitForSelector everywhere

Locators auto-wait — you almost never need waitForSelector. Assert visibility instead: await expect(locator).toBeVisible().

8. Hands-on task (15 minutes)

  1. 1

    Open the demo TodoMVC app

    Navigate to https://demo.playwright.dev/todomvc.

  2. 2

    Rewrite selectors using only role/label/text

    Open UI Mode, click "Pick locator" and rewrite three actions using getByRole, getByPlaceholder, and getByText. No CSS, no XPath.

  3. 3

    Trigger strict mode

    Add three todos, then try page.getByRole('button', { name: "×" }). Fix with .filter({ hasText }).

  4. 4

    Verify a11y bonus

    Anywhere getByRole couldn't find the element, ask a dev to add a proper role/label — you just improved accessibility.

9. What's next

Locators point at elements. Assertions check them. Continue to Module 2 / Lesson 2: Web-First Assertions.

Docs: playwright.dev/docs/locators · ARIA role definitions

Frequently asked questions

1.What is the best locator in Playwright?
`getByRole()` is the recommended first choice — it mirrors how assistive tech sees the page and rarely breaks on redesigns. Fall back to `getByLabel` for form fields, `getByText` for unique text, and `getByTestId` for elements without semantic meaning.
2.getByRole vs getByTestId — which should I use?
Prefer `getByRole` because it doubles as an accessibility check: if the role doesn't exist, the UI is likely inaccessible. Use `getByTestId` only for elements that genuinely have no user-facing role, or when you need a rock-stable hook for a component that changes text often.
3.What is Playwright strict mode?
Locators are strict by default: if a locator resolves to more than one element, Playwright throws instead of guessing. Narrow the locator with a `name`, `.filter()`, `.first()`, or `.nth(i)` — never disable strict mode.
4.How do I click an element inside a list item?
Scope with `.locator()` or `.filter()`: `page.getByRole('listitem').filter({ hasText: 'Milk' }).getByRole('button', { name: 'Delete' }).click()`. This composes locators without brittle CSS.
5.When should I use CSS or XPath selectors in Playwright?
Almost never. Use them only as a last resort for elements with no role, label, text or test id — usually inside third-party widgets you can't modify. Even then, keep them shallow (`.pw-datepicker >> [data-day='15']`).
6.How do I test hidden elements?
Locators find hidden elements too; actions and assertions decide. Use `toBeHidden()` to assert hidden state, `{ force: true }` on a click to bypass visibility (rarely a good idea), or wait for visibility with `await locator.waitFor()`.

Related lessons