Локаторы: id, css, xpath

Локатор — это адрес элемента на странице. Разберём все восемь стратегий поиска, поймём, почему id вне конкуренции, честно сравним CSS и XPath и научимся писать локаторы, которые не разваливаются от каждой правки вёрстки.

Локатор — правило, по которому WebDriver находит элемент в DOM (Document Object Model — дерево объектов страницы, каким его видит браузер). Технически это пара «стратегия + строка запроса»: например, стратегия By.ID и строка login-form.

Зачем вообще думать о локаторах

Начинающий автоматизатор обычно рассуждает так: тест упал — значит, в приложении баг. На практике в незрелом проекте до 80% падений UI-тестов — это не баги продукта, а сломанные локаторы. Вёрстальщик поменял класс кнопки с btn-primary на button--main, дизайнер добавил обёртку div вокруг формы — и полсотни тестов покраснели, хотя приложение работает идеально.

Такие тесты не просто бесполезны: они вредны. Команда перестаёт верить красному билду («да это опять локаторы протухли»), и однажды за этим шумом проезжает настоящий баг. Поэтому качество локаторов — не вопрос вкуса, а вопрос того, будет ли автотестам вообще кто-то доверять.

Возьмём для примера типичную форму входа. Именно её мы будем «нащупывать» разными стратегиями.

<form id="login-form" class="card card--auth">
  <input name="username" class="form-control input-lg" data-testid="login-username">
  <input name="password" type="password" class="form-control input-lg" data-testid="login-password">
  <a href="/restore" class="link muted">Забыли пароль?</a>
  <button class="btn btn-primary" data-testid="login-submit">Войти</button>
</form>

Восемь стратегий поиска

В Python-биндингах Selenium стратегии живут в классе By. Их ровно восемь, и все они принимают одну строку-запрос.

from selenium.webdriver.common.by import By

# 1. По атрибуту id — самый быстрый и самый однозначный способ
driver.find_element(By.ID, "login-form")

# 2. По атрибуту name — жив в старых формах и в HTML, где name обязателен
driver.find_element(By.NAME, "username")

# 3. По ОДНОМУ классу (не по списку!) — By.CLASS_NAME, "btn btn-primary" упадёт
driver.find_element(By.CLASS_NAME, "btn-primary")

# 4. По имени тега — полезно разве что для body, table, iframe
driver.find_element(By.TAG_NAME, "form")

# 5. По полному тексту ссылки (только для тега a!)
driver.find_element(By.LINK_TEXT, "Забыли пароль?")

# 6. По части текста ссылки
driver.find_element(By.PARTIAL_LINK_TEXT, "Забыли")

# 7. CSS-селектор — рабочая лошадка
driver.find_element(By.CSS_SELECTOR, "[data-testid='login-submit']")

# 8. XPath — тяжёлая артиллерия
driver.find_element(By.XPATH, "//button[normalize-space()='Войти']")

Обратите внимание на подводный камень номер три: By.CLASS_NAME ищет по одному имени класса. Строка btn btn-primary с пробелом — это не «элемент с двумя классами», а невалидный запрос, и вы получите InvalidSelectorException. Нужны два класса сразу — берите CSS: .btn.btn-primary.

СтратегияКогда братьРиск
By.IDвсегда, если id есть и он осмысленныйфреймворк может генерировать id вида ext-gen-42
By.NAMEполя формимена не уникальны на странице
By.CSS_SELECTORдефолт для всего остальногозавязка на классы вёрстки
By.XPATHпоиск по тексту, движение вверх по деревулегко написать хрупкий монстр-локатор
By.CLASS_NAME / By.TAG_NAMEпочти никогдаслишком широкий охват
By.LINK_TEXTссылки в простых приложенияхломается при смене локали и от лишнего пробела

Почему id — лучший локатор

По спецификации HTML атрибут id уникален в пределах документа. Это даёт три бонуса сразу. Во-первых, скорость: браузер держит индекс по id, поиск — это обращение к хеш-таблице, а не обход дерева. Во-вторых, однозначность: не бывает «а какой из трёх найденных элементов имеется в виду». В-третьих, устойчивость: id обычно ставят осознанно, под смысл элемента, а классы — под внешний вид, и меняют их вместе с дизайном.

