Привет, Хабр! Меня зовут Вера Соколова, я Android-разработчик в команде автотестирования проекта Мой МТС.

Автотесты — очень полезная штука в разработке, так как они упрощают жизнь ручным тестировщикам. На релизе во время smoke-прогона у коллег и так очень много работы, а тут небольшая, но порой ощутимая помощь в виде автотестов, когда после запуска руками тестировщики проверяют только то, что не автоматизировано или если тесты где-то не прошли.



Но автотесты нужно еще написать и первостепенная задача — поиск элементов. Чтобы кликнуть на кнопочку, ее нужно сначала найти. С какими трудностями при этом можно столкнуться и как эти трудности преодолеть я расскажу ниже, добро пожаловать под кат!

А зачем вообще искать?

Любой тест — это взаимодействие. В случае с автотестированием — это взаимодействие с элементами на экране мобильного телефона: кнопками, текстами, чекбоксами, диалогами, таббарами. Когда ручной тестировщик прокликивает элементы — у него не возникает вопросов, куда конкретно кликать, это описано в кейсе. Но как научить машину кликать на «вон ту большую красную кнопку»? Задача усложняется, если таких больших красных кнопок на экране несколько, а система блочная, элементы в ней друг от друга не зависят и переиспользуются.



Что мы подразумеваем под уникальным элементом? Это элемент, который можно идентифицировать на экране в единственном экземпляре. Всего одна большая красная кнопка, всего один заголовок таббара, всего одна кнопка «назад». Яркий пример не уникальных элементов — повторяющиеся айтемы списка, с ними часто возникают проблемы. У каждого элемента должно быть что-то особенное, отличительная черта, иначе драйвер его не найдет.

Казалось бы, нужно взять и найти такие элементы, в чем проблема-то? Но не все так просто, как кажется.

Стратегии поиска элементов

В зависимости от используемого драйвера/платформы есть несколько вариантов поиска. В нашем случае речь пойдет про Android и 2 драйвера — UIAutomator2 и Espresso. В таблице — стратегии, которые мы использовали в автотестах:



Рассмотрим стратегии по отдельности.

Content description

Content description — в дословном переводе это «описание контента». (cпасибо, кэп!) Этот параметр в android задуман как метка, позволяющая пользователям с ограниченными возможностями распознавать элементы интерфейса при помощи программы чтения с экрана. А теперь представьте какой-нибудь аватар на главном экране приложения. Если в атрибут content-desk этого элемента зашить значение вида avatar_main_screen, то при чтении программой пользователь явно услышит не то, что должен. Поэтому от Content description мы отказались почти сразу.

ID и первые проблемы. Нумерация элементов и необходимость наличия в ресурсах

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

Допустим, у нас есть некий список, который состоит из айтемов:



Проблема 1: при динамической смене id конечный идентификатор должен быть прописан в ресурсах приложения. Если список состоит из 10 элементов, в ресурсах должны быть прописаны все 10 id: item0, item1, … item9, иначе во время выполнения при попытке сменить идентификатор приложение упадет, не найдя нужный id.

<item type="id" name="item"/>
<item type="id" name="item0"/>
<item type="id" name="item1"/>
<item type="id" name="item2"/>
<item type="id" name="item3"/>
<item type="id" name="item4"/>
<item type="id" name="item5"/>
<item type="id" name="item6"/>
<item type="id" name="item7"/>
<item type="id" name="item8"/>
<item type="id" name="item9"/>

Решение: в Android-проекте нужно добавить обработку ошибки Resource not found exception. Так мы изменяем только те идентификаторы элементов, которые прописаны в ресурсах.
Проблема 2: Каждый айтем — это контейнер с некоторым набором элементов (иконка, текст, описание). Представьте, что для каждого такого элемента придётся в ресурсах прописывать id. Тогда они быстро превратятся в помойку из кучи пронумерованных элементов.

<!--Блок item_container-->
<item type="id" name="itemContainer"/>
<item type="id" name="itemContainer0"/>
<item type="id" name="itemContainer1"/>
<item type="id" name="itemContainer2"/>
<item type="id" name="itemContainer3"/>
<item type="id" name="itemContainer4"/>
<item type="id" name="itemContainer5"/>
<item type="id" name="itemContainer6"/>
<item type="id" name="itemContainer7"/>
<item type="id" name="itemContainer8"/>
<item type="id" name="itemContainer9"/>

<item type="id" name="itemTitle"/>
<item type="id" name="itemTitle0"/>
<item type="id" name="itemTitle1"/>
<item type="id" name="itemTitle2"/>
<item type="id" name="itemTitle3"/>
<item type="id" name="itemTitle4"/>
<item type="id" name="itemTitle5"/>
<item type="id" name="itemTitle6"/>
<item type="id" name="itemTitle7"/>
<item type="id" name="itemTitle8"/>
<item type="id" name="itemTitle9"/>

<item type="id" name="itemIcon"/>
<item type="id" name="itemIcon0"/>
<item type="id" name="itemIcon1"/>
<item type="id" name="itemIcon2"/>
<item type="id" name="itemIcon3"/>
<item type="id" name="itemIcon4"/>
<item type="id" name="itemIcon5"/>
<item type="id" name="itemIcon6"/>
<item type="id" name="itemIcon7"/>
<item type="id" name="itemIcon8"/>
<item type="id" name="itemIcon9"/>

Решение: нужно нумеровать только контейнеры (родительские блоки) первых 10-15 элементов. Для тестов этого количества обычно достаточно. Элементы внутри контейнера чаще всего уникальные. Далее при написании теста сначала ищем родителя, а по нему уже находим нужный элемент.

XPath

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

Во-первых, здесь возможны проблемы с производительностью. Во-вторых, конструкция xpath неустойчива. Стоит в разметке приложения добавить дополнительную вложенность и тесты улетят в трубу. И в-третьих, ощутимо усложняется читаемость в коде автотестов. Представьте, что каждое объявление элементов выглядит вот так:

public final By redButton = By.xpath("/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout");


Мы используем XPath для ускорения написания теста, когда идентификатора нет и разработчику нужно время на то, чтобы добавить его. Выручает он и в тех случаях, когда нужно обратиться к каким-либо системным элементам, например, к ланчеру Android или залезть в настройки, тогда без этого инструмента никак. А также в тех случаях, когда остальные стратегии поиска недоступны.

Работу можно упростить, используя возможности и сокращения языка xpath. Для упрощения составления более короткого xpath удобно использовать онлайн-инструменты, например раз и два. Есть множество сервисов, в которых можно подобрать более короткую комбинацию для xpath, предварительно загнав xml разметки экрана.

Мы пришли также к составлению xpath для поиска более чем по одному родителю, когда в некоторую функцию передаем список идентификаторов, а на выходе получаем склеенную строчку xpath:

public By androidXpath(String... elementIds) {
   StringBuilder xpath = new StringBuilder();
      for (String elementId : elementIds) {
         xpath.append("//*[ends-with(@resource-id,\"/").append(elementId).append("\")]");
      }
   return By.xpath(xpath.toString());
}

Идентификаторы последовательно передаются через запятую в порядке от родителя верхнего уровня до конечного элемента. Уровень вложенности значения не имеет. Главное — уникальность каждого n-ого родителя среди прочих контейнеров.

Теги и эспрессо

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

1) Файлик build_config.xml с описанием параметров зашивается в проект автотестов и содержит информацию о min sdk, версию грэдла и другие данные. Они нужны как ключ к основному проекту. То есть эти данные повторяют данные, зашитые в основном проекте.

{
   "toolsVersions": {
      "gradle": "5.6",
      "androidGradlePlugin": "3.4.2",
      "compileSdk": 28,
      "buildTools": "28.0.3",
      "minSdk": 18,
      "targetSdk": 28,
      "kotlin": "1.3.71"
   }
}

Местоположение файла нужно указать в капабилити с ключом appium:espressoBuildConfig.

2) Конфликт ресурсов. Когда смежные библиотеки имеют одни и те же идентификаторы ресурсов — тесты не стартанут. Решение: прописать в файле build_config.xml исключения:

{
   "toolsVersions": {
      "gradle": "5.6",
      "androidGradlePlugin": "3.4.2",
      "compileSdk": 28,
      "buildTools": "28.0.3",
      "minSdk": 18,
      "targetSdk": 28,
      "kotlin": "1.3.71"
   },
   "additionalAppDependencies": [
      "com.google.android.material:material:1.0.0",
      "androidx.lifecycle:lifecycle-extensions:2.1.0"
   ]
}

3) Периодические ошибки вроде “драйвер не стартанул”, особенно при первом его запуске. Тут пока сделать ничего не удалось, приходится страдать.

4) На момент написания статьи, для тестов с Espresso-driver в проекте используется последняя стабильная версия appium 1.22.0. Android-проект перешёл на использование agp 7 (android gradle plugin) и java 11. И возникла проблема со сборкой appium-espresso-driver в связи с несовместимыми изменениями, которые поломали билд конфиг драйвера. В репозитории appium-espresso-driver уже висит открытый ПР на эту тему, но пока мы ждем когда его смержат.

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



Послесловие и итоги


Про внешние сдк, внедрённые в проект

С сдк вариантов два: либо использовать локаторы в том виде, в котором они уже зашиты разработчиками сторонних библиотек, либо расковыривать библиотеку и переписывать как надо. Нужно ли это вообще? Скорее всего нет.

А есть ли разница, телефон или планшет?

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

А как это помогает?

Автотесты дают нам как минимум — более читаемый код, как максимум — счастливых ручных тестировщиков, которым на релизе нужно проверять значительно меньше кейсов. Например, в последнем релизном прогоне было 183 кейса. 22 упало, 22 скрыто, итого — 140 кейсов на сегодняшний день.

Надеюсь, что наш опыт в поиске уникальных элементов будет вам полезен. С радостью отвечу на вопросы в комментариях! Если же у вас есть свои лайфхаки при поиске той самой большой красной кнопки на экране при написании автотестов — обязательно расскажите о нем.

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


  1. Toliwer
    20.12.2021 21:36

    Упавшие автотесты лучше, чем отсутствующие :)


    1. vera_vera Автор
      21.12.2021 13:55

      А то)))


  1. AliceCarroll
    20.12.2021 21:36

    Для content description Вы можете просто написать простенькую оберточку вида

    @BindingAdapter("testLabel")
    fun bindTestLabel(view: View, testLabel: CharSequence?) {    
    if(BuildConfig.DEBUG && testLabel != null) {        
    view.importantForAccessibility = 1        
    view.contentDescription = testLabel }}

    и использовать аттрибут testLabel, который будет виден как content description только в дебажных сборках


    1. vera_vera Автор
      21.12.2021 13:54

      Интересное предложение, спасибо!