Локаторы: 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']).
И иерархия предпочтений, если запомнить только одно из урока:
data-testid(или другой согласованный тестовый атрибут);- осмысленный статичный
id; - семантика и ARIA-атрибуты;
nameдля полей форм;- CSS по устойчивым классам предметной области (
.cart-item, а не.mb-2); - 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, иначе поиск уйдёт в корень документа.