Всем привет! Меня зовут Максим Новиков, я Android-разработчик в команде мобильного приложения оператора Yota.

Последнее время многие проекты начали переходить или пробовать у себя Compose. Вот и мы не остались в стороне и решили сделать на нём первую фичу. Частью процесса разработки фичи в нашей команде является написание автоматизированных UI тестов. Они помогают нам ускорить выпуск фичей и уменьшить время регрессионного тестирования.

Автотестирование View системы развивается уже достаточно давно. У нас есть множество инструментов, зарекомендовавших себя очень хорошо. Compose, напротив, только начинает обрастать различными решениями и фреймворками, например, у kaspresso на момент написания статьи Compose находится в раннем доступе.

Сегодня разберём в чём же особенности нативного тестирования на Compose, наступим на пару граблей и напишем свои первые тесты. А в следующей статье уже будем копать вглубь и посмотрим, как это всё работает под капотом.

Что же такое E2E тесты и зачем они нужны?

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

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

Хотелось бы отметить, что Google подготовили довольно хорошую документацию, по тому как работает автотестирование.

Причём открывая документацию сразу видим предупреждение, что подход в тестировании Compose будет совершенно отличным от View:

Note: Testing a UI created with Compose is different from testing a View-based UI. The View-based UI toolkit clearly defines what a View is. A View occupies a rectangular space and has properties, like identifiers, position, margin, padding, and so on. In Compose, only some composables emit UI into the UI hierarchy, therefore a different approach to matching UI elements is needed.

Давайте разберёмся, в чём же отличия. Возьмём самый простой пример со счётчиком:

Пример со счётчиком

@Composable
private fun Counter() {
  Box(Modifier.fillMaxSize()) {
    val count = remember { mutableStateOf(0) }
    Text(
      text = count.value.toString(),
      modifier = Modifier
        .align(Alignment.Center)
    )
    Button(
      onClick = { count.value = count.value + 1 },
      modifier = Modifier
        .align(Alignment.BottomCenter)
    ) {
      Text(text = "Add")
    }
  }
}

Поскольку мы тестируем только через Activity, понадобится одна зависимость:

androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")

Первое, что нужно в тесте - это подключить AndroidComposeTestRule:

 @get:Rule
 val composeTestRule = createAndroidComposeRule<MainActivity>()

Если же нам необходимо модифицировать Intent нашего Activity:

 @get:Rule
 val activityScenarioRule = ActivityScenarioRule<MainActivity>(createIntent())

 @get:Rule
 val composeTestRule = AndroidComposeTestRule(activityScenarioRule) {
    var activity: MainActivity? = null
    it.scenario.onActivity { activity = it }
    activity!!
 }

 private fun createIntent() = Intent(
    InstrumentationRegistry.getInstrumentation().targetContext,
    MainActivity::class.java
 ).apply {
    // Modify your Intent here
 }

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

Приступим к самому тесту.

Что мы хотим?

  1. Нажать на кнопку;

  2. Проверить, что текст изменился на 1;

  3. Нажать ещё раз;

  4. Проверить, что текст изменился на 2.

Первое, что необходимо сделать, это понять, как найти наши Composable функции. Так как нам необходимо работать с функцией как с объектом (к примеру получать текст), в тестовом фреймворке была добавлена абстракция SemanticNode. Чтобы обратиться к этим нодам воспользуемся composeTestRule и его функциями onNode*():

  • composeTestRule.onNodeWithText("1") -  ищем ноду с текстом, подходит для Text() и TextField().

  • composeTestRule.onNodeWithContentDescription("1") - ищем ноду с описанием, подходит для Image, Icon и некоторых других.

  • composeTestRule.onNodeWithTag("TAG") - поскольку в Composable в отличие от View, нет id, для тестирования была сделана система тестовых тегов.

Добавляем класс для удобного обращения к тегам:

object CounterTags {

  const val TEXT = "CounterTags:TEXT"
  const val BUTTON = "CounterTags:BUTTON"
}

Добавляем сами теги:

