Разделы портала

Онлайн-тренинги

.
Сильные локаторы элементов для тестов фронтэнда
25.10.2023 00:00

Автор: Марк Нунан (Mark Noonan).
Оригинал статьи
Перевод: Ольга Алифанова

Автоматизированные тесты фронтэнда – это здоров. Мы можем написать тест с кодом посещения страницы – или загрузить лишь один компонент – и заставить код теста кликать по элементам или печатать так, как это сделал бы пользователь, а затем проверять состояние приложения после этих взаимодействий. Таким образом можно убедиться, что все, описанное в тестах, работает в приложении правильно.

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

Структура фронтэнд-теста

Для создания тестов полезно знать классический шаблон – Настрой, Действуй, Проверь. Для фронтэнд-тестов это выглядит как тест-файл, делающий вот что:

  1. Настрой: Подготовка к тесту. Посещение определенной страницы, монтаж определенного компонента с правильными свойствами, имитация какого-либо состояния, и т. п.
  2. Действуй: Какие-либо действия в приложении. Клик по кнопке, заполнение формы, и т. д. Для простых проверок состояния этот шаг можно пропустить.
  3. Проверь: Проверка чего-либо. Появилось ли благодарственное сообщение после отправки формы? Были ли переданы правильные данные на бэкэнд при помощи POST-запроса?

Для определения, с чем именно взаимодействовать, а потом – что проверять на странице, можно использовать различные локаторы элементов, чтобы нацелиться на нужные нам части DOM.

Локатор может быть ID элемента, текстовым содержанием элемента или CSS-селектором: например, .blog-post или даже article > div.container > div > div > p:nth-child(12). Все, относящееся к элементу и способное идентифицировать этот элемент для прогонщика тестов, может быть локатором. Как вы, возможно, уже догадались, изучая CSS-селектор, у локаторов много разновидностей.

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

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

Руководство по локаторам элементов фронтэнд-тестирования для начинающих

Для начала представим, что нам нужно написать инструкции по выполнению работы реальным человеком. Новый инспектор ворот только что нанят в ООО «Инспекторы Ворот». Вы начальник, и после того, как все познакомились, вам нужно выдать новичку инструкции по первому инспектированию ворот. Дабы он преуспел, возможно, не стоит писать подобную записку:

«Иди мимо желтого дома, продолжай движение, пока не дойдешь до поля, где в тот раз потерялась коза подруги Мишиной мамы; затем поверни налево и скажи мне, открываются ли ворота перед домом через дорогу».

Эти инструкции похожи на использование длинного CSS-селектора или XPath в качестве локатора. Они хрупкие – и «по-плохому хрупкие». Если желтый дом перекрасят, то при повторе шагов ворота будет невозможно найти, и сотрудник сдастся (или, в данном случае, упадет тест).

Схожим образом, не зная о происшествии с козой подруги Мишиной мамы, вы не сможете остановиться в нужной точке и проверить нужные ворота. Именно это делает «плохую хрупкость» плохой – тест может сломаться по тысяче причин, и ни одна из них не имеет отношения к работоспособности ворот.

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

«Иди к воротам номер 1234 и проверь, открываются ли они».

Это похоже на поиск элемента по его ID. Это более стабильно, и содержит всего один шаг. Все точки отказа прошлого теста устранены. Это тест упадет только в случае, когда ворота с этим ID не открываются.

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

В следующий раз, когда новенький инспектор отправляется тестировать «Ворота 1234», он натыкается на другие ворота, посещает неправильный дом и проверяет не то, что нужно. Тест может упасть, или, что еще хуже, сработать, если ворота работают как надо – однако тест проверяет не то, что нужно. Он дает ложную уверенность. Он будет продолжать срабатывать, даже если нужные нам ворота украдены воротными ворами под покровом ночи.

После подобного инцидента становится ясно, что ID не так стабильны, как нам казалось. Мы продолжаем размышлять и решаем, что хотим иметь специальный ID на внутренней стороне ворот – только для тестирования. Мы направляем технического специалиста, чтобы он разместил этот специальный ID только на этих воротах. Новые тест-инструкции выглядят так:

