CSS-локатор вроде button.btn-primary-v2 сломается как только разработчик переименует класс. getByRole('button', { name: 'Submit' }) переименование переживёт: находит кнопку так как пользователь её идентифицирует, по роли и лейблу. Шесть типов локаторов в Playwright упорядочены от наиболее к наименее рекомендуемым, и getByRole стоит первым по причине: его режим отказа соответствует реальному воздействию на пользователя. Если доступное имя изменилось, тест должен сломаться. Это руководство разбирает каждый тип, когда вместо них использовать getByLabel или getByTestId, цепочки с filter() и что значит strict mode violation когда локатор находит больше одного элемента.

Почему локаторы важны

Самая частая причина флакующих тестов не таймминг. Это хрупкие локаторы. Тест который находит кнопку по CSS-классу (button.btn-primary-v2) сломается как только разработчик переименует этот класс. Тест который находит кнопку по роли и лейблу (getByRole('button', { name: 'Submit' })) переживёт любое CSS-изменение: он ищет кнопку так же как пользователь, по тому что написано и что делает.

Playwright предоставляет шесть типов локаторов. Перечислены от наиболее к наименее рекомендуемым.

getByRole: используй первым

getByRole находит элементы по ARIA-роли и доступному имени. Это локатор который Playwright рекомендует по умолчанию, и небезосновательно: именно так пользователи и скринридеры идентифицируют элементы. Если доступное имя изменилось, тест должен сломаться. Это реальное UX-изменение.

// кнопки
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Delete item' }).click();

// ссылки
await page.getByRole('link', { name: 'Home' }).click();
await page.getByRole('link', { name: 'View details' }).click();

// заголовки
await expect(page.getByRole('heading', { name: 'My Travel Items' })).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toContainText('Dashboard');

// элементы форм
await page.getByRole('textbox', { name: 'Search' }).fill('Tokyo');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('combobox', { name: 'Status' }).selectOption('active');

// таблицы
const rows = page.getByRole('row');
await expect(rows).toHaveCount(6); // 1 строка заголовка + 5 строк данных

Часто используемые ARIA-роли: button, link, heading, textbox, checkbox, radio, combobox (дропдаун), listitem, row, cell, dialog, table, navigation, main.

Опция name совпадает с доступным именем элемента. Для кнопок и ссылок это видимый текст. Для инпутов это связанный лейбл. По умолчанию без учёта регистра.

// exact: false (дефолт) — частичное совпадение
page.getByRole('button', { name: 'sub' }) // находит "Submit", "Subscribe"

// exact: true — только полное совпадение
page.getByRole('button', { name: 'Submit', exact: true }) // только "Submit"

getByLabel: для полей форм

getByLabel находит инпут, select или textarea по связанному элементу . Правильный локатор для форм входа, строк поиска и любых полей с видимым лейблом.

await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Date of birth').fill('1990-01-15');

Работает независимо от того используется ли for/id, aria-label или лейбл оборачивает инпут. Не нужно знать как реализован лейбл. Playwright разберётся.

<!-- все три находятся через getByLabel('Email') -->
<label for="email">Email</label><input id="email" />
<label><span>Email</span><input /></label>
<input aria-label="Email" />

getByPlaceholder: когда нет лейбла

Некоторые инпуты имеют placeholder вместо видимого лейбла. getByPlaceholder обрабатывает этот случай.

await page.getByPlaceholder('Search destinations...').fill('Tokyo');
await page.getByPlaceholder('Enter your email').fill('test@example.com');

Предпочитай getByLabel когда лейбл есть. getByPlaceholder только для инпутов которые имеют лишь placeholder.

getByText: для неинтерактивных элементов

getByText находит элементы по видимому текстовому содержимому. Используй для проверки текста на странице, а не для кликов по элементам (для этого getByRole).

// проверяем что текст есть
await expect(page.getByText('My Travel Items')).toBeVisible();
await expect(page.getByText('Invalid credentials')).toBeVisible();

// точное vs частичное совпадение
page.getByText('Travel')          // находит "My Travel Items", "Travel guide"
page.getByText('Travel', { exact: true })  // только точный "Travel"

// в области конкретного типа элемента
page.locator('p').getByText('Error occurred')  // только элементы <p>

getByText находит все элементы содержащие этот текст, включая родительские контейнеры. Если «Submit» встречается в абзаце и в кнопке, getByText('Submit') вернёт несколько элементов. Для интерактивных элементов используй getByRole.

getByTestId: явный контракт

getByTestId находит элементы по атрибуту data-testid (настраивается). Используй когда разработчики явно добавили тест-хуки в DOM.

<button data-testid="submit-payment">Pay now</button>
<div data-testid="success-message">Payment complete</div>