Text(
    modifier = Modifier
                   .align(Alignment.Center)
                   .testTag(CounterTags.TEXT) <- тут
)
Button(
    modifier = Modifier
                   .align(Alignment.BottomCenter)
                   .testTag(CounterTags.BUTTON) <- и тут
)

Переходим к этапу симуляции действий пользователя.

Для этого уже написаны основные extension функции:

fun SemanticsNodeInteraction.perform*()

SemanticsNodeInteraction мы получаем как результат onNode*()

Нам необходим performClick():

composeTestRule.onNodeWithTag(CounterTags.BUTTON).performClick()

Переходим к этапу проверки состояния UI.

Для этого уже служат функции:

fun SemanticsNodeInteraction.assert*()

В нашем с��учае необходимо просто проверить текст, для этого используем assertTextEquals().

composeTestRule.onNodeWithTag(CounterTags.BUTTON).performClick()
composeTestRule.onNodeWithTag(CounterTags.TEXT).assertTextEquals("1")
composeTestRule.onNodeWithTag(CounterTags.BUTTON).performClick()
composeTestRule.onNodeWithTag(CounterTags.TEXT).assertTextEquals("2")

Запускаем:

И всё работает!

Теперь представим, что текст у нас на кнопке динамический и мы хотим его проверить.

Добавляем tag для текста:

     Text(
        text = "Add",
        modifier = Modifier
          .testTag(CounterTags.BUTTON_TEXT)
      )

И в конце теста проверяем его:

composeTestRule.onNodeWithTag(CounterTags.BUTTON_TEXT).assertTextEquals("Add")

Запускаем и... Всё работает падает ошибка.

java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Add])
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'CounterTags:BUTTON_TEXT')
However, the unmerged tree contains '1' node that matches. Are you missing `useUnmergedNode = true` in your finder?

Видим, что сейчас нет composable функции с тегом BUTTON_TEXT, однако нам подсказывают, что возможно мы забыли указать “useUnmergedNode = true”. 

Здесь и начинается главное отличие Compose.

При тестировании View системы мы обращались непосредственно к иерархии View, которую мы построили в xml/коде. В Compose создаётся отдельное семантическое дерево, которое представляет наш UI, НО не является им. Причём данное дерево также используется для обеспечения доступности интерфейса. Кроме того, присутств��ет дополнительная оптимизация, которая мерджит представления наших Compose функций в одну ноду.

Чтобы вывести дерево в логах есть следующая функция:

fun SemanticsNodeInteraction.printToLog(tag: String)

Принтим весь UI:

