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.

php
$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.

php
// 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.

php
// 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.

php
$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.

php
$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.

php
$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.

php
$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:

php
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