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)')// плохо
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-прогона.