Но есть важное «но». Если id генерируется на лету — id="ember1042", id=":r3:", id="input-8f3a1c" — он бесполезен: после следующей перерисовки страницы номер поедет. Такой id ничем не лучше случайного числа. Отличать легко: перезагрузите страницу и посмотрите, изменился ли id.

CSS против XPath

Священная война автоматизаторов. Правда прозаична: CSS быстрее и читаемее, XPath — мощнее.

CSS-селектор браузер выполняет нативным document.querySelector — это оптимизированный движок, который используется при отрисовке каждой страницы миллионы раз в секунду. XPath же исполняется через document.evaluate: движок отдельный, медленнее, а в старых версиях Internet Explorer его вообще не было и Selenium подсовывал JS-эмуляцию (отсюда и легенды про «XPath дико тормозит»). На современных страницах разница между CSS и XPath — единицы миллисекунд, и на фоне сетевых задержек её не видно. Так что «скорость» — не главный аргумент.

Главный аргумент — читаемость. Сравните:

# CSS: коротко и понятно
"[data-testid='login-submit']"
"form#login-form input[name='password']"
"ul.cart-items > li:nth-child(2) .price"

# XPath: то же самое, но многословнее
"//button[@data-testid='login-submit']"
"//form[@id='login-form']//input[@name='password']"
"//ul[contains(@class,'cart-items')]/li[2]//*[contains(@class,'price')]"

Зато есть три вещи, которые CSS не умеет вовсе, и тут XPath незаменим:

# 1. Поиск по ТЕКСТУ элемента (CSS текст не видит в принципе)
"//button[normalize-space()='Оформить заказ']"

# 2. Движение ВВЕРХ по дереву: нашли текст — взяли строку таблицы целиком
"//td[normalize-space()='Ноутбук']/ancestor::tr"

# 3. Поиск соседа по смыслу: строка товара, у которой внутри есть нужное имя,
#    и в ней — кнопка удаления
"//tr[.//span[normalize-space()='Ноутбук']]//button[@data-testid='remove']"

normalize-space() — маленький герой этой истории: он схлопывает лишние пробелы и переносы строк по краям. В вёрстке текст почти всегда обёрнут переносами, поэтому text()='Войти' регулярно не находит ничего, а normalize-space()='Войти' — находит.

Как писать устойчивые локаторы

Правило одно: локатор должен цепляться за смысл элемента, а не за его оформление. Классы btn-primary, mt-4, css-1x3jz8h (привет, CSS-in-JS) — это оформление, оно меняется каждый спринт. Атрибут data-testid — это смысл, его никто не тронет, пока функция жива.

from selenium.webdriver.common.by import By

class LoginPage:
    # ХОРОШО: контракт с фронтендом, живёт годами
    USERNAME = (By.CSS_SELECTOR, "[data-testid='login-username']")
    PASSWORD = (By.CSS_SELECTOR, "[data-testid='login-password']")
    SUBMIT = (By.CSS_SELECTOR, "[data-testid='login-submit']")
    ERROR = (By.CSS_SELECTOR, "[data-testid='login-error']")

    def login(self, user, password):
        self.driver.find_element(*self.USERNAME).send_keys(user)
        self.driver.find_element(*self.PASSWORD).send_keys(password)
        self.driver.find_element(*self.SUBMIT).click()

Звёздочка в find_element(*self.USERNAME) распаковывает кортеж в два аргумента — стратегию и строку. Хранить локаторы кортежами удобно: их можно передавать в ожидания как есть, без переписывания.

Если data-testid в проекте нет — сходите к фронтендерам и договоритесь. Это пятиминутная правка в их коде, которая экономит недели вашей. Аргумент для менеджера простой: «атрибут для тестов дешевле, чем чинить локаторы после каждого релиза». Пока договариваетесь, стройте локаторы на том, что стабильно по своей природе: id, name, роль и семантику ([role='dialog'], [aria-label='Закрыть'], button[type='submit']).

И иерархия предпочтений, если запомнить только одно из урока:

  1. data-testid (или другой согласованный тестовый атрибут);
  2. осмысленный статичный id;
  3. семантика и ARIA-атрибуты;
  4. name для полей форм;
  5. CSS по устойчивым классам предметной области (.cart-item, а не .mb-2);
  6. XPath по тексту — только когда иначе никак.

Как это работает