«Иди к воротам с тест-ID «my-favorite-gate» и проверь, открываются ли они».

Это похоже на использование популярного атрибута data-testid. Такие атрибуты очень хороши – в коде очевидно, что они предназначены для автоматизированных тестов, и их нельзя менять или удалять. Пока у ворот есть этот атрибут, вы всегда их найдете. Как и в случае с ID, уникальность не контролируется, но она более вероятна.

Это максимально далекий от хрупкости вариант, помогающий проверить функциональность ворот. Теперь мы зависим только от специально добавленного для тестирования атрибута. Но тут скрывается одна проблема…

Это тест пользовательского интерфейса ворот, но локатор – то, чем пользователь для поиска ворот никогда не воспользуется.

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

Если мы ищем ворота при помощи тест-ID, и ворота перекрашены, а номер дома замазан, тест все еще будет срабатывать. Но весь смысл ворот в том, чтобы люди могли попасть в дом. Иными словами, рабочие ворота, которые пользователь не способен найти – это повод для падения теста, и нам нужен локатор, способный выявить эту проблему.

Вот еще одна попытка создать тест-инструкцию для инспектора-новичка:

«Иди к воротам дома номер 40 и проверь, открываются ли они».

Тут используется локатор, добавляющий тесту ценности: он зависит от того же, от чего зависят пользователи – от метки ворот. У теста есть потенциальная причина упасть еще до того, как дело дойдет до реального взаимодействия, которое мы хотим проверить – и на первый взгляд это выглядит не очень. Но в данном случае локатор значим с точки зрения пользователя, и его нельзя сбрасывать со счетов, как «хрупкий». Если ворота нельзя найти по их метке, без разницы, открываются ли они – это «хорошая хрупкость».

DOM имеет значение

Множество советов по фронтэнд-тестированию гласит, что нужно избегать локаторов, зависимых от структуры DOM. Это означает, что разработчики могут со временем рефакторить компоненты и страницы, оставляя тестам проверку, что потоки задач пользователя не нарушены – без необходимости обновлять тесты в соответствии с новой структурой. Это полезный принцип, но я бы немного его подправил – при фронтэнд-тестировании нужно избегать локаторов, зависящих от не относящейся к делу структуры DOM.

Чтобы приложение верно функционировало, DOM должна отражать природу и структуру содержимого страницы в любой момент времени. Во-первых, это связано с доступностью. Правильная в этом смысле DOM сильно упрощает вспомогательным технологиям правильность анализа отражаемого браузером содержимого и его описание для пользователей, не способных его увидеть. Структура DOM и старый добрый HTML играют огромную роль в независимости пользователей, опирающихся на вспомогательные технологии.

Набросаем фронтэнд-тест, передающий что-либо в форму контактов нашего приложения. Для этого мы воспользуемся Cypress, но принципы выбора локаторов стратегически применимы к любым фреймворкам фронтэнд-тестирования, пользующихся DOM для поиска элементов. В тесте мы находим элементы, вводим текст, отправляем форму и проверяем достижение состояния «Получено благодарственное сообщение».

//Не рекомендуется
cy.get('#name').type('Mark')
cy.get('#comment').type('test comment')
cy.get('.submit-btn').click()
cy.get('.thank-you').should('be.visible')

В этих четырех строчках заключено множество разнообразных неявных проверок. cy.get() проверяет, что элемент существует в DOM. Тест упадет, если элемент по прошествии определенного времени не появился, а действия вроде type и click вначале проверяют, что элементы видимы, доступны и ничем не заблокированы, и лишь затем переходят к делу.

Итак, мы получаем многое «бесплатно» даже в таком простом тесте, но мы также внедрили некоторые зависимости от того, что не имеет реального значения для нас (и для пользователей). Конкретные ID и классы, проверяемые нами, кажутся довольно стабильными, особенно по сравнению с селекторами вроде div.main > p:nth-child(3) > span.is-a-button. Такие длинные селекторы настолько специфичны, что крошечное изменение DOM вызовет падение теста, потому что он не может найти элемент, а не потому, что функциональность сломана.

