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

Меня зовут Лёша, и я занимаюсь тестированием в 2ГИС. Моя команда работает над сервисом по бронированию отелей Отелло. В этой статье я расскажу, как использовать кастомные локаторы, чтобы тесты оставались стабильными и поддерживаемыми даже при изменениях в приложении. В примерах я использую Python, но не стоит пугаться, если не знакомы с этим языком: сложных операций не будет.

Проблемы стандартных локаторов 

Когда я начинал описывать свои первые локаторы несколько лет назад, они выглядели так:

class LoginPageLocators:
    REGISTRATION_EMAIL = (By.CSS_SELECTOR, '#id_registration-email')
    REGISTRATION_PASSWORD = (By.CSS_SELECTOR, '#id_registration-password')
    REGISTRATION_PASSWORD_REPEAT = (By.CSS_SELECTOR, '#id_registration-repeat')

    LOGIN_EMAIL = (By.CSS_SELECTOR, "#id_login-username")
    LOGIN_PASSWORD = (By.CSS_SELECTOR, "#id_login-password")

Как и многие другие тестировщики, я использовал CSS-локаторы, иногда XPath. Подобный стиль описания подходит для тестирования небольших сайтов, однако при работе с более сложными проектами я заметил следующие проблемы↓

Сложные или изменяющиеся структуры DOM

Бывали ситуации, когда я описал все локаторы, написал отличные тесты, но через некоторое время они начинали падать… А причина в том, что разработчик изменил имя класса, id или решил убрать лишний div. Это значительно замедляло процесс написания и поддержки тестов, так как приходилось выделять дополнительное время на разбор структуры страницы и коммуникацию с разработчиками.

А вы тоже писали громоздкие XPath-пути, которые больше напоминали строительные леса, нежели аккуратный код, только потому, что невозможно было подобрать подходящий CSS-локатор?

Динамически генерируемые идентификаторы

Некоторые веб-приложения создают динамические идентификаторы для элементов, которые изменяются при каждом обновлении страницы. В таких случаях традиционные селекторы становятся бесполезными, так как невозможно точно указать элемент по его id или классу и остается полагаться только на XPath.

Недоступные уникальные атрибуты

Иногда элементы не имеют уникальных атрибутов для их поиска. Например, если на странице несколько кнопок с одинаковыми классами и атрибутами, это может вызвать путаницу при их выборе.

От стандартных локаторов к кастомным

Раньше подобные проблемы я считал неизбежными, пока не пришёл в 2ГИС. Здесь локаторы описывались не привычным мне способом, а кастомными data-атрибутами. Эта практика не является чем-то новым: ещё несколько лет назад на конференции Heisenbug Станислав Васенков подробно рассказывал о подобном подходе, акцентируя внимание на важности удобных и стабильных локаторов. Тем не менее, я уверен, что тема всё ещё актуальна.

Кастомные локаторы

Это пользовательские селекторы, которые тестировщики сами указывают в коде фронта. Они не зависят от структуры DOM и используются только в тестах, что делает их устойчивыми к изменениям в вёрстке.

Важная ремарка. Чтобы реализовать кастомные локаторы, необходим доступ к коду проекта. Конечно, можно просить разработчиков добавлять их, но тогда теряется ряд преимуществ, о которых расскажу далее.

Основные атрибуты кастомных локаторов:

  • name — уникальное имя объекта, которое обычно начинается с префикса wat‑, test‑, locator‑, в зависимости от соглашений конкретной команды. Например, wat‑login‑link или test‑search‑button.

  • type — тип элемента. Например, цвет логотипа в зависимости от цветовой схемы пользователя может отличаться, в таком случае у двух картинок будет одинаковый data‑name («wat‑logo‑image»), но разные data‑type («dark» и «light»).

  • value — значение элемента. Например, индекс элемента в списке выдачи.

  • state — состояние элемента. Например, состояние кнопки (активна или задизейблена).

У элемента может быть как один атрибут name, так и несколько атрибутов: name, type, etc.

