QA Interview Hub · Knowledge base · Quizzes
Playwright
Locators
Locators, and Locator vs ElementHandle
The recommended built-in locators are: getByRole, getByLabel, getByPlaceholder, getByText, getByAltText, getByTitle and getByTestId. The priority is getByRole with a name, because it reflects how the page is seen by a user and by assistive technologies. CSS and XPath through page.locator() are a fallback.
A Locator is lazy: when it is created it searches for nothing, and the search happens every time at the moment of an action or a check. So it survives re-rendering and doesn't give stale element errors. Before an action Playwright automatically waits for the element to be attached, visible, stable, enabled and able to receive events.
A Locator works in strict mode: if a selector finds several elements, the action throws an error, and you have to narrow it with filter, first, nth or a chain. An ElementHandle is a reference to a specific DOM node captured once, without auto-waiting and strict mode, so it is discouraged. It is rarely needed, for example to pass a node into evaluate.
getByRole in depth: options and precision
getByRole searches by ARIA role and accessible name. It is important to know how name works: by default it is a case-insensitive substring search. So { name: 'Save' } will also find the "Save draft" button. For an exact match there is exact: true, and for a flexible one a regular expression.
page.getByRole('button', { name: 'Save', exact: true });
page.getByRole('link', { name: /^Order #\d+$/ });
page.getByRole('heading', { name: 'Cart', level: 1 });
page.getByRole('checkbox', { name: 'I agree', checked: true });
page.getByRole('button', { name: 'Menu', expanded: false });
page.getByRole('tab', { selected: true });
Additional options filter by state: checked, pressed, expanded, selected, disabled, level for headings. By default hidden elements are not taken into account; includeHidden: true changes that.
The accessible name comes from the element's text, aria-label, aria-labelledby or the associated label. If getByRole doesn't find a field, it is often a real accessibility bug: an input without a label wouldn't be read by a screen reader either. I report such cases to the developers instead of working around them with CSS.
To check the structure of a whole block, since version 1.49 there is toMatchAriaSnapshot: it compares the accessibility tree with a YAML template. This is handy for menus or forms where the composition and order of elements matter.
In an interview: In getByRole the name is searched as a case-insensitive substring by default, so for identical buttons I use exact: true or a regex. I also apply state options like checked or expanded. If a role isn't found, it is a signal of an accessibility problem.
Chains, filter, has, and, or
Strict mode forces you to refine locators, and there are several tools for that.
A chain narrows the search area: page.getByTestId('cart').getByRole('button', { name: 'Remove' }). The inner locator is searched only inside the outer one.
filter keeps the elements that match a condition:
const row = page.getByRole('row')
.filter({ hasText: 'INV-1042' })
.filter({ has: page.getByRole('cell', { name: 'Paid' }) });
await row.getByRole('button', { name: 'Refund' }).click();
const activeItems = page.getByRole('listitem').filter({ hasNot: page.getByText('Archive') });
const visibleToasts = page.getByRole('alert').filter({ visible: true });
The difference is important: a chain looks for a child element, while filter({ has }) returns the outer element itself that has such a descendant. That is, "find the row that has a Paid cell", not the cell itself. There are also hasText, hasNotText, hasNot and visible.
and intersects two locators for one element: page.getByRole('button').and(page.getByTitle('Subscribe')). or gives an alternative: "either a consent dialog or a Continue button", which is useful when an application may show one of two states.
first(), last(), nth() are the last resort. They are tied to the order in the DOM, so they break when the sort order changes. If I write nth, it is only when the order is part of the requirement.
In an interview: For tables I find a row through filter with hasText or has, and then the needed button inside the row. I use nth only when the order is important by the requirements. or helps when an application can legitimately show one of two states.
Test id, lists and the all() trap
getByTestId searches for the data-testid attribute by default. If the project uses another one, for example data-qa, it is set in the config: use: { testIdAttribute: 'data-qa' }. I use a test id when there is no stable role or text: icons without a label, dynamic containers, multilingual texts.
To check a list, assertions on the whole locator are better than loops:
const items = page.getByRole('listitem');
await expect(items).toHaveCount(3);
await expect(items).toHaveText(['Bread', 'Milk', 'Cheese']);
toHaveText with an array checks the count and the order, and retries until the timeout.
The trap is locator.all(). It returns an array of locators for the elements that are in the DOM right now, and it doesn't wait. If the list is still loading, I get an empty array, and the loop simply checks nothing: the test is green, though nothing was tested. So before all() I first wait for the state, for example with toHaveCount.
await expect(items).toHaveCount(3);
for (const item of await items.all()) {
await expect(item.getByRole('img')).toBeVisible();
}
Likewise count(), textContent() and allTextContents() return values at a single moment, without retries. They can be used for logic, but not for checks.
In an interview: For lists I use toHaveCount and toHaveText with an array, because they retry. all() doesn't wait for elements, so before it I always put an assertion on the count, otherwise the loop may walk through an empty array and give a falsely green test.