Но даже с короткими селекторами вроде #name есть проблемы:

  1. ID в коде может быть изменен или удален, и элемент будет упущен, особенно если форма встречается на странице не один раз. Для каждого варианта может генерироваться уникальный ID, а это не так-то легко добавить в тест.
  2. Если на странице несколько таких форм, и у всех одинаковый ID, нужно решить, какую форму заполнять.
  3. С точки зрения пользователя нам без разницы, что там за ID, поэтому все встроенные проверки тут немного… необоснованы?

Рекомендуемое решение первых двух проблем зачастую выглядит, как использование специальных атрибутов данных в HTML, добавленных исключительно для тестирования. Уже лучше – тесты больше не зависят от структуры DOM, и если разработчик изменит код вокруг компонента, тест все еще будет срабатывать, не нуждаясь в обновлении, пока к правильному элементу ввода добавлено data-test="name-field".

Однако это не решает третьей проблемы – у нас все еще есть тест взаимодействия с фронтэндом, использующий нечто бессмысленное для пользователя.

Значимые локаторы интерактивных элементов

Локаторы элементов значимы, если они зависят от чего-то, от чего мы хотим такой зависимости, потому что что-то в локаторе важно для пользовательского опыта. Я считаю, что для интерактивных элементов наилучший локатор – это доступное имя элемента. Вот пример идеала:

//Рекомендуется
cy.getByLabelText('Name').type('Mark')

В примере использован помощник byLabelText из библиотеки Cypress Testing. Если вы используете эту библиотеку в любой форме, она, вероятно, уже помогает вам создавать подобные доступные локаторы.

Это полезно, так как теперь встроенные проверки, доставшиеся нам бесплатно через команду cy.type(), включают проверку доступности поля формы. Все интерактивные элементы должны иметь доступное имя, с которым работает вспомогательная технология. Именно так, к примеру, пользователь экранного чтеца узнает, что поле, в которое он что-то вводит, вызвано для ввода нужной информации.

Как правило, подобное доступное имя поля формы предоставляется через элемент-метку, ассоциированный с полем по ID. Команда Cypress Testing Library getByLabelText проверяет, что поле имеет соответствующую метку, а также то, что поле – это элемент, которому разрешено иметь метку. К примеру, этот HTML совершенно правильно упадет перед попыткой команды type(), потому что несмотря на наличие метки присвоение метки для div – это неверный HTML.

<!-- Не рекомендуется  -->
<label for="my-custom-input">Editable DIV element:</label>
<div id="my-custom-input" contenteditable="true" />

Так как это неверный HTML, экранный чтец никогда не сможет правильно сопоставить эту метку с полем. Чтобы это исправить, нужно обновить разметку и использовать реальный элемент ввода:

<!-- Рекомендуется -->
<label for="my-real-input">Real input:</label>
<input id="my-real-input" />

Супер. Теперь, если тест упадет после правок DOM, это будет связано не с не имеющими отношения к делу структурными переменами в размещении элементов, а с нашими правками, которые каким-то образом сломали часть DOM, непосредственно интересующую наш тест, значимую для пользователей.

Значимые локаторы для неинтерактивных элементов

В случае с неинтерактивными элементами надо подумать. Попробуем применить смекалку и не хвататься за атрибуты data-cy или data-test – они всегда будут под рукой, если DOM не имеет никакого значения.

Прежде чем обращаться к универсальным локаторам, вспомним, что состояние DOM – это священный Грааль веб-разработчиков (ну, я так считаю). DOM рулит пользовательским опытом  всех, кто не взаимодействует со страницей через визуальный интерфейс. В большинстве случаев в ней есть значимые элементы, которые мы можем/должны использовать в коде и включать в тест-локаторы.

А если это не так, потому что, допустим, код приложения состоит из универсальных контейнеров вроде div и span, то сначала надо подумать об исправлении кода приложения, а потом – о добавлении тестов. В противном случае есть риск того, что тесты будут утверждать, что универсальные контейнеры ожидаемы и желательны, а это усложнит улучшение доступности этого компонента.

И тут вскрывается целый клубок проблем доступности в организации. Если никто о ней не говорит, и это не стандартная практика, то фронтэнд-разработчики не воспринимают доступность всерьез. Однако мы, по идее, эксперты во всем, что касается «правильной разметки» дизайна и того, что нужно учитывать, проектируя ее.