Пример:

<html>
 <body>
   <div class="header">
     <img data-n="wat-logo-image" data-t="dark" src="logo-dark.png" alt="Dark Logo">
     <img data-n="wat-logo-image" data-t="light" src="logo-light.png" alt="Light Logo">
     <button class="search" data-n="wat-search-button">Search</button>
     <button data-n="wat-login-button" data-s="active">Login</button>
   </div>

   <div>
     <ul data-n="wat-element-of-list">
       <li data-n="wat-element-of-list" data-v="1">Sherlock Holmes</li>
       <li data-n="wat-element-of-list" data-v="2">Harry Potter</li>
       <li data-n="wat-element-of-list" data-v="3">Indiana Jones</li>
     </ul>
   </div>
 </body>
</html>

Эти локаторы уникальны и не используются нигде, кроме тестов. Это делает код понятным и легко читаемым. Например, рассмотрим код с использованием атрибутов locator-name и сравним с XPath. Вместо длинного выражения:

//div[@class='header']//button[contains(@class, 'search')]

можно написать лаконично:

make_locator(name='wat-search-button')

По сути, используется тот же XPath, но писать и читать такой локатор в коде будет сильно проще.

Функция make_locator может вернуть локатор '//*[@data-n="wet-search-button"]'

Вот реализация функции make_locator:

def make_locator(name: str, type_: Optional[str] = None, value: Optional[str] = None, state: Optional[str] = None):
   value = f'[@data-v="{value}"]' if value else ''
   type_ = f'[@data-t="{type_}"]' if type_ else ''
   state = f'[@data-s="{state}"]' if state else ''
   return f'//*[@data-n="{name}"]{value}{type_}{state}'

Также отмечу, что все локаторы в прокте у нас начинаются с пефикса “wat-”. Во-первых. это позволяет быстро идентифицировать селектор как локатор. Во-вторых, избавляет от случайных совпадений с любым другим текстом в коде, ведь префикс используется только для локаторов.

Преимущества кастомных локаторов

Кастомные локаторы решают множество проблем:

  • Устойчивость к изменениям. Если разработчик меняет классы или структуру DOM, кастомные локаторы остаются неизменными.

  • Читабельность. Локаторы с уникальными именами проще понимать и поддерживать. Например, вместо сложного XPath можно записать wat-hotel-dashboard-page или wat-main-page-text. 

  • Меньше зависимости от фронтенда. Тестировщики могут сами задавать локаторы, не полагаясь на текущую структуру страницы.

  • Упрощение написания и поддержки тестов. Больше не нужно использовать длинные и сложные XPath.

Вот примеры локаторов, которые я приводил в самом начале статьи:

class MainPageLocators:
   LOGIN_LINK = make_locator("wat-login_link")


class LoginPageLocators:
   REGISTRATION_EMAIL = make_locator('wat-registration-email')
   REGISTRATION_PASSWORD = make_locator('wat-registration-password')
   REGISTRATION_PASSWORD_REPEAT = make_locator('wat-registration-repeat')

   LOGIN_EMAIL = make_locator("wat-login-username")
   LOGIN_PASSWORD = make_locator("wat-login-password")


class ProductPageLocators:
   BUY_BUTTON = make_locator("wat-add-to-basket-button")
   SUCCESS_MESSAGE = make_locator("wat-success_message")

Улучшение структуры кода

В 2ГИС мы пошли дальше и внедрили несколько дополнительных подходов, чтобы сделать работу с локаторами ещё удобнее.

Разделение элементов на типы

У разных элементов на странице часто есть уникальные наборы действий. Например, текстовые элементы требуют только получения текста, в то время как кнопки можно кликать и проверять их состояние при наведении. Поэтому мы разделили элементы на группы, выделив для каждой группы свой набор методов.

