IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Beginner·5 / 16

Locators

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.

ts
test('order dashboard interactions', async ({ page }) => {
  await page.goto('/dashboard')

  // Кнопки
  await page.getByRole('button', { name: 'Create order' }).click()
  await page.getByRole('button', { name: /save/i }).click()

  // Посилання
  await page.getByRole('link', { name: 'Orders' }).click()

  // Заголовки
  await expect(page.getByRole('heading', { name: 'My Orders' })).toBeVisible()

  // Таблиця
  const table = page.getByRole('table')
  await expect(table.getByRole('row')).toHaveCount(6) // 5 рядків + header

  // Checkbox
  await page.getByRole('checkbox', { name: 'Select all' }).check()

  // Combobox (select)
  await page.getByRole('combobox', { name: 'Status' }).selectOption('pending')
})

getByLabel — for form inputs

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.

ts
test('login with valid credentials', async ({ page }) => {
  await page.goto('/login')

  await page.getByLabel('Email').fill('admin@example.com')
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)
  await page.getByRole('button', { name: 'Sign in' }).click()

  await expect(page).toHaveURL('/dashboard')
})

test('create order form', async ({ page }) => {
  await page.goto('/orders/new')

  await page.getByLabel('Customer name').fill('Ivan Kozenko')
  await page.getByLabel('Item').fill('Laptop')
  await page.getByLabel('Quantity').fill('2')
  await page.getByRole('button', { name: 'Submit' }).click()
})

getByPlaceholder — when there's no label

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 але з placeholder
  await page.getByPlaceholder('Search orders...').fill('keyboard')
  await page.getByPlaceholder('Search orders...').press('Enter')

  await expect(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')

  // Перевірка тексту статусу
  await expect(page.getByText('Order confirmed')).toBeVisible()

  // Точний збіг
  await expect(page.getByText('Pending', { exact: true })).toBeVisible()

  // Regex — коли текст може трохи відрізнятися
  await expect(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')
  await expect(page.getByTestId('revenue-chart')).toBeVisible()
})
ts
// playwright.config.ts — кастомний атрибут
export default defineConfig({
  use: {
    testIdAttribute: 'data-qa', // замість data-testid
  },
})

// HTML: <button data-qa="submit-order">Submit</button>
// В тесті:
await page.getByTestId('submit-order').click()

Filtering locators

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()
  await expect(targetRow.getByText('Cancelled')).toBeVisible()
})

// Фільтр за вкладеним локатором
const pendingRows = page.getByRole('row').filter({
  has: page.getByRole('cell', { name: 'Pending' })
})
await expect(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()

  await expect(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') })

  // Перевірити кількість
  await expect(rows).toHaveCount(5)

  // Перший рядок — найновіше замовлення
  await expect(rows.nth(0)).toContainText('ORDER-042')

  // Обійти всі рядки
  for (const row of await rows.all()) {
    await expect(row.getByRole('cell', { name: /ORDER-d+/ })).toBeVisible()
  }
})

// last() — останній елемент
await expect(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 elements
await page.getByRole('button').click()

// ✅ Уточни яку кнопку
await page.getByRole('button', { name: 'Submit order' }).click()

// ✅ Або звузь scope
await page.getByRole('form', { name: 'Create order' })
  .getByRole('button', { name: 'Submit' }).click()

// ✅ Або візьми конкретний за індексом (якщо порядок важливий)
await page.getByRole('button', { name: 'Delete' }).nth(2).click()

// Перевірити кількість без strict mode violation
await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(5)