Собираясь писать UI-тесты для приложения на Compose Multiplatform, я столкнулась с дефицитом туториалов и документации по этой теме. Из полезного были только пара статей и выступлений на недавних конференциях, а примеры в основном сводились к щелчку по кнопке и проверке результата. Пришлось писать почти наугад, запуская код и изучая результат. Хочу поделиться с Хабром получившимися наработками.

Быстрый старт

Начиная с создания SourceSet, процесс подключения зависимостей доходчиво описан в документации.

Теперь приступим к написанию тестов и рассмотрим пример с неизбежным Hello world ?

import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import org.junit.Rule
import org.junit.Test
import androidx.compose.material.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag

class HelloWorld {
    @get:Rule
    val rule = createComposeRule()
    val hello = "Hello, World!"

    @Test
    fun myTest() {
        rule.setContent {
            Text(
                text = hello,
                modifier = Modifier.testTag("text")
            )
        }
        rule.onNodeWithTag("text").assertTextEquals(hello)
    }
}

Прежде всего создаем тестовое правило – механизм, который будет перехватывать тестируемый метод и добавлять к нему дополнительную функциональность. Предваряем наше правило аннотацией @get:Rule, где @Rule – аннотация для подобных правил, унаследованная от Java и обращающаяся к свойству класса. Как известно, Kotlin организует доступ к свойствам через неявное использование геттеров, поэтому нам приходится явно указать get, чтобы не ошибиться адресом.

Наше тестовое правило rule – экземпляр класса ComposeContentTestRule, который мы создали с помощью фабричного метода createComposeRule().

Затем создаем функцию myTest(). Перед ней указываем аннотацию @Test, и в IDEA появляется зеленый треугольник, нажатие на которой позволяет запустить отдельный тест.

Пример теста
Пример теста

С помощью метода setContent() задаем composable-элементы для тестирования. Пока это просто надпись, но вскоре на этом месте появятся вызовы composable функций.

Обязательно используем метод модификатора testTag(), чтобы присвоить объекту тег, по которому наш тест сможет его найти. Кстати, модификаторы есть не у всех composable компонентов, а без них теги задать нельзя, что существенно ограничивает возможности тестирования.

Теперь найдем элемент по нашему тегу с помощью метода onNodeWithTag() и проверим, что найденный элемент содержит текст из переменной hello.

Запускаем тест.

Зеленые маркеры успеха
Зеленые маркеры успеха

Семантическое дерево

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

Теги семантического дерева полезны не только для тестирования. Они также обеспечивают доступность (accessibility) приложения – удобство его использования пользователями с ограничениями по здоровью.

Дерево можно увидеть воочию:

rule.onRoot().printToLog("text")

Правда, оно выглядит не очень живописно:

text: printToLog:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=0.0, r=1024.0, b=768.0)px
 |-Node #2 at (l=0.0, t=0.0, r=81.0, b=19.0)px, Tag: 'text'
   Text = '[Hello, World!]'
   Actions = [SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]

Как видим, в нашем дереве всего две записи – корень и текстовая надпись.

Добавим еще один элемент:

rule.setContent {
    Text(
        text = hello,
        modifier = Modifier.testTag("text")
    )
    Button(
        onClick = {},
        modifier = Modifier.testTag("button")
    ) { Text("Click me!") }
}

И увидим, как подросло наше деревце:

text: printToLog:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=0.0, r=1024.0, b=768.0)px
 |-Node #2 at (l=0.0, t=0.0, r=81.0, b=19.0)px, Tag: 'text'
 | Text = '[Hello, World!]'
 | Actions = [SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]
 |-Node #3 at (l=0.0, t=6.0, r=102.0, b=42.0)px, Tag: 'button'
   Focused = 'false'
   Role = 'Button'
   Text = '[Click me!]'
   Actions = [RequestFocus, OnClick, SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]
   MergeDescendants = 'true'

Возможно, вы обратили внимание на загадочное useUnmergedTree. Это параметр метода onRoot(), задающий вариант построения дерева – merged или unmerged. Только что мы видели merged tree: в нем объединены все узлы, для которых это целесообразно. Например, наша кнопка с текстом выглядит в нем как единое целое (node #3).

А вот как выглядит unmerged tree (rule.onRoot(useUnmergedTree = true)):

text: printToLog:
Printing with useUnmergedTree = 'true'
Node #1 at (l=0.0, t=0.0, r=1024.0, b=768.0)px
 |-Node #2 at (l=0.0, t=0.0, r=81.0, b=19.0)px, Tag: 'text'
 | Text = '[Hello, World!]'
 | Actions = [SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]
 |-Node #3 at (l=0.0, t=6.0, r=102.0, b=42.0)px, Tag: 'button'
   Focused = 'false'
   Role = 'Button'
   Actions = [RequestFocus, OnClick]
   MergeDescendants = 'true'
    |-Node #5 at (l=16.0, t=16.0, r=86.0, b=32.0)px
      Text = '[Click me!]'
      Actions = [SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution, GetTextLayoutResult]

Как видим, в unmerged дереве кнопка и написанный на ней текст разделены на разные узлы (node #3 и node #5 соответственно). При этом действие OnClick существует только для кнопки, но не для ее текста.

Структура теста

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

rule.onNodeWithTag("text").assertTextEquals(hello)

Она построена по следующему шаблону:

rule{.finder}{.assertion/action}

Finder выбирает в семантическом дереве один или несколько узлов, соответствующих заданному критерию. Примеры:

  • onNodeWithTag()

  • onNodeWithText()

  • onAllNodesWithTag()

  • onAllNodesWithText()

Assertion проверяет, что условие теста выполнено:

  • assertExists()

  • assertIsEnabled()

  • assertTextContains()

Помимо проверки мы можем выполнять действие (action) над элементом:

rule.onNodeWithTag("button").performClick()

Некоторые виды действий:

  • performClick()

  • performTextInput()

  • performKeyPress()

Существует разновидность шаблона, где к finder присоединяется matcher:

rule{.finder({matcher})}{.assertion/action }

Пример:

rule.onNode(hasTestTag("button")).performClick()

Matcher задает критерий поиска для finder. Например:

  • hasText()

  • hasTestTag()

  • isDialog()

  • isPopup()

Полный список всех этих функций можно найти в шпаргалке.

Практика

Переходим к более полезным практическим примерам, чем щелчок по кнопке.

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

class MyTest {
    @get:Rule
    val rule = createComposeRule()

    private fun launchContent() {
        rule.setContent {
            MyWindow({})
        }
    }

    @Test
    fun `Test my window`() {
        launchContent()
        rule.onNodeWithTag("my_tag").assertExists()
    }
}

Kotlin позволяет использовать пробелы в имени функции, если заключить ее в обратные кавычки. Это очень удобно для тестов, потому что позволяет видеть сводку тестирования почти на естественном языке.

Тесты пройдены
Тесты пройдены

Тест слайдера

Рассмотрим слайдер с диапазоном значений от 1 до max и шириной width.

@Composable
fun MySlider() {
    var x by remember { mutableStateOf(max.toFloat()) }

    Slider(
        value = x,
        valueRange = 1f..max.toFloat(),
        onValueChange = { x = it },
        steps = max - 2,
        modifier = Modifier.width(width)
            .testTag("my_slider")
    )
    Text("${x.toInt()}",
        modifier = Modifier.testTag("my_text"))
}

Параметр steps указывает число делений на слайдере. При steps = 0 мы получим непрерывную шкалу, а в данном случае шкала дискретная. Для диапазона [1..max], включающего обе границы, слайдер позволяет установить ровно max значений, из которых два – это сами границы 1 и max, а для остальных max-2 понадобятся деления.

При изменении значения ползунка слайдера it мы записываем новое значение в x, состояние которого сохраняется между вызовами с помощью функции remember(). Это позволяет отображать в текстовой надписи ровно то значение x, которое выбрано на слайдере.

Оффтоп

Как известно, делегат by требует двух импортов – getValue() и setValue(). В сочетании с remember на этом месте кода мне всегда вспоминается:

Remember, remember the fifth of November,

Gunpowder treason and plot…

К слайдеру и текстовой надписи мы добавили тестовые теги myslider и mytext, чтобы обращаться к ним из тестов.

Однако когда мы попытаемся обратиться к слайдеру по тегу

rule.onNodeWithTag("my_slider")

нас ждет культурный шок от отсутствия готовых actions. По слайдеру можно только кликнуть или нажать клавишу на клавиатуре.

Как же изменять его значение из теста? Сначала я попробовала зацепиться за последний вариант – управлять слайдером с помощью клавиатуры.

rule.onNodeWithTag("my_slider")
    .performKeyPress(KeyEvent.VK_PAGE_DOWN)

Увы, это не работает. VK_PAGE_DOWN имеет тип Int, а не KeyEvent.

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

@OptIn(ExperimentalTestApi::class)
@Test
fun `Test my slider`() {
    launchContent()
    val width = 240
    val delta = width / max.toFloat()
    for (i in 0..< max){
        with(rule.onNodeWithTag("my_slider")) {
            performMouseInput {
                click(Offset((i * delta + 1).toFloat(), 0f))
            }
        }
        rule.onNodeWithTag("my_text").assertTextEquals((i + 1).toString())
    }
}

Зная ширину слайдера width и его максимальное значение max, получаем ширину диапазона между делениями delta. Теперь для i-го деления достаточно переместить по горизонтали указатель мыши на i*delta, чтобы попасть по делению, и еще чуть вправо, чтобы попасть внутрь диапазона. По вертикали двигаться не нужно (0f). Кликнув по полученной точке, проверяем, что числовое значение текстовой надписи соответствует номеру деления, увеличенному на единицу.

Метод click() относится к экспериментальному API, поэтому не забудьте указать аннотацию

@OptIn(ExperimentalTestApi::class)

перед функцией.

Тест чекбоксов

UI тестирование в Compose Multiplatform не позволяет определить состояние элемента без assertion. Мы можем написать что-то вроде

rule.onNodeWithTag("my_checkbox").assertIsOn() 

Но результат нельзя получить для дальнейшего использования. Можно только провалить тест.

Тем не менее, попробуем протестировать группу чекбоксов:

var checkList = mutableMapOf("zero" to true, "one" to true, "two" to true, "three" to true, "four" to true, "five" to true)

@Composable
fun MyCheckboxList() {
    Row() {
        checkList.forEach { (x, b) ->
            Column {
                val isChecked = remember { mutableStateOf(b) }

                Checkbox(
                    checked = isChecked.value,
                    onCheckedChange = {
                        isChecked.value = it
                    },
                    enabled = true,
                    modifier = Modifier.testTag("checkbox_${x}")
                )
                Text(text = x)
            }
        }
    }
}

Сейчас все флажки установлены – в списке checkList все значения равны true. Снимем некоторые из них, выполнив клик мышкой.

@Test
fun `Test my checkbox list`() {
    launchContent()

    // снимем два флажка
    val exclude = listOf("one", "three")
    exclude.forEach {
        checkList[it] = false
    }
    checkList.filter{(_, b) -> !b }.forEach { (x, _) ->
        rule.onNodeWithTag("checkbox_$x").run {
            assertExists()
            assertIsOn()
            performClick()
            assertIsOff()
        }
    }

…
}

Хотя мы не знаем, что происходит с чекбоксами, у нас есть список с булевыми значениями. Используем их для получения ожидаемого состояния каждого чекбокса и сравним с фактическим – тут уже можно применить assertion.

    // проверим, что флажки сняты
    checkList.forEach { (x, b) ->
        rule.onNodeWithTag("checkbox_$x").assertExists()

        if (b)
            rule.onNodeWithTag("checkbox_$x").assertIsOn()
        else
            rule.onNodeWithTag("checkbox_$x").assertIsOff()
    }
}

Теперь тест провалится не при любой проверке значения чекбокса, а лишь при отклонении от ожидаемого поведения.

Выводы

Система UI тестирования в Compose Multiplatform предоставляет неплохой функционал, но, на мой взгляд, пока еще сырая и недостаточно гибкая. Для проверки интерфейса более сложного, чем текст + кнопка, приходится изобретать велосипеды.

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

Полезные ссылки

https://developer.android.com/develop/ui/compose/testing/testing-cheatsheet (наглядная шпаргалка)

https://developer.android.com/codelabs/jetpack-compose-testing

https://github.com/android/compose-samples (см. тесты внутри проектов)

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


  1. m0rtis
    21.04.2024 07:15

    А в каком смысле это тесты для Compose Multiplatform? Их можно запустить из commonTest?