Для этого создали иерархию классов, которая логически разделяет элементы. Базовый класс WebElement содержит универсальные методы, применимые ко всем элементам. От него наследуются другие классы, такие как Clickable, Textable и SimpleElement, которые добавляют специфические методы для работы с различными типами элементов. Эти классы являются классами-свойствами, мы не используем их напрямую для описания элемента, а только для описания типов. 

  • Clickable — наследуется у классов, которые описывают элементы, которые можно кликать. Например, кнопка может быть активной или неактивной. Если кнопка неактивна, мы не можем на неё кликнуть, и нам нужно проверить это в тесте. 

  • Textable —  наследуется у классов, которые описывают элементы, у которых можно получать текст. Текстовые элементы также могут содержать текст или не содержать его. Например, кнопка может содержать текст, а из картинки тест достать не выйдет.

  • SimpleElement — наследуется у классов, которые описывают универсальные элементы без специфичного поведения.

Пример иерархии классов

Пример реализации классов:

class WebElement:
    def __init__(self, locator):
        self.locator = locator

    def is_visible(self):
        # Проверка видимости элемента
        pass

class Clickable(WebElement):
    def click(self):
        # Логика клика по элементу
        pass

    def hover(self):
        # Логика наведения курсора
        pass

class Textable(WebElement):

    def get_text(self):
        # Логика получения текста элемента
        pass

class SimpleElement(WebElement):
    pass

class BaseButton(Clickable):
    pass

class BaseText(Textable):
    pass

А так выглядят обновленные локаторы:

class MainPageLocators:
   LOGIN_LINK = BaseLink("wat-login_link")


class LoginPageLocators:
   REGISTRATION_EMAIL = BaseInput('wat-registration-email')
   REGISTRATION_PASSWORD = BaseInput('wat-registration-password')
   REGISTRATION_PASSWORD_REPEAT = BaseInput('wat-registration-repeat')

   LOGIN_EMAIL = BaseInput("wat-login-username")
   LOGIN_PASSWORD = BaseInput("wat-login-password")


class ProductPageLocators:
   BUY_BUTTON = BaseButton("wat-add-to-basket-button")
   SUCCESS_MESSAGE = BaseElement("wat-success_message")

Теперь при написании тестов IDE автоматически подсказывает доступные действия для конкретного элемента. Например, если мы обращаемся к кнопке, то IDE предложит методы для клика и проверки состояния. Если это текстовый элемент, то предложит только методы для получения текста. Кроме того, IDE подсказывает только те действия, которые возможны для конкретного локатора. Например, для некликабельного элемента не будет доступен метод клика. Это значительно упрощает работу, так как мы сразу видим, какие действия можно выполнить с локатором. Такой подход делает код более читаемым и понятным.

Древовидная структура локаторов

Ещё один способ улучшить тесты — использовать древовидную систему обращения к элементу. Чтобы проверить видимость блока, мы идём к его родителю, а потом дальше по дереву до самого элемента. Это помогает обращаться именно к нужному элементу. Например, если есть текст, который может быть в разных кнопках, и необходимо получить его, используя button.text_content(), мы можем получить несколько элементов с локатором button.

Когда мы идём от родителя к ребёнку, мы проверяем, что элемент находится в ожидаемом месте. Если он переместился в другой контейнер, мы его не найдём. Это гарантирует, что тест проверяет именно то, что нам нужно, а не первый попавшийся элемент. Однако, гарантия эта будет работать только в том случае, если элемент сменит родителя, например, переместившись в другой блок. Что делать, если у родителя появляется еще один такой ребенок? Я рекомендую вам всегда проверять, что локатор, к которому идет обращение, уникален и возвращать ошибку, если это не так.

Для реализации древовидной структуры локаторов в ваших Python-тестах можно использовать библиотеку Web-Bricks. У себя под капотом она хранит необходимую логику для построения обращения по дереву.

Ниже пример одной из страниц. Здесь логика make_locator, описанная ранее, уже спрятана внутри классов:

class AuthSection(BaseElement):
   LOCATOR = 'wat-auth-section'

   @property
   def login(self):
       return BaseInput('wat-login-username')

   @property
   def password(self):
       return BaseInput('wat-login-password')

