A locator in Playwright is not a DOM node found once but a live query that finds the element anew at the moment of an action or an assertion. The difference is fundamental: the old way of "find an element and hold the reference" breaks as soon as the page re-renders, while a locator asks the page again each time. So choosing a locator is choosing by which trait you describe the element, and on that trait depends whether the test survives a markup refactor.
The main divide runs between a technical and a user-facing anchor. A selector like '#app > div:nth-child(2) .btn-primary' is tied to the markup structure and classes - to what changes with any template or design edit. The query getByRole('button', { name: 'Place order' }) relies on what the user sees and hears: the element's role and its accessible name. The second way describes a contract that changes with behavior, not with markup.
From this grows a clear priority of locators. First you take getByRole with a name - for buttons, links, headings, dialogs; it is the most robust and accessible way. Then getByLabel for form fields, getByText, placeholder and alt for visible content. getByTestId - when there is no stable semantic anchor and an explicit test id has to be set. CSS and XPath - a last resort, for a technical interface with no accessible semantics.
| Priority | Locator | When |
|---|---|---|
| 1 | getByRole + name | Buttons, links, headings, dialogs |
| 2 | getByLabel | Form fields |
| 3 | getByText / placeholder / alt | Visible content |
| 4 | getByTestId | No stable semantic anchor |
| Last | CSS / XPath | A technical interface with no semantics |
This order is not a matter of taste but a consequence of E2E checking the system through the user's eyes. Role and accessible name are exactly the traits by which a human and assistive technology find an element; a test relying on them also checks the interface's accessibility. Relying on a class or a position in the DOM ties the test to implementation details invisible to the user and changing most often.
Locators can narrow the scope, and this is the key to working with lists and tables. Instead of a brittle index, a row is found by its content: getByRole('row').filter({ hasText: 'anna@example.com' }), and within it you then look for the cell and the button by their roles. So the test describes "the row about this user, the edit button in it" - a robust user contract independent of row order and table markup.
It helps to see both the locator priority and the narrowing technique side by side once, to keep both in mind while writing a scenario. Below is the priority map and an example of filtering a table row; you return to them each time your hand reaches to write a CSS path instead of a query by role and name.
A separate value is the locator's strictness. If a query matches two elements at once, Playwright does not take the first one but fails with an ambiguity error. This is not a hindrance but a signal: the user contract is described insufficiently precisely. The right reaction is to refine the locator (add a scope via filter or an exact name), not to muffle the problem with a random .first() that silently cements a dependence on element order.
The typical locator failures are predictable. A CSS path by structure and classes that goes red at the first markup edit. A reflexive .first() over an ambiguous query, hiding the real problem and tying the test to order. And getByTestId where there is a proper role and label - an extra test id instead of checking the accessible contract. Describe the element the way the user finds it, and narrow the scope by content, not by index.
// Narrowing the scope: a row by content, then the cell and button by role
const row = page.getByRole('row').filter({ hasText: 'anna@example.com' })
await expect(row.getByRole('cell', { name: 'Active' })).toBeVisible()
await row.getByRole('button', { name: 'Edit' }).click()