Когда вы пишете driver.find_element(By.CSS_SELECTOR, "..."), клиент Selenium отправляет драйверу браузера HTTP-запрос по протоколу W3C WebDriver: POST /session/<id>/element с телом вида {"using": "css selector", "value": "[data-testid='login-submit']"}. Драйвер (chromedriver, geckodriver) выполняет поиск внутри браузера и возвращает не сам элемент, а ссылку на элемент — уникальный идентификатор, за которым в памяти драйвера закреплён конкретный узел DOM.

Любопытная деталь: в W3C-протоколе стратегий поиска всего четыре — css selector, xpath, link text, partial link text. Остальные четыре (id, name, class name, tag name) клиентская библиотека Selenium сама переписывает в CSS: By.ID, "login-form" уезжает на сервер как селектор [id="login-form"]. То есть под капотом ваш «поиск по id» — это тот же CSS, просто с человеческим интерфейсом.

Второй важный момент — контекст поиска. Метод find_element есть не только у драйвера, но и у самого элемента: тогда поиск идёт только внутри его поддерева.

card = driver.find_element(By.CSS_SELECTOR, "[data-testid='product-42']")

# CSS честно ищет ВНУТРИ карточки
price = card.find_element(By.CSS_SELECTOR, ".price")

# ЛОВУШКА: XPath с ведущим // игнорирует контекст и уходит в корень документа!
wrong = card.find_element(By.XPATH, "//button[@data-testid='buy']")   # первая кнопка на ВСЕЙ странице

# Правильно — точка в начале означает «от текущего элемента»
right = card.find_element(By.XPATH, ".//button[@data-testid='buy']")

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

Частые ошибки

  • Copy → XPath из DevTools. Браузер выдаёт что-то вроде /html/body/div[3]/div[2]/main/section/div[1]/button. Такой локатор ломается от любого лишнего div. Абсолютные пути в тестах не живут дольше одного спринта.
  • Опора на утилитарные классы. .mt-4, .text-center, .btn-lg описывают внешний вид. Дизайнер сдвинул отступ — тест упал.
  • Опора на сгенерированные классы. .css-1x3jz8h, .module_button__a8f2 — это хеши сборки, они меняются при пересборке фронтенда.
  • Локатор по видимому тексту без нужды. Появится английская локаль — и //button[text()='Войти'] перестанет работать. Текст проверяйте, но искать по нему старайтесь только там, где выбора нет.
  • Хрупкий nth-child. li:nth-child(3) означает «третий по счёту», а не «нужный». Добавят баннер в список — и вы кликнете не туда.
  • Неуникальный локатор. find_element молча возвращает первый подходящий элемент. Всегда проверяйте селектор в консоли браузера: document.querySelectorAll("...").length должно давать 1. Для XPath — $x("...") в DevTools Chrome.
  • Забытый iframe. Элемент видно глазами, а find_element его не находит — почти наверняка он внутри iframe, и нужен driver.switch_to.frame(...). Локатор тут ни при чём.

Итоги

  • Локатор — это пара «стратегия + запрос»; в Selenium восемь стратегий, но реально нужны By.ID, By.CSS_SELECTOR и By.XPATH.
  • By.ID — самый быстрый и однозначный, но только если id статичный, а не сгенерированный фреймворком.
  • CSS быстрее и читаемее; XPath берите ради поиска по тексту и движения вверх по дереву (ancestor::).
  • Устойчивый локатор цепляется за смысл (data-testid, id, ARIA), а не за оформление (классы, порядковые номера, абсолютные пути).
  • Под капотом By.ID и By.NAME превращаются в обычный CSS-селектор — магии нет.
  • Внутри элемента XPath пишите с точки: .//button, иначе поиск уйдёт в корень документа.
Проверьте себя
1. Какой локатор кнопки в форме входа переживёт редизайн с наибольшей вероятностью?
ABy.XPATH, /html/body/div[3]/form/button
BBy.CLASS_NAME, btn-primary
CBy.CSS_SELECTOR, [data-testid='login-submit']
DBy.CSS_SELECTOR, .card > div:nth-child(4) > button
2. Вы нашли карточку товара в переменной card и внутри неё ищете кнопку через card.find_element(By.XPATH, //button). Что произойдёт?
AПоиск пойдёт от корня документа и вернёт первую кнопку на всей странице
BПоиск ограничится карточкой — ведущие слэши на это не влияют
CSelenium бросит InvalidSelectorException
DВернётся пустой список