class RegistrationSection(BaseElement):
   LOCATOR = 'wat-registration-section'

   @property
   def email(self):
       return BaseInput('wat-registration-email')

   @property
   def password(self):
       return BaseInput('wat-registration-password', 'initial')

   @property
   def repeat_password(self):
       return BaseInput('wat-registration-password', 'repeat')


class LoginPage(BasePage):

   @property
   def auth_section(self):
       return AuthSection

   @property
   def registration_section(self):
       return RegistrationSection

При выполнении теста необходимо идти от родителя к ребенку и проверять отображение каждого последующего элемента на странице:

page: LoginPage = opened_login_page()

page.auth_section.login.fill('test@mail.com')
page.auth_section.password.fill('password')

assert page.success_message.is_visible()

Заключение

Мой путь от стандартных локаторов к кастомным показал, что даже сложные задачи можно упростить, если использовать правильные подходы. Применив вышеописанные подходы, я реально забыл, как пользоваться XPath и СSS-селекторами, потому что мне не нужно о них думать и я могу сфокусировать внимание на более важных вещах.

Надеюсь, мой опыт будет полезен и вам!

Комментарии (7)


  1. mihmig
    23.01.2025 12:20

    Разработчики одного из банков не стали заморачиваться с удалением тестовых тегов на "проде":


  1. grg2000
    23.01.2025 12:20

    Интересно, а можно узнать подробнее каким образом реализуется удаление кастомных локаторов на проде?


    1. RealLazyCat
      23.01.2025 12:20

      технически это просто:
      есть коммит разработки.
      добавляют тест-локаторы.
      делают коммит
      раскатывают и тестируют на тест -стенде
      откатывают коммит
      раскатывают на проде
      или вообще добавляют в отдельной ветке локаторы. которую потом удаляют

      но вот процесс вставки тест-локаторов интересен, не руками же их вставляют


      1. grg2000
        23.01.2025 12:20

        Звучит заморочно, и как будто бы такие локаторы будут каждый раз теряться при релизе, либо в через определенное время придется при релизах кучу тестовых комитов откатывать. В общем интересно как у автора это на проекте реализовано.

        Когда я сталкивался с аналогичными решениями - приходилось самим ходить во фронтовый код и вставлять локаторы, потом скриптом парсили их все сразу в проект теста.


    1. teka_d Автор
      23.01.2025 12:20

      Можно фильтровать кастомные локаторы на этапе сборки, используя переменные окружения. Например, в React-приложении функция для добавления локатора может выглядеть так:

      export function locator(name?: string, type?: string, value?: string, state?: string) {
        if (process.env.APP_ENV === 'production') {
          return {};  // На проде возвращаем пустой объект (локаторы не добавляются)
        }
        return { 'data-n': name, 'data-t': type, 'data-v': value, 'data-s': state };
      }

      Примеры использования:

      <button {...locator('wat-login-button', 'primary')} />
      • В development-среде кнопка получит атрибуты:

        <button data-n="wat-login-button" data-t="primary"></button>
      • В production-среде атрибуты добавляться не будут:

        <button></button>

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


  1. rsashka
    23.01.2025 12:20

    Некоторые веб-приложения создают динамические идентификаторы для элементов, которые изменяются при каждом обновлении страницы. В таких случаях традиционные селекторы становятся бесполезными, так как невозможно точно указать элемент по его id или классу и остается полагаться только на XPath.

    Вот в этом и состоит корень зла. А ваше решение, это фактически возврат к нормальным XPath и СSS-селекторами, как они задумывались изначально.


    1. teka_d Автор
      23.01.2025 12:20

      Раньше тоже так думал, потом пообщался с коллегами из других компаний, оказалось, что не всем это очевидно. Что уж там, сам я тоже когда-то пользовался исключительно селекторами, созданными разработчиками. Потому решил, что тема все ещё может быть актуальна.