composeTestRule.onRoot(useUnmergedTree = false).printToLog(“{здесь обычный тег логирования}")

Давайте посмотрим, что мы имеем:

 Дерево с параметром useUnmergedTree = 'true'

Node #10 at (l=0.0, t=66.0, r=1080.0, b=2028.0)px - это наш Surface
 |-Node #11 at (l=0.0, t=66.0, r=1080.0, b=2028.0)px - это наш Box
    |-Node #12 at (l=528.0, t=1018.0, r=553.0, b=1077.0)px, Tag: 'CounterTags:TEXT' - это наш текст счётчика
    | Text = '[2]'
    | Actions = [GetTextLayoutResult]
    |-Node #14 at (l=452.0, t=1913.0, r=628.0, b=2012.0)px, Tag: 'CounterTags:BUTTON' - это наша кнопка
      Role = 'Button'
      Focused = 'false'
      Actions = [OnClick, RequestFocus]
      MergeDescendants = 'true'
       |-Node #17 at (l=502.0, t=1937.0, r=579.0, b=1989.0)px, Tag: 'CounterTags:BUTTON_TEXT' - это наш текст в кнопке
         Text = '[Add]'
         Actions = [GetTextLayoutResult]

Дерево с параметром useUnmergedTree = 'false'

Node #10 at (l=0.0, t=66.0, r=1080.0, b=2028.0)px - это наш Surface
 |-Node #11 at (l=0.0, t=66.0, r=1080.0, b=2028.0)px - это наш Box
    |-Node #12 at (l=528.0, t=1018.0, r=553.0, b=1077.0)px, Tag: 'CounterTags:TEXT' - это наш текст счётчика
    | Text = '[2]'
    | Actions = [GetTextLayoutResult]
    |-Node #14 at (l=452.0, t=1913.0, r=628.0, b=2012.0)px, Tag: 'CounterTags:BUTTON' - это наша кнопка вместе с текстом
      Role = 'Button'
      Focused = 'false'
      Text = '[Add]'
      Actions = [OnClick, RequestFocus, GetTextLayoutResult]
      MergeDescendants = 'true'

И здесь мы видим, что вместо текста с кнопкой у нас просто кнопка. Причём у кнопки теперь есть свойство Text, и мы даже можем увидеть его значение: Text = '[Add]'.

И дополнительные свойства:

Role = 'Button' - что данный composable это кнопка.
Focused = 'false' - что она не в фокусе.
[OnClick, RequestFocus, GetTextLayoutResult] - действия, которые с ней можно совершать.
И самое интересное MergeDescendants = 'true' - говорит нам, что данная нода будет пытаться соединить в себе потомков при возможности. Причём именно “Потомков”, а не “Детей”.

Разница между потомками и детьми

сын и дочь - дети

сын, дочь, внучка, внук - потомки

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

Button() {
    Box { - добавили вложенности
      Text()
    }
  }

Наше смёрдженное дерево никак не изменится.

В итоге для решения нашей задачи можно либо использовать useUnmergedTree = true'

composeTestRule.onNodeWithTag(CounterTags.BUTTON_TEXT,useUnmergedTree = true' ).assertTextEquals("Add")

либо просто использовать саму кнопку

composeTestRule.onNodeWithTag(CounterTags.BUTTON).assertTextEquals("Add")

Кроме функции вывод дерева в логи, его также можно посмотреть при помощи LayoutInspector:

Посмотрим на самый интересный момент - Кнопку:

Здесь мы видим два раздела:

  • Declared Semantics - свойства, которые добавляет непосредственно эта нода.

  • Merged Semantics - смердженные свойства самой ноды и её потомков. Видим, что кнопка также включает наш текст.

С использованием простых нод разобрались, давайте попробуем сделать что-то чуть более сложное - работу со списками. 

Пример со списком

Задача: список, у каждой ячейки может быть ошибочное состояние (иконка с восклицательным знаком). Необходимо проверить её наличие или отсутствие. При нажатии на иконку, необходимо удалить элемент.

Для упрощения у ячеек, чья позиция кратна 5, выставляем ошибочное состояние.

@Composable
fun ListExample() {
  val data = remember { mutableStateOf((1 until 100).map { it.toString() }) }
  LazyColumn(Modifier.testTag(ListTags.LIST)) {
    items(data.value, { it }) { item ->
      Row(
        modifier = Modifier
          .padding(10.dp)
          .testTag(ListTags.ITEM)
      ) {
        Text("item $item", Modifier.testTag(ListTags.TEXT))
        if (item.toInt() % 5 == 0) {
          Image(
            painter = painterResource(R.drawable.ic_error),
            contentDescription = null,
            modifier = Modifier
              .testTag(ListTags.ICON)
              .clickable { data.value = data.value.filter { it != item } },
          )
        }
      }
    }
  }
}

object ListTags {

  const val LIST = "ListTags:LIST"
  const val TEXT = "ListTags:TEXT"
  const val ITEM = "ListTags:ITEM"
  const val ICON = "ListTags:ICON"
}

Выглядит вот так:

Для усложнения задачи будем проверять item 20 и 21. Необходимая последовательность действий:

  1. Скролим к ячейке с текстом "item 20";

  2. Проверяем что у этой ячейки отображается иконка;

  3. Проверяем что у ячейки с текстом "item 21" не отображается иконка;

  4. Нажимаем на иконку у ячейки “item 20”;

  5. Проверяем, что ячейка “item 20” удалена.

Первое что нужно, это проскролить до нашей ячейки.

Находим наш список:

composeTestRule.onNodeWithTag(ListTags.LIST)

И дальше у нас есть на выбор 3 функции как скролить:

  • performScrollToKey(key: Any) - в случае, если вы знаете ключ, который вы установили в списке. Если ваш key - это id генерируемый на сервере, то способ вам не подходит;

  • performScrollToIndex(index: Int) -  если вы уверены в позиции вашего элемента;

  • performScrollToNode(matcher: SemanticsMatcher) - здесь мы можем задать кастомные матчеры для поиска нашей ячейки. Им и воспользуемся.

С SemanticsMatcher мы уже сегодня работали, но они были скрыты за функциями.

Если посмотрим на onNodeWithTag(testTag: String) упрощенно внутри выглядит так: onNode(hasTestTag(testTag)).

Функция hasTestTag - возвращает нам SemanticsMatcher.

Аналогично есть hasText(), hasContentDescription(), hasClickAction() и многие другие.

Скролим к ячейке, у которой в потомках есть нода с текстом “item 20”:

composeTestRule.onNodeWithTag(ListTags.LIST)
  .performScrollToNode(hasAnyDescendant(hasText("item 20")))

hasAnyDescendant(matcher: SemanticsMatcher): SemanticsMatcher - проверяет, что у потомков текущей ноды есть нода соответствующая переданному матчеру.

Теперь посмотрим на Api работы  со списками.

Поиск элемента выглядит похоже: 

  • onAllNodes()

  • onAllNodesWithText()

  • onAllNodesWithContentDescription()

  • onAllNodesWithTag()

Главная разница в том, что получаем вместо SemanticsNodeInteraction следующий класс SemanticsNodeInteractionCollection.

Работа с ним похожа на koltin.Collections.

Можем отфильтровать ноды:

filter(matcher: SemanticsMatcher) :SemanticsNodeInteractionCollection.

Можем взять первый элемент по условию:

filterToOne(matcher: SemanticsMatcher) :SemanticsNodeInteractionCollection.

Получить по индексу:

get(index: Int): SemanticsNodeInteraction

И многое другое.

Итак, задача проверить, что ячейка с текстом “item 20” имеет статус ошибки, у нас это иконка.

Ищем элементы списка:

composeTestRule.onAllNodesWithTag(ListTags.ITEM)

Далее найдём ячейку, в которой есть текст “item 20” и также есть иконка:

.filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))

