Skip to main content
Playwright beginner Lesson 2 of 10

Locators: Finding Elements That Stay Found

Role, label and text locators, why strict mode fails on purpose, and how filtering and chaining pin down one element in a list without using nth().

A locator does not find anything when you create it. It stores how to find, and re-runs that search on every action. This is why locators survive re-renders that would leave an old-style element handle pointing at detached DOM.

const input = page.getByPlaceholder('What needs to be done?');
// nothing has happened yet — no query, no browser round trip
await input.fill('buy milk');   // the search runs here
await input.press('Enter');     // and again here

The built-in locators, in preference order

import { test, expect } from '@playwright/test';

test('the locators worth reaching for first', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');

  await page.getByPlaceholder('What needs to be done?').fill('buy milk');
  await page.getByPlaceholder('What needs to be done?').press('Enter');

  await expect(page.getByRole('heading', { name: 'todos' })).toBeVisible();
  await expect(page.getByText('buy milk')).toBeVisible();
  await expect(page.getByTestId('todo-count')).toContainText('1 item left');
});
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/locators.spec.ts:3:1 › the locators worth reaching for first (1.1s)

  1 passed (1.9s)
LocatorMatches onUse it for
getByRoleARIA role + accessible namebuttons, links, headings, inputs — the default
getByLabelthe label text of a form controlform fields
getByPlaceholderplaceholder attributeinputs with no visible label
getByTextvisible text contentstatic copy, list items
getByTitletitle attributeicon buttons that have one
getByTestIddata-testidelements with no user-facing identity

getByRole is first because it fails when accessibility fails. If a redesign turns your submit button into a <div onclick>, getByRole('button', { name: 'Submit' }) stops matching — and it should, because a screen reader stopped matching too.

Strict mode: a failure that is doing you a favour

Add two todos, then ask for “the checkbox”:

test('strict mode refuses to guess', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');

  for (const item of ['buy milk', 'walk the dog']) {
    await input.fill(item);
    await input.press('Enter');
  }

  await page.getByRole('checkbox').check();
});
  ✘  1 [chromium] › tests/locators.spec.ts:12:1 › strict mode refuses to guess (35ms)

    Error: locator.check: Error: strict mode violation: getByRole('checkbox')
    resolved to 3 elements:
        1) <input id="toggle-all" class="toggle-all" type="checkbox"/> aka
           getByRole('checkbox', { name: 'Mark all as complete' })
        2) <input class="toggle" type="checkbox"/> aka
           getByRole('listitem').filter({ hasText: 'buy milk' }).getByRole('checkbox')
        3) <input class="toggle" type="checkbox"/> aka
           getByRole('listitem').filter({ hasText: 'walk the dog' }).getByRole('checkbox')

      19 |
    > 20 |   await page.getByRole('checkbox').check();
         |                                    ^

Three things to notice. It failed in 35ms rather than timing out — the element was found immediately, there was just more than one. It listed the actual HTML of each match. And it suggested a working locator for each one, which you can paste straight into the test.

Most frameworks would have clicked the first checkbox and passed. That test would then keep passing after someone reorders the list, while checking the wrong box.

Filtering to one

test('filter picks the row you mean', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');

  for (const item of ['buy milk', 'walk the dog']) {
    await input.fill(item);
    await input.press('Enter');
  }

  const dogRow = page.getByRole('listitem').filter({ hasText: 'walk the dog' });
  await dogRow.getByRole('checkbox').check();

  await expect(dogRow).toHaveClass('completed');
  await expect(page.getByTestId('todo-count')).toHaveText('1 item left');
});
  ✓  1 [chromium] › tests/locators.spec.ts:12:1 › filter picks the row you mean (1.2s)

  1 passed (2.0s)

filter({ hasText }) narrows a set of matches; chaining .getByRole('checkbox') then searches inside the surviving one. Read the two lines together and they say what a person would say: “in the row that says walk the dog, tick the checkbox.”

filter also takes has (a descendant locator) and the negative forms hasNotText / hasNot:

const activeRows = page.getByRole('listitem').filter({ hasNotText: 'walk the dog' });
await expect(activeRows).toHaveCount(1);
  ✓  1 [chromium] › tests/locators.spec.ts:28:1 › negative filter (1.1s)

A locator can point at many elements

That is not an error until you act on it. Assertions on a multi-element locator work on the whole set:

test('assert on the whole list at once', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');

  for (const item of ['buy milk', 'walk the dog', 'file taxes']) {
    await input.fill(item);
    await input.press('Enter');
  }

  const titles = page.getByTestId('todo-title');
  await expect(titles).toHaveCount(3);
  await expect(titles).toHaveText(['buy milk', 'walk the dog', 'file taxes']);
});
  ✓  1 [chromium] › tests/locators.spec.ts:33:1 › assert on the whole list at once (1.4s)

  1 passed (2.2s)

Passing an array to toHaveText asserts count and order in one line. Get the order wrong and the diff is precise:

    Error: Timed out 5000ms waiting for expect(locator).toHaveText(expected)

    - Expected  - 1
    + Received  + 1

      Array [
        "buy milk",
    -   "file taxes",
        "walk the dog",
    +   "file taxes",
      ]

nth, first, last — and why to avoid them

await page.getByRole('listitem').first().getByRole('checkbox').check();
await page.getByRole('listitem').nth(1).click();
await page.getByRole('listitem').last().click();

These silence strict mode by picking a position. They work, and they are the wrong default: a test that says nth(1) breaks the moment someone adds a row above, and worse, it does not break — it quietly checks a different todo. Reach for filter first and treat nth as an admission that the page gives you nothing better.

Iterating over matches

test('read every row', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');

  for (const item of ['buy milk', 'walk the dog']) {
    await input.fill(item);
    await input.press('Enter');
  }

  for (const row of await page.getByTestId('todo-title').all()) {
    console.log(await row.textContent());
  }
});
buy milk
walk the dog

  ✓  1 [chromium] › tests/locators.spec.ts:48:1 › read every row (1.2s)

all() resolves the locator once and returns a fixed array — so it does not wait for elements to appear. If the list is still loading, you get an empty array and a green test that asserted nothing. Assert the count first, then iterate.

CSS and XPath, when nothing else fits

await page.locator('.todo-list li').first().click();
await page.locator('xpath=//section[@class="main"]//li').first().click();
await page.locator('css=input.toggle-all').check();

Legal, sometimes necessary, always a liability: they bind the test to markup that no user can see. If you find yourself writing them often, the fix is usually a data-testid in the app, not a cleverer selector.

Practice

1. Locate the "Active" filter link and click it, then assert the URL.
test('active filter', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  await page.getByRole('link', { name: 'Active' }).click();
  await expect(page).toHaveURL(/#\/active$/);
});
  ✓  1 [chromium] › tests/locators.spec.ts:60:1 › active filter (901ms)

  1 passed (1.6s)

getByRole('link', { name }) matches the accessible name, which for a plain anchor is its text. toHaveURL accepts a regex, which is what you want for a hash route.

2. Write a locator that matches the row containing "buy milk" and no other row, without using nth().
const row = page.getByRole('listitem').filter({ hasText: 'buy milk' });
await expect(row).toHaveCount(1);
  ✓  1 [chromium] › tests/locators.spec.ts:66:1 › one row (1.1s)

Asserting toHaveCount(1) documents the intent and fails loudly if the filter ever becomes ambiguous — better than discovering it through a strict mode violation three lines later.

3. Use getByText('buy') after adding "buy milk" and "buy bread". What happens?
    Error: strict mode violation: getByText('buy') resolved to 2 elements:
        1) <span data-testid="todo-title">buy milk</span>
        2) <span data-testid="todo-title">buy bread</span>

getByText does a substring match by default. Pass { exact: true } for a whole-string match, or a regex for anything in between — getByText(/^buy milk$/).

4. Chain two filters to find a completed row whose text contains "milk".
const row = page
  .getByRole('listitem')
  .filter({ hasText: 'milk' })
  .filter({ has: page.locator('.completed, input.toggle:checked') });

await expect(row).toHaveCount(1);
  ✓  1 [chromium] › tests/locators.spec.ts:74:1 › completed milk row (1.3s)

filter calls stack, each narrowing the previous set. has takes a locator that must match inside each candidate — note it is built from page, not from the row, because Playwright re-roots it against each match.

Next: auto-waiting — why expect retries and page.$ does not.

Frequently Asked Questions

What is a locator in Playwright?
A locator is a recipe for finding an element, not the element itself. Nothing is queried when you create one — the search runs again on every action or assertion, which is why a locator survives a re-render that would invalidate a stored element handle.
Why does Playwright throw a strict mode violation?
Because the locator matched more than one element and Playwright refuses to guess which one you meant. It is a design choice: silently acting on the first match is how a test starts passing against the wrong element. Narrow the locator with `filter()` or chaining, or say `first()` explicitly.
Should I use getByRole or getByTestId?
Prefer `getByRole` for anything a user perceives — buttons, links, headings, form fields — because it breaks when accessibility breaks, which is a bug worth catching. Use `getByTestId` for elements with no accessible identity, such as a styling wrapper or a list row.
Are CSS and XPath selectors still supported?
Yes, `page.locator('css=…')` and XPath both work, and sometimes nothing else will do. They are last resorts because they bind the test to markup structure, so a refactor that changes no behaviour still breaks the test.