Locators
Find elements the way a user would, and know what to do when nothing semantic exists.
Every action starts with the same question: which element. Answer it badly and the test breaks on the next redesign, or worse, keeps passing while checking the wrong button.
A locator is a question, not an element
$page->locator('.price') does not find anything. It records how to find it. The lookup runs when an action or an assertion needs the element, and it runs again on every retry.
$price = $page->locator('.price');
$page->getByRole('button', ['name' => 'Refresh'])->click();
$price->textContent(); // reads the price after the refresh, not before
That is why a locator survives a re-render that would invalidate a captured element handle. It also explains the two behaviours that surprise people first:
It waits. A supported action waits for the conditions that action requires before it fires. You do not add a separate wait before a click. If a click times out, inspect which actionability condition was not met.
It refuses to guess. If a locator matches three elements, an action throws instead of taking the first one. A test that silently clicks the first of three "Delete" buttons is worse than a test that fails: it passes while proving nothing.
Start from the accessible name
The order below is not style advice. Each step down couples the test to something less stable than the step above.
// A user sees a control with a role and a name.
$page->getByRole('button', ['name' => 'Sign in'])->click();
$page->getByLabel('Email')->fill('ada@example.com');
$page->getByPlaceholder('name@example.com')->fill('ada@example.com');
$page->getByText('Welcome back')->isVisible();
The test to apply: would a person describe the element this way? "The Sign in button" is how a user talks. .btn-primary:nth-child(2) is how the stylesheet talks, and stylesheets get rewritten.
getByAltText() and getByTitle() follow the same principle for images and tooltips. The cheatsheet lists every form side by side.
Role locators have a second benefit that is easy to miss. A control with no accessible name is a control a screen reader cannot announce. When getByRole() cannot find your button, the accessibility bug came first, and the test found it.
When nothing semantic exists
Two escape hatches, in this order.
// A stable hook you own, when the markup genuinely has no semantics
$page->getByTestId('cart-total')->textContent();
// CSS, when the structure itself is the contract
$page->locator('table.report tbody tr');
Reach for getByTestId() when the element is a decorative wrapper, a canvas, or a widget with no role. Reach for CSS when the structure is what you are testing, such as the row count of a generated table.
Before either, ask whether the right fix is in the product. Adding aria-label="Close" to an icon button fixes the test and the screen reader in the same commit. A test id fixes only the test.
Narrow before you filter
The most common real problem is not finding an element, it is finding the right one among many. Scope to a container first, then look inside it.
$row = $page->getByRole('row')->filter(['hasText' => 'ada@example.com']);
$row->getByRole('button', ['name' => 'Delete'])->click();
This reads like the intent: the delete button of Ada's row. Compare with nth(3), which reads like nothing and breaks when a row is inserted above.
filter() takes four shapes: hasText and hasNotText for visible content, has and hasNot for a nested locator that must be present or absent.
$products = $page->locator('.product');
$products->filter(['has' => $page->locator('.badge-new')]);
$products->filter(['hasNot' => $page->locator('.out-of-stock')]);
Use first(), last(), and nth() only when position is the actual requirement, such as "the most recent entry is at the top". Position as a shortcut around an ambiguous locator is a bug waiting for a sprint.
Inside an iframe
Frame content is a separate document; a page locator cannot cross into it.
$page->frameLocator('#checkout')
->getByRole('button', ['name' => 'Pay'])
->click();
Payment forms, embedded editors, and consent banners are the usual cases.
Diagnosing a locator that does not match
When an action times out, the question is whether the locator is wrong or the page never arrived.
$candidates = $page->getByRole('button', ['name' => 'Save']);
echo $candidates->count(); // 0 means wrong locator, 2+ means ambiguous
count() returns immediately without waiting, which makes it a probe rather than an assertion. Zero points at the locator or at a page that has not rendered yet; two or more explains a strict mode failure before it happens.
all() goes one step further and hands back one locator per match, which is how you print what the page actually contains:
foreach ($candidates->all() as $candidate) {
echo $candidate->textContent().PHP_EOL;
}
Both are diagnosis tools. Neither belongs in a finished test: they read the page once, so a value that arrives a moment later is missed.
Common mistakes
Using isVisible() as an assertion. It answers about this instant and does not retry, so it turns a timing problem into a flaky boolean. Assertions belong to expectations, which retry.
Reaching for CSS first because it is familiar from the browser console. The console has no concept of what a user perceives; the test should.
Chaining from the page instead of from a container. $page->getByRole('button', ['name' => 'Delete']) in a table of twenty rows is ambiguous by construction.
Adding a test id to silence a strict mode error. The error said two elements match. A test id on one of them hides the duplicate instead of answering why the page has two identical controls.
Trade-offs
| Approach | Couples the test to | Breaks when |
|---|---|---|
| Role, label, text | what the user perceives | the product changes on purpose |
| Test id | a hook you control | someone renames the attribute |
| CSS | the presentation | the stylesheet is refactored |
Semantic locators are not merely more stable. They fail for the right reason: when they break, the user-facing behaviour changed too. A CSS locator breaks on changes no user would ever notice, which is how suites earn their reputation for crying wolf.
Go next
- Act on what you found: Actions and input
- Assert instead of checking: Choosing assertions
- Look one up fast: Locators cheatsheet
- Read a real case: Read a table row by its text