Pull to refresh

Compose End to End тестирование. Основы

Reading time10 min
Views7.5K

Всем привет! Меня зовут Максим Новиков, я 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 и встретим некоторые ограничения.

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

Tags:
Hubs:
Total votes 3: ↑3 and ↓0+3
Comments2

Articles