Локатор в Playwright - это не найденный один раз узел DOM, а живой запрос, который заново находит элемент в момент действия или проверки. Разница принципиальна: старый способ "найти элемент и держать ссылку" ломается, как только страница перерисовалась, а локатор каждый раз спрашивает страницу заново. Поэтому выбор локатора - это выбор того, по какому признаку вы описываете элемент, и от этого признака зависит, переживёт ли тест рефакторинг вёрстки.
Главный водораздел проходит между технической и пользовательской опорой. Селектор вроде '#app > div:nth-child(2) .btn-primary' привязан к структуре разметки и классам - к тому, что меняется при любой правке шаблона или дизайна. Запрос getByRole('button', { name: 'Оформить заказ' }) опирается на то, что видит и слышит пользователь: роль элемента и его доступное имя. Второй способ описывает контракт, который меняется вместе с поведением, а не с вёрсткой.
Из этого вырастает чёткий приоритет локаторов. Первым берут getByRole с именем - для кнопок, ссылок, заголовков, диалогов; это самый устойчивый и доступный способ. Затем getByLabel для полей формы, getByText, placeholder и alt для видимого контента. getByTestId - когда стабильной семантической опоры нет и приходится ставить явный test id. CSS и XPath - последнее средство, для технического интерфейса без доступной семантики.
| Приоритет | Локатор | Когда |
|---|---|---|
| 1 | getByRole + name | Кнопки, ссылки, заголовки, диалоги |
| 2 | getByLabel | Поля формы |
| 3 | getByText / placeholder / alt | Видимый контент |
| 4 | getByTestId | Нет стабильной семантической опоры |
| Последний | CSS / XPath | Технический интерфейс без семантики |
Этот порядок не вкусовщина, а следствие того, что E2E проверяет систему глазами пользователя. Роль и доступное имя - ровно те признаки, по которым элемент находят человек и вспомогательные технологии; тест, опирающийся на них, заодно проверяет доступность интерфейса. Опора на класс или позицию в DOM привязывает тест к деталям реализации, которые пользователю не видны и меняются чаще всего.
Локаторы умеют сужать область, и это ключ к работе со списками и таблицами. Вместо хрупкого индекса строку находят по содержимому: getByRole('row').filter({ hasText: 'anna@example.com' }), а внутри неё уже ищут ячейку и кнопку по их ролям. Так тест описывает "строка про этого пользователя, кнопка редактирования в ней" - устойчивый пользовательский контракт, не зависящий от порядка строк и разметки таблицы.
Полезно один раз увидеть и приоритет локаторов, и приём сужения рядом, чтобы держать оба в голове при написании сценария. Ниже - карта приоритета и пример фильтрации строки таблицы; к ним возвращаются каждый раз, когда рука тянется написать CSS-путь вместо запроса по роли и имени.
Отдельная ценность - строгость локатора. Если запрос соответствует двум элементам сразу, Playwright не берёт первый попавшийся, а падает с ошибкой неоднозначности. Это не помеха, а сигнал: пользовательский контракт описан недостаточно точно. Правильная реакция - уточнить локатор (добавить область через filter или точное имя), а не заглушить проблему случайным .first(), который молча закрепит зависимость от порядка элементов.
Типичные провалы локаторов предсказуемы. CSS-путь по структуре и классам, который краснеет при первой же правке вёрстки. Рефлекторный .first() поверх неоднозначного запроса, прячущий настоящую проблему и привязывающий тест к порядку. И getByTestId там, где есть нормальная роль и подпись, - лишний test id вместо проверки доступного контракта. Описывайте элемент так, как его находит пользователь, и сужайте область по содержимому, а не по индексу.
// Сужение области: строка по содержимому, затем ячейка и кнопка по роли
const row = page.getByRole('row').filter({ hasText: 'anna@example.com' })
await expect(row.getByRole('cell', { name: 'Активен' })).toBeVisible()
await row.getByRole('button', { name: 'Редактировать' }).click()