automationplaywrightseleniumui-testsqa

Локаторы, которые переживут редизайн: стратегия селекторов для UI-автотестов

Знакомая картина: с утра красный CI, ты открываешь отчёт в ожидании честного бага, а там — TimeoutError: waiting for selector "div.css-1x7f9a2 > div:nth-child(3) > button". Ничего не сломалось. Просто дизайнер поменял вёрстку карточки, класс перегенерился, кнопка уехала на одну ноду вбок. Тест «упал», но продукт работает. Ты чинишь локатор, коммитишь, и через две недели всё повторяется.

Это не отдельная неудача — это самый частый способ, которым UI-автотесты превращаются из актива в обузу. Хрупкий локатор ломается на любом рефакторинге вёрстки, накапливает ложные падения, команда перестаёт верить прогону и начинает игнорировать красный. Хорошая новость: устойчивость теста почти целиком определяется тем, как ты находишь элемент. И это управляемо.

Почему локатор — это контракт, а не деталь

Когда ты пишешь page.getByRole('button', { name: 'Оплатить' }), ты фиксируешь намерение: «на странице есть кнопка, которую пользователь видит как „Оплатить“». Когда ты пишешь div:nth-child(3) > button, ты фиксируешь совсем другое: «третий div сверху, внутри него кнопка». Первое — про поведение продукта, оно меняется редко и осмысленно. Второе — про текущую структуру DOM, которая меняется каждый спринт и без всякого смысла для пользователя.

Локатор — это точка сцепления теста с приложением. Чем ближе он к тому, что видит и делает пользователь, тем реже будет ломаться от изменений, которые пользователь не заметил. Отсюда весь остальной разбор.

Приоритет селекторов: сверху вниз

Это иерархия «бери верхнее, что подходит, и спускайся только если нельзя». Порядок не случайный — он от «максимально про пользователя» к «максимально про реализацию».

  • 1. Роль + доступное имя. getByRole('button', { name: 'Оплатить' }), getByLabel('Email'). Это то, как элемент воспринимает пользователь (и скринридер). Ломается только если реально меняется смысл элемента. Бонус: заодно проверяешь доступность — если у кнопки нет доступного имени, тест не найдёт её, и это правильный сигнал.
  • 2. data-testid. Явный крючок для тестов: getByTestId('checkout-submit'). Не зависит ни от вёрстки, ни от текста, ни от языка. Компромисс: требует договорённости с разработкой (см. ниже), но это самый стабильный вариант, когда роль/текст не подходят.
  • 3. Видимый текст / плейсхолдер. getByText('Корзина пуста'), getByPlaceholder('Поиск'). Близко к пользователю, но ломается при смене копирайта и при локализации (см. про i18n).
  • 4. Осмысленный CSS-атрибут. input[name="email"], [aria-label="Закрыть"]. Привязка к стабильному атрибуту, а не к позиции. Терпимо.
  • 5. CSS по классам/структуре и XPath по позиции. div.card > button, //div[2]/span[3]. Последнее средство. Хрупко по определению — привязано к реализации, которая меняется чаще всего.

Правило простое: если для одного и того же элемента есть выбор — бери тот, что выше по списку. Опускайся вниз, только когда верхние честно недоступны.

Две главные ловушки внизу списка

XPath по позиции — бомба замедленного действия. //div[2]/div/ul/li[3]/button работает ровно до первого добавленного враппера, нового баннера сверху или изменённого порядка. Он не выражает что ты ищешь — только где оно лежало сегодня. Такой локатор не переживает даже мелкий рефакторинг. Если без XPath никак — привязывайся к атрибуту или тексту (//button[@data-testid="submit"], //*[text()="Оплатить"]), а не к индексам нод.

Автогенеренные классы — фальшивая стабильность. css-1x7f9a2, sc-bdVaJa, jss42 от CSS-in-JS (styled-components, Emotion, MUI) и CSS-модулей выглядят как надёжный CSS-селектор, но хэш меняется при каждой пересборке стилей. Тест зелёный сегодня и красный после безобидного изменения в соседнем компоненте. Никогда не цепляйся за такие классы.

data-testid: договоритесь с разработкой

Самый частый спор: «не хочу мусорить в проде тестовыми атрибутами». Аргументы, которые обычно снимают возражение:

  • data-* — валидный HTML и штатный механизм для произвольных данных; он не влияет на стили, поведение и SEO.
  • Несколько байт на элемент в gzip — статистический ноль рядом с весом реальной разметки.
  • При желании атрибуты можно вырезать в прод-сборке babel/SWC-плагином — но чаще проще оставить: их наличие ещё и упрощает отладку и аналитику.

О чём договориться заранее, чтобы это не превратилось в хаос:

  • Единое имя атрибута. data-testid — дефолт Testing Library и getByTestId в Playwright/Cypress. Не плоди data-test, data-qa, data-cy вперемешку.
  • Конвенция значений. Читаемые и стабильные: checkout-submit, cart-item-remove. Не завязывай на порядок (item-3) — используй бизнес-идентификатор (item-<sku>).
  • testid ставит разработчик вместе с фичей, а не QA пост-фактум патчами. Тогда крючок живёт в одном PR с компонентом и не теряется при рефакторинге.

