The question I ask first when reading someone's Playwright tests: are they using getByRole or CSS selectors? The answer tells me how brittle the test suite is. Locators are how you find elements — picking the right one makes tests survive refactors.
The locator priority order
Start at the top. Drop down only when the element genuinely has no role, label, or text.
Every locator in Playwright is lazy — it doesn't find the element until you use it. When you call .click() or expect(), Playwright searches the page at that moment, waits for the element to appear, and retries automatically. This auto-wait only works through locators — not through raw DOM handles.
The locator you pick determines how resilient the test is. If a developer renames a CSS class or changes a div to a section, CSS selectors break. If they rename a button's label from "Save" to "Save changes", getByText('Save') breaks. But getByRole('button', { name: /save/i }) — that survives because it matches how the browser exposes the element to screen readers.
getByRole — the one to use by default
getByRole matches elements by their ARIA role and accessible name. The role is either declared explicitly (role="button") or inferred from the HTML tag — <button> is a button, <a> is a link, <h1> is a heading. The accessible name is what a screen reader would announce for that element.
In practice: for any interactive element (button, link, checkbox, select, input with a label), getByRole is the answer. It tests the right thing — the semantics — not the implementation.
For form fields with a <label> — use getByLabel. It works even when the label is linked via htmlFor/id rather than wrapping the input. This is what I use for login forms, order creation forms, settings pages.
Some inputs have no visible label — they rely on placeholder text. getByPlaceholder finds them. Not ideal (missing labels are an accessibility problem), but if the app is built this way and you can't change it, this is the locator to use.
ts
test('search orders', async ({ page }) => {
await page.goto('/orders')
// Поле пошуку без label але з placeholderawait page.getByPlaceholder('Search orders...').fill('keyboard')
await page.getByPlaceholder('Search orders...').press('Enter')
awaitexpect(page.getByRole('row')).toHaveCount(3)
})
getByText — for non-interactive content
getByText is for content you want to assert on — paragraphs, status labels, table cells. I avoid using it to find clickable elements since roles are more stable. It supports exact match, substring, and regex.
ts
test('order status is shown correctly', async ({ page }) => {
await page.goto('/orders')
// Перевірка тексту статусуawaitexpect(page.getByText('Order confirmed')).toBeVisible()
// Точний збігawaitexpect(page.getByText('Pending', { exact: true })).toBeVisible()
// Regex — коли текст може трохи відрізнятисяawaitexpect(page.getByText(/order #d+ created/i)).toBeVisible()
})
getByTestId — the escape hatch
getByTestId finds elements by data-testid attribute. I use it when an element has no semantic role, no label, and no stable text — for example, a custom chart component, a drag-and-drop card, or a canvas element. It requires developers to add data-testid attributes, which is a small coordination cost but creates stable, explicit test targets.
You can configure a custom attribute name instead of data-testid in the config:
ts
// HTML: <div data-testid="revenue-chart">...</div>test('revenue chart renders after data loads', async ({ page }) => {
await page.goto('/dashboard')
awaitexpect(page.getByTestId('revenue-chart')).toBeVisible()
})
When getByRole('row') returns 20 rows, you need to narrow down to the one you care about. .filter() lets you add conditions — by visible text or by another locator inside it. This is how I target specific rows in order tables without resorting to nth-child selectors.
ts
test('cancel specific order from list', async ({ page }) => {
await page.goto('/orders')
// Знайти рядок що містить 'ORDER-042' і клікнути Cancel в ньомуconst targetRow = page.getByRole('row').filter({ hasText: 'ORDER-042' })
await targetRow.getByRole('button', { name: 'Cancel' }).click()
await page.getByRole('button', { name: 'Confirm cancellation' }).click()
awaitexpect(targetRow.getByText('Cancelled')).toBeVisible()
})
// Фільтр за вкладеним локаторомconst pendingRows = page.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Pending' })
})
awaitexpect(pendingRows).toHaveCount(3)
Chaining and scoping
Locators can be chained — each call narrows the search to within the previous result. This is cleaner than long CSS selectors and more readable: page.getByRole('dialog').getByRole('button', { name: 'Save' }) is self-documenting.
ts
test('edit order in modal', async ({ page }) => {
await page.goto('/orders')
// Клікнути Edit для конкретного замовленняawait page.getByRole('row').filter({ hasText: 'ORDER-007' })
.getByRole('button', { name: 'Edit' })
.click()
// Всі наступні пошуки — всередині dialog, не по всій сторінціconst modal = page.getByRole('dialog')
await modal.getByLabel('Status').selectOption('shipped')
await modal.getByRole('button', { name: 'Save changes' }).click()
awaitexpect(modal).not.toBeVisible()
})
Working with lists
When you have a list of similar items — an order list, a product grid, a notification stack — you often need to assert on the count, check all items, or find one specific item. all() returns the current elements as an array, nth() picks by index.
ts
test('order list has correct items', async ({ page }) => {
await page.goto('/orders')
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') })
// Перевірити кількістьawaitexpect(rows).toHaveCount(5)
// Перший рядок — найновіше замовленняawaitexpect(rows.nth(0)).toContainText('ORDER-042')
// Обійти всі рядкиfor (const row ofawait rows.all()) {
awaitexpect(row.getByRole('cell', { name: /ORDER-d+/ })).toBeVisible()
}
})
// last() — останній елементawaitexpect(page.getByRole('listitem').last()).toContainText('No more items')
Strict mode — one match expected
By default, if a locator matches more than one element, calling an action on it throws — Playwright wants you to be precise. This prevents accidental clicks on the wrong element when there are multiple matches. If you intentionally want multiple elements, use all() or count().
ts
// ❌ Кидає: strict mode violation: getByRole('button') resolved to 8 elementsawait page.getByRole('button').click()
// ✅ Уточни яку кнопкуawait page.getByRole('button', { name: 'Submit order' }).click()
// ✅ Або звузь scopeawait page.getByRole('form', { name: 'Create order' })
.getByRole('button', { name: 'Submit' }).click()
// ✅ Або візьми конкретний за індексом (якщо порядок важливий)await page.getByRole('button', { name: 'Delete' }).nth(2).click()
// Перевірити кількість без strict mode violationawaitexpect(page.getByRole('button', { name: 'Delete' })).toHaveCount(5)