Как мы уже видели, для элементов, которые мы традиционно считаем интерактивными, есть неплохое универсальное правило, которое легко встроить в тесты фронтэнда: у интерактивных элементов должны быть очевидные метки, верно ассоциируемые с элементом. Все интерактивное при тестировании должно выбираться из DOM по этой необходимой метке.

Элементы, которые не считаются интерактивными – например, большая часть отрисовывающих содержание элементов, отображающих, скажем, куски текста, кроме базовых ориентиров вроде main, - не вызовут падений в аудите Lighthouse, если поместить их в универсальные контейнеры div или span. Но разметка будет не особенно информативной и полезной для вспомогательных технологий, потому что она не описывает природу и структуру содержания для человека, не способного его увидеть.

При помощи отрисовки HTML мы сообщаем важную контекстную информацию тем, кто не способен воспринимать визуальное содержание. HTML используется для построения DOM, а DOM – для создания дерева доступности браузера, а дерево доступности – это API, которым пользуются разнообразные вспомогательные технологии, чтобы объяснить содержание и возможные действия человеку с ограниченными возможностями, использующему ПО. Экранный чтец – зачастую первый всплывающий в голове пример, но дерево доступности может использоваться и другой технологией, вроде, скажем, дисплеев, переводящих страницы на шрифт Брайля.

Автоматизированные проверки доступности не сообщат нам, правильно ли мы создали HTML для содержания страницы. «Правильность» HTML – это субъективная оценка разработчиков, какую информацию нужно передавать в дереве доступности.

Как только мы это оценили, можно решать, что из этого встраивать в автоматизированное тестирование фронтэнда.

Допустим, мы решили, что контейнер со статусной ролью ARIA будет содержать «Спасибо» и сообщения об ошибках для формы контактов. Симпатичное решение – обратная связь об успешности или неудаче при отправке формы будет передана экранным чтецом. Для контроля визуального состояния можно применять CSS-классы .thank-you и .error.

Если мы добавили этот элемент и хотим написать для него UI-тест, можно написать такую проверку, которая проводится после заполнения и отправки формы:

//Не рекомендуется
cy.get('.thank-you').should('be.visible')

или даже тест, использующий устойчивый, но все равно бессмысленный селектор:

//Не рекомендуется
cy.get('[data-testid="thank-you-message"]').should('be.visible')

Все это можно переписать, используя cy.contains():

//Рекомендуется
cy.contains('[role="status"]', 'Thank you, we have received your message')
.should('be.visible')

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

Мы, безусловно, добились тут некоторого покрытия, не истратив кучу кода, но также добавили новой хрупкости. В наших тестах строки на обычном языке, и это значит, что если сообщение «Спасибо» изменится на что-то вроде «Спасибо, что написали нам!», тест внезапно упадет. То же самое произойдет со всеми остальными тестами. Маленькое изменение метки потребует обновление всех тестов, использующих этот элемент и эту метку.

Это можно поправить, используя для таких строк фронтэнд-тестов тот же источник истины, что и для кода. И если в HTML наших компонентов напрямую встроены человекочитаемые предложения – теперь есть повод удалить их оттуда.

Человекочитаемые строки – это магические числа кода UI

Магическое число (или, менее красиво, «безымянная численная константа») – это то крайне конкретное значение, которое иногда встречается в коде и важно для конечного результата расчета, вроде старого доброго 1.023033. Так как это число не имеет метки, значимость его неочевидна, и поэтому неочевидно, что именно оно делает. Возможно, применяет налоговую ставку. Возможно, компенсирует какой-то неизвестный нам баг. Кто знает?

В любом случае это верно и для фронтэнд-тестирования, и общий совет – избегать магических чисел в связи с их непонятностью. На код-ревью такие числа, скорее всего, выявят и спросят, что они делают. Можно ли переместить их в константу? То же самое можно сделать, если значение используется в нескольких местах. Вместо того, чтобы везде его повторять, можно хранить его в переменной и использовать при необходимости.