Ловушки, о которых забывают

  • Локализация. Тест по видимому тексту getByText('Add to cart') умрёт, как только включат русскую локаль или A/B поменяет копирайт. Для мультиязычных приложений это решает data-testid или роль. Текст оставляй там, где сам текст и есть предмет проверки.
  • Динамический контент. Списки, таблицы, ленты — не привязывайся к «третьей строке». Ищи по содержимому строки (getByRole('row', { name: /iPhone 15/ })) или по testid с бизнес-ключом. Иначе тест ломается от смены сортировки, пагинации или новых данных.
  • Неуникальный локатор. getByRole('button', { name: 'Удалить' }), когда таких кнопок пять, — это либо strict-mode ошибка (Playwright), либо молчаливый клик по первой попавшейся (старый Selenium). Скоупь поиск в контейнер: сначала находишь нужную карточку, потом кнопку внутри неё.
  • Скрытые и дубликаты в DOM. Модалки, которые лежат в разметке всегда, off-screen-меню, задвоенные элементы для мобильной/десктопной вёрстки. Локатор находит невидимый — тест кликает «в пустоту». Фильтруй по видимости и скоупу.
  • Тень и iframe. Shadow DOM и iframe не пробиваются обычным CSS насквозь. Playwright пронзает открытый Shadow DOM автоматически, для iframe нужен frameLocator; в Selenium — явный switchTo().frame().

Как это выглядит в разных инструментах

Принцип один и тот же везде — «роль/доступное имя выше, чем структура», меняется только API.

  • Playwright. Встроенные getByRole, getByLabel, getByText, getByTestId идут ровно в рекомендованном приоритете, плюс авто-ожидание и strict mode, который падает на неуникальном локаторе — то есть подсказывает проблему сразу.
  • Testing Library (React/Vue/JS). Задала сам принцип приоритета запросов: getByRolegetByLabelTextgetByTextgetByTestId в конце. Философия «тестируй так, как пользуется пользователь».
  • Cypress. Официально рекомендует data-*-атрибуты и cy.get('[data-testid=...]'), явно отговаривая от привязки к тегам/классам/id, которые меняет вёрстка и стили.
  • Selenium. Ниже уровнем: By.id и By.name стабильнее, By.cssSelector по осмысленному атрибуту терпим, By.xpath по позиции — последнее средство. Паттерн Page Object тут не про локаторы сами по себе, а про то, чтобы держать их в одном месте и чинить в одной точке.
  • Appium (мобилки). Аналог data-testid — accessibility id (content-desc в Android, accessibilityIdentifier в iOS). Он же нужен незрячим пользователям, так что это не «тестовый костыль», а полезный атрибут.

Чек-лист: хороший локатор

  • Выражает что за элемент (роль/имя/бизнес-ключ), а не где он лежит в DOM.
  • Не привязан к позиции (nth-child, div[2], индексам строк).
  • Не цепляется за автогенеренные классы (css-1a2b3c, jss42, sc-...).
  • Переживает смену языка/копирайта (или текст и есть предмет проверки).
  • Уникален в нужном скоупе; при неуникальности — сначала контейнер, потом элемент.
  • Находит именно видимый элемент, а не скрытый дубликат.
  • Единая конвенция data-testid (одно имя атрибута, читаемые стабильные значения).
  • testid добавлен разработчиком вместе с фичей, а не пропатчен QA сверху.
  • Локаторы вынесены из тела теста (Page Object / фикстуры) — правишь в одном месте.

Антипаттерны — красные флаги в ревью

  • //div[2]/div[1]/span[3] и любой XPath по индексам нод.
  • .css-1x7f9a2, .sc-bdVaJa, .jss17 — хэши CSS-in-JS/модулей.
  • nth-child, first(), last() как способ «попасть в нужный из многих».
  • Локатор по видимому тексту в мультиязычном приложении.
  • Гигантский CSS-путь body > div > div > main > section > ....
  • Один и тот же локатор скопирован в десять тестов вместо Page Object.

Коротко — что забрать с собой

  • UI-тесты чаще падают от хрупких локаторов, чем от багов. Устойчивость закладывается выбором селектора.
  • Приоритет: роль/доступное имя → data-testid → текст → осмысленный CSS → XPath по позиции в самом конце.
  • XPath по индексам и автогенеренные классы — гарантированная хрупкость.
  • data-testid — не «мусор в проде», а контракт с разработкой; договоритесь об имени и конвенции заранее.
  • Локатор должен выражать что за элемент, а не где он лежал сегодня.

Почитать: Playwright — Locators · Playwright — Best Practices · Testing Library — приоритет запросов · Kent C. Dodds — Making UI tests resilient to change · Cypress — Best Practices · Selenium — Locator strategies