await page.getByTestId('submit-payment').click();
await expect(page.getByTestId('success-message')).toBeVisible();

Преимущество: атрибуты data-testid невидимы пользователям и не несут функциональной нагрузки, поэтому разработчики не переименуют их случайно. Недостаток: кто-то должен добавить их в код. Для приложений которые контролируешь, подходит. Для сторонних приложений работаешь с той структурой что есть.

Если команда ещё не использует data-testid, предложи это. Попроси разработчиков добавить атрибуты data-testid к ключевым интерактивным элементам. Несколько минут на компонент, и локаторы становятся стабильными.

getByAltText и getByTitle

Два редких, но иногда полезных локатора:

// изображения с alt-текстом
await page.getByAltText('User profile picture').click();
await expect(page.getByAltText('Company logo')).toBeVisible();

// элементы с атрибутом title
await page.getByTitle('Close dialog').click();

Используются редко. Большинство интерактивных элементов доступны через getByRole.

Цепочки локаторов

Когда нужно сузить область поиска до конкретного элемента внутри контейнера, цепляй локаторы:

// находим строку содержащую "Tokyo" и кликаем её кнопку Delete
const tokyoRow = page.getByRole('row').filter({ hasText: 'Tokyo' });
await tokyoRow.getByRole('button', { name: 'Delete' }).click();

// находим секцию формы и взаимодействуем с её инпутом
const addressSection = page.locator('.address-section');
await addressSection.getByLabel('City').fill('Warsaw');

filter({ hasText: '...' }) сужает локатор до элементов содержащих конкретный текст. В сочетании с nth() для выбора по индексу:

// первая строка данных в таблице (индекс 0 — заголовок)
const firstRow = page.getByRole('row').nth(1); // nth(0) заголовок, nth(1) первая строка данных
await firstRow.getByRole('button', { name: 'Edit' }).click();

Чего избегать

CSS-селекторы. Хрупкие, зависят от реализации, ломаются при рефакторинге:

// плохо
page.locator('.btn.btn-primary')
page.locator('#submit-button')
page.locator('div > ul > li:nth-child(3)')

XPath. Многословный, хрупкий, сложно читать:

// плохо
page.locator('//button[@class="btn btn-primary" and text()="Submit"]')

Текстовые селекторы без контекста. Неоднозначны когда текст встречается в нескольких местах:

// рискованно — что если "Edit" встречается несколько раз?
page.getByText('Edit')
// лучше — ограничено строкой которая нужна
page.getByRole('row').filter({ hasText: 'Tokyo' }).getByRole('button', { name: 'Edit' })

Практика на lab.becomeqa.com

Открой https://lab.becomeqa.com и напиши локаторы для:

1. Кнопки Login в навигации

2. Инпутов Username и Password в модале входа

3. Кнопки Submit в модале входа

4. Строки таблицы с конкретным направлением после входа

5. Кнопки Add Item на дашборде

import { test, expect } from '@playwright/test';

test('locator practice', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');

  // 1. кнопка входа в навигации
  await page.getByRole('button', { name: 'Login' }).click();

  // 2 и 3. форма входа
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();

  // 4. конкретная строка в таблице
  const parisRow = page.getByRole('row').filter({ hasText: 'Paris' });
  await expect(parisRow).toBeVisible();

  // 5. кнопка добавления элемента
  await expect(page.getByRole('button', { name: 'Add Item' })).toBeVisible();
});

FAQ

Когда использовать locator() напрямую вместо методов getBy*?

Когда нужны CSS или атрибутные селекторы которые методы getBy* не покрывают. Например, page.locator('[data-status="active"]') находит все элементы с конкретным значением дата-атрибута. Используй как последнее средство, не как первый выбор.

Можно комбинировать несколько локаторов?

Да. locator.and(otherLocator) находит элементы соответствующие обоим:

// кнопка которая одновременно видима и содержит текст "Submit"
page.getByRole('button').and(page.getByText('Submit'))

Что если два элемента совпадают с локатором?

Playwright бросает strict mode violation если локатор находит больше одного элемента при попытке взаимодействия. Исправь сделав локатор конкретнее: добавь filter, используй nth() или ограничь область родительским контейнером.

Как отладить локатор который ничего не находит?

Используй режим highlight в Playwright Inspector: PWDEBUG=1 npx playwright test. Или вызови await locator.highlight() в тесте чтобы визуально пометить найденный элемент во время headed-прогона.

→ See also: Assertions в Playwright: полное руководство | Начало работы с Playwright: первые тесты за 30 минут | Playwright Codegen: записывайте тесты без написания кода | Как читать сообщения об ошибках Playwright