Годами создавая пользовательские интерфейсы, я начал относиться к текстовому содержанию в HTML или файлах шаблонов так же, как к магическим числам в других контекстах. Мы везде и всюду вставляем в код абсолютные значения, но в реальности, возможно, полезнее хранить их где-то еще и извлекать во время билда (или через API, в зависимости от ситуации).

Для этого есть ряд причин:

  • Мне доводилось работать с клиентами, которые хотели запускать все через систему управления контентом. Контента, отсутствовавшего в этой си стеме, – даже меток форм и статусных сообщений – надо было избегать. Клиентам нужна была вся полнота контроля, чтобы изменения контента не требовали изменения кода и передеплоя сайта. В этом есть смысл; код и контент – разные концепции.
  • Я работал с множеством мультиязычных баз кода, текст в которых должен был протягиваться через некий фреймворк интернационализации – примерно так:
<label for="name">
<!-- выводит "Name" при английском языке, и что-то иное на другом языке -->
{{content[currentLanguage].contactForm.name}}
</label>
  • С точки зрения фронтэнд-тестирования тесты UI куда более устойчивы, если вместо проверки зашитого в коде конкретного сообщения «Спасибо» мы делаем как-нибудь так:
const text = content.en.contactFrom // один раз сделали – все тесты в файле смогут это прочитать
cy.contains(text.nameLabel, '[role="status"]').should('be.visible')

Каждый случай уникален, но некая система констант строк – огромное подспорье для устойчивых UI-тестов, крайне рекомендую. Если (когда) нам понадобится перевод или динамический контент, мы к нему уже подготовились.

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

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

Все еще, однако, стоит изолировать код в целом от содержания фронтэнда. Иногда можно даже взболтать и перемешать – например, импортировать строки в компонентные тесты, но не в end-to-end. Таким образом мы избавимся от некоторой дупликации и убедимся, что компоненты отображают правильный контент, все еще располагая фронтэнд-тестами, независимо проверяющими ожидаемый текст с точки зрения редактора и пользователя.

Использование локаторов data-test

CSS-селекторы вроде [data-test="success-message"], тем не менее, могут быть очень полезными, если используются намеренно, а не при любой возможности. Если, с нашей точки зрения, не существует значимого, доступного способа нацелиться на элемент, атрибуты data-test – все еще наилучшее решение. Они гораздо лучше, нежели зависимость от совпадений – скажем, того, что присутствует в структуре DOM в тот день, когда вы создаете тест, - или стилистике «второй элемент в списке в третьем div с классом card».

Также иногда ожидается динамический контент, где нельзя просто выцепить строки из некоего общего источника истины и использовать их в тестах. В этих ситуациях атрибут data-test помогает получить конкретный необходимый нам элемент. Его все еще можно скомбинировать с проверкой доступности, например:

cy.get('h2[data-test="intro-subheading"]')

Тут мы хотим получить атрибут data-test для intro-subheading, но тест все еще проверяет, что это действительно элемент h2, а не какой-то другой. Атрибут data-test используется для доступа к конкретному нужному нам h2, а не какому-то любому h2 со страницы, если по какой-то причине содержание этого h2 нельзя узнать заранее при создании теста.

cy.contains('h2[data-test="intro-subheading"]', 'Welcome to Testing!')

data-test селекторы также удобны при переходе в конкретную область страницы и проверках там:

cy.get('article[data-test="ablum-card-blur-great-escape"]').within(() => {
cy.contains('h2', 'The Great Escape').should('be.visible')
cy.contains('p', '1995 Album by Blur').should('be.visible')
cy.get('[data-test="stars"]').should('have.length', 5)
})

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

Если DOM имеет значение, тестируйте его

Фронтэнд-тесты будут куда более ценными, если мы серьезно задумаемся, как сообщить тестам, с какими элементами взаимодействовать и какого содержания ожидать. Следует выбирать доступные имена для работы с интерактивными компонентами, и конкретные имена и ARIA-роли – для не-интерактивного содержания, если это важно для функциональности. При практичности таких локаторов они создают правильный баланс силы и хрупкости.

А для всего остального, конечно, есть data-test.

Обсудить в форуме