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)
| Locator | Matches on | Use it for |
|---|---|---|
getByRole | ARIA role + accessible name | buttons, links, headings, inputs — the default |
getByLabel | the label text of a form control | form fields |
getByPlaceholder | placeholder attribute | inputs with no visible label |
getByText | visible text content | static copy, list items |
getByTitle | title attribute | icon buttons that have one |
getByTestId | data-testid | elements 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.