Для объединений матчеров используется инфиксная логическая функция “and”. Также есть функция “or” и operator функция отрицания “not”, которую можно использовать следующим образом: “!hasText()”

И наконец проверим, что она отображается:

.assertIsDisplayed()

Итого:

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .assertIsDisplayed()

Аналогично, проверим что у “item 21” отсутствует иконка ошибки, используя функцию “not”:

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 21")) and !hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .assertIsDisplayed()

Теперь получим саму ноду иконки и нажмём на неё. Также находим нужную ячейку, получаем её детей onChildren(). Затем аналогично ячейке находим нашу иконку:

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .onChildren()
  .filterToOne(hasTestTag(ListTags.ICON))
  .performClick()

И теперь проверим, что ячейка удалена через функцию assertDoesNotExist():

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .assertDoesNotExist()
Весь код теста
composeTestRule.onNodeWithTag(ListTags.LIST)
  .performScrollToNode(hasAnyDescendant(hasText("item 20")))

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .assertIsDisplayed()

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 21")) and !hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .assertIsDisplayed()

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .onChildren()
  .filterToOne(hasTestTag(ListTags.ICON))
  .performClick()

composeTestRule.onAllNodesWithTag(ListTags.ITEM)
  .filterToOne(hasAnyDescendant(hasText("item 20")) and hasAnyDescendant(hasTestTag(ListTags.ICON)))
  .assertDoesNotExist()

В этот раз без сюрпризов всё работает:

В первый раз начиная работать с новым фреймворком очень легко потеряться в многообразии API. Чтобы этого не произошло был сделан очень удобный cheat sheet:

Заключение

Сегодня мы познакомились с основами, которых хватит на большинство кейсов, необходимых для покрытия вашего приложения UI тестами. В следующей статье уже посмотрим, как же оно работает под капотом на примере hasText, научимся делать свой matcher и встретим некоторые ограничения.

Исходный код тестов