Привет, Хабр! Вы когда-нибудь тратили слишком много драгоценного времени на поиск нужного testTag
в иерархии Compose Screen? А потом еще полдня чинили тесты после каждого чиха в UI? Или, может быть, несколько недель ждали, пока в дизайн-систему добавят недостающий тег или семантику, чтобы вообще начать писать свой UI-тест? Если вы хоть на один вопрос ответили «Да», эта статья для вас. Давайте навсегда покончим с этой рутиной и займемся более креативными задачами!
Цель статьи — НЕ представить очередной фреймворк написания UI-тестов для приложений на Android, а упростить их написание с помощью всем знакомого Page Object. Мы будем прятать рутину поиска нужных компонентов и одновременно повышать устойчивость тестов к изменениям. Всё для того, чтобы тесты стали удобнее, надёжнее и проще поддерживались.
Меня зовут Дмитрий Омельченко, я Android-разработчик в Райффайзенбанке. Несколько лет назад мы с командой начали внедрять автоматизацию тестирования в приложение online-банка, и за это время реализовали несколько интересных решений, об одном из которых хочу вам рассказать.

Для тех, кто никогда не писал UI-тесты
Для тех, кто никогда не писал UI-тесты, немного пояснений. При их написании много времени уходит на поиск нужных компонентов по специальным «тестовым тегам» (testTag
), которые помогают найти нужную ноду (SemanticsNodeInteraction
). Она соответствует виджету на экране для взаимодействия с компонентом или проверки его состояния. Если в компоненте таких тегов нет, перед написанием теста их нужно добавить. Если теги были изменены, то тесты упадут, и их придётся долго фиксить: искать все места, где они использовались, и заменять на новые, порой вместе с изменением логики всего теста.

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

Для такого компонента нам нужно проверить:
отображение лейбла и текста ошибки, а также их валидность;
поле ввода: кликабельность и валидность текста;
иконку: отображение и
contentDescription
.
В Райффайзенбанке мы используем несколько подходов для написания UI-тестов, в том числе для Compose.
Нативный
compose-testing
от Google: классика, но всё в одном месте, что не всегда удобно для больших проектов.Kaspresso + Kakao: хорошо зарекомендовавшая себя связка, обертка над
compose-testing
, но всё равно есть трудности с поиском нод.Ultron: ещё одна обертка над
compose-testing
, собравшая хвалебные отзывы в одном из наших проектов.
Давайте посмотрим, как выглядит один и тот же тест на каждом из этих фреймворков.
Нативный Compose Testing:
fun testNative() {
// Находим контейнер ноду, в которой лежат все остальные ноды
val amountInputField = hasTestTag("AmountInputTag")
// Находим и проверяем лейбл
rule.onNode(hasParent(amountInputField) and hasTestTag("LabelTag"))
.assertIsDisplayed()
.assertTextEquals("Сумма")
// Находим и проверяем ошибку
rule.onNode(hasParent(amountInputField) and hasTestTag("ErrorTag"))
.assertIsDisplayed()
.assertTextEquals("Максимум 1 млн")
// Находим и проверяем иконку
rule.onNode(hasParent(amountInputField) and hasTestTag("LeftIconTag"))
.assertIsDisplayed()
.assertContentDescriptionEquals("Иконка поиска")
// Находим и проверяем само поле ввода
rule.onNode(hasParent(amountInputField) and hasTestTag("InputFieldTag"))
.assertIsDisplayed()
.assertTextEquals("10 000 000 ₽")
.assertHasClickAction()
}
Kakao (с Page Object'ом и DSL):
Сначала создадим Compose Screen — Page Object для экрана, в котором будем искать и хранить все нужные ноды.
class SampleScreen(
semanticsProvider: SemanticsNodeInteractionsProvider,
) : ComposeScreen<SampleScreen>(semanticsProvider) {
val inputAmountContainerNode: KNode = child {
hasParent(hasTestTag("AmountInputTag"))
}
val inputAmountLabelNode: KNode = inputAmountContainerNode.child {
hasTestTag("LabelTag")
}
val inputAmountErrorTextNode: KNode = inputAmountContainerNode.child {
hasTestTag("ErrorTag")
}
val inputAmountLeftIconNode: KNode = inputAmountContainerNode.child {
hasTestTag("LeftIconTag")
}
val inputAmountInputFieldNode: KNode = inputAmountContainerNode.child {
hasTestTag("InputFieldTag")
}
}
А вот сам тест:
fun testKakao() {
onComposeScreen<SampleScreen>(rule) {
inputAmountContainerNode.assertIsDisplayed()
inputAmountLabelNode {
assertIsDisplayed()
assertTextEquals("Сумма")
}
inputAmountErrorTextNode {
assertIsDisplayed()
assertTextEquals("Максимум 1 млн")
}
inputAmountLeftIconNode {
assertIsDisplayed()
assertContentDescriptionEquals("Иконка поиска")
}
inputAmountInputFieldNode {
assertIsDisplayed()
assertTextEquals("10 000 000 ₽")
assertHasClickAction()
}
}
}
Ultron
Page Object для Ultron:
class SampleUltronScreen : Screen<SampleUltronScreen>() {
// Находим и сохраняем ноды
private val container by lazy {
composeList(hasTestTag("AmountInputTag"))
}
val label by lazy {
container.visibleChild(hasTestTag("LabelTag"))
}
val errorText by lazy {
container.visibleChild(hasTestTag("ErrorTag"))
}
val leftIcon by lazy {
container.visibleChild(hasTestTag("LeftIconTag"))
}
val inputField by lazy {
container.visibleChild(hasTestTag("InputFieldTag"))
}
}
И тест:
fun testUltron() {
SampleUltronScreen.label
.assertIsDisplayed()
.assertTextEquals("Сумма")
SampleUltronScreen.errorText
.assertIsDisplayed()
.assertTextEquals("Максимум 1 млн")
SampleUltronScreen.leftIcon
.assertIsDisplayed()
.assertContentDescriptionEquals("Иконка поиска")
SampleUltronScreen.inputField
.assertIsDisplayed()
.assertTextEquals("10 000 000 ₽")
.assertHasClickAction()
}
Общие недостатки существующих подходов
Несмотря на различия фреймворков, у них схожие проблемы.
«Rocket science» с поиском тегов: поиск нужных тегов не только в проекте, но и в дебрях дизайн-системы, порой, то еще приключение.
Дублирование логики поиска нод: снова и снова приходится писать одинаковый код для нахождения одного и того же компонента.
Отсутствие контекста: все функции проверки и взаимодействия применяются ко всем
SemanticsNodeInteraction
без учета возможностей конкретного виджета. Из-за этого зачастую пишутся «минимальные» тесты, игнорирующие специфичные проверки.Хрупкость тестов: стоит изменить теги или вложенность компонентов и может сломаться вообще всё.
Все эти боли сводятся к одной: недостаточная инкапсуляция логики для работы с виджетами в тесте.
Мы нашли для себя решение. Идея проста: каждый UI-компонент должен предоставлять свой Page Object. Так как у нас используется дизайн-система для UI-компонентов, мы сделали отдельную библиотеку с Page Object-ами, чтобы шарить их на все проекты, где есть дизайн-система.
Такой Page Object:
Знает, как найти свои внутренние составляющие и умеет их хранить.
Имеет ограниченный контекст взаимодействия (только те проверки и действия, которые применимы к данному компоненту).
Не зависит от фреймворка UI-тестирования в разных проектах.
Будет обновляться вместе с обновлением дизайн-системы.
Позволяет добавить дополнительные UI-тесты в дизайн систему.
Например:
TextNode
— для обычного текста.TextInputNode
— для поля ввода (кастомного или нативного компонентаBasicTextField
).
IconNode
— для иконок.Любой другой компонент со своей логикой работы с ним.
Благодаря такому подходу у каждого компонента есть не только визуальное представление, но и свой «тестовый контракт», который чётко описывает, как его проверять и с чем взаимодействовать. Это, как если бы каждый ваш UI-элемент приносил с собой инструкцию по тестированию, и вам не нужно было каждый раз придумывать её заново.
Также, помимо скриншот-тестов, в дизайн-системе мы добавили еще UI-тесты, которые можно использовать для проверки состояния виджета, чтобы повысить устойчивость к багам при обновлениях виджетов.
Архитектура: как это работает под капотом
Чтобы реализовать эту идею, мы создали небольшой набор абстракций и классов:
1.) ComponentBaseNode
— базовый класс для всех Page Object'ов компонентов.
abstract class ComponentBaseNode<T>(
val nodeProvider: () -> Any, // Лямбда с провайдером ноды
) : BaseNodeContext, DSL<T> {
override val container: NodeContainer by lazy {
when (val node = nodeProvider()) {
is SemanticsNodeInteraction -> SemanticsNodeContainer(node)
is NodeContainer -> node
else -> CUSTOM_PROVIDER?.invoke(node)
?: throw UknownNodeProviderException(node)
}
}
}
nodeProvider
— это лямбда, которая умеет «доставать» нужную ноду из любого фреймворка (будь тоKNode
из Kaspresso,UltronNode
из Ultron или нативныйSemanticsNodeInteraction
от Compose).Мы используем
Any
для максимальной абстракции от конкретного фреймворка.По умолчанию в дизайн-системе поддерживается только нативный, но это легко расширяется.
2.) NodeContainer
— абстракция над нодой.
Благодаря тому, что все три представленных выше фреймворка — это обертки над compose-testing, мы можем создать интерфейс, который позволяет единообразно работать с нашими тестовыми нодами (SemanticsNodeInteraction
).
interface NodeContainer {
fun child(semanticsMatcher: SemanticsMatcher): NodeContainer
fun child(testTag: String, position: Int = 0): NodeContainer
fun interact(block: SemanticsNodeInteraction.() -> Unit)
}
Давайте рассмотрим все варианты реализаций NodeContainer
.
Для нативного Compose Testing:
class SemanticsNodeContainer(
private val item: SemanticsNodeInteraction
) : NodeContainer {
override fun child(semanticsMatcher: SemanticsMatcher): NodeContainer {
return SemanticsNodeContainer(
item.onChildren().filterToOne(semanticsMatcher)
)
}
override fun child(testTag: String, position: Int): NodeContainer {
return SemanticsNodeContainer(
item.onChildren().filter(hasTestTag(testTag))[position]
)
}
override fun interact(block: SemanticsNodeInteraction.() -> Unit) {
item.apply(block)
}
}
Реализация для Kaspresso:
class KNodeContainer(private val item: KNode) : NodeContainer {
override fun child(semanticsMatcher: SemanticsMatcher): NodeContainer {
return KNodeContainer(item.child<KNode> {
addSemanticsMatcher(semanticsMatcher)
useUnmergedTree = true
})
}
override fun child(testTag: String, position: Int): NodeContainer {
return KNodeContainer(item.child<KNode> {
hasTestTag(testTag)
hasPosition(position)
useUnmergedTree = true
})
}
override fun interact(block: SemanticsNodeInteraction.() -> Unit) {
item.delegate.interaction.nodeProvider.provideSemanticsNodeInteraction().block()
}
}
Реализация для Ultron:
class UltronSemanticsNodeContainer(
private val item: UltronComposeSemanticsNodeInteraction,
) : VNodeContainer {
override fun child(semanticsMatcher: SemanticsMatcher): VNodeContainer {
return SemanticsNodeContainer(
item = item.semanticsNodeInteraction
.onChildren()
.filterToOne(semanticsMatcher)
)
}
override fun child(testTag: String, position: Int): ViennaNodeContainer {
return SemanticsNodeContainer(
item = item.semanticsNodeInteraction
.onChildren()
.filter(hasTestTag(testTag))[position]
)
}
override fun interact(block: SemanticsNodeInteraction.() -> Unit) {
item.semanticsNodeInteraction.block()
}
}
3.) Поддержка нескольких фреймворков через UiTestConfigurator
.
С помощью синглтона UiTestConfigurator
можно реализовать подключение кастомного провайдера нод. Это ключевой момент, который позволяет нашему решению быть фреймворк-независимым.
typealias CustomNodeContainerProvider = (Any) -> NodeContainer?
object UiTestConfigurator {
internal var CUSTOM_PROVIDER: CustomNodeContainerProvider? = null
fun setCustomNodeContainer(containerProvider: CustomNodeContainerProvider) {
CUSTOM_PROVIDER = containerProvider
}
}
Теперь, если вы настроите свой провайдер (обычно это делается в init
-блоке базового класса для тестов), один и тот же тест будет работать с Kaspresso, Ultron или нативным Compose Testing, в зависимости от того, какой фреймворк вы используете в конкретном проекте.
4.) BaseNodeContext
— базовый контекст для всех нод.
Это интерфейс, который предоставляет всем нашим тестовым компонентам набор общих, часто используемых методов для проверки состояния и взаимодействия.
interface BaseNodeContext {
// делегат для работы с нодой
val container: NodeContainer
// Проверяем что нода отображается на экране
fun assertIsDisplayed() = container.interact {
this.assertIsDisplayed()
}
// Проверяем кликабельность ноды
fun assertHasClickAction() = container.interact {
this.assertHasClickAction()
}
// Выводим в лог дерево нод этой ноды и смотрим по тегу "Node tree of"
fun printTreeToLog() = container.interact {
this.printToLog("Node tree of ${this::class.simpleName.orEmpty()}")
}
}
Все тестовые компоненты получают эти методы «из коробки». Для демонстрации представлен минимальный список функций, но вы можете дополнить его любыми функциями, которые применяются ко всем нодам. Например, можно добавить метод assert
(semanticsMatcher: SemanticsMatcher
) как «заплатку» на случай, если вам понадобится проверить что-то очень специфичное, что нельзя покрыть базовым набором методов.
Для реализации цепочки вызовов функций, как в Ultron или нативном фреймворке, вместо терминальной функции (возвращающей Unit) нужно сделать возврат NodeContainer
.
Например, мы можем добавить возврат NodeContainer
с помощью apply
:
fun assertIsDisplayed(): ViennaNodeContainer = container.apply {
interact { this.assertIsDisplayed() }
}
5.) DSL<T>
— «синтаксический сахар» для тестов.
Этот интерфейс, знакомый многим по Kaspresso, делает взаимодействие с ComponentBaseNode
в тестах более читаемым и лаконичным.
interface DSL<out T> {
operator fun invoke(function: T.() -> Unit) {
function(this as T)
}
infix fun perform(function: T.() -> Unit): T {
function(this as T)
return this
}
}
Он позволяет писать тесты в стиле, напоминающем Kotlin DSL, что значительно улучшает их читаемость.
6.) Page Object для текста и иконки со своим контекстом.
Теперь мы можем создавать конкретные реализации ComponentBaseNode
для каждого типа UI-компонента в нашей дизайн-системе и добавлять специфичные для них методы проверки.
TextNode:
class TextNode(
nodeProvider: () -> Any,
) : ComponentBaseNode<TextNode>(nodeProvider), TextNodeContext
interface TextNodeContext : BaseNodeContext {
fun assertValueEquals(value: String) = container.interact {
this.assertValueEquals(value)
}
// ... (другие специфичные для работы с текстом методы)
}
IconNode:
class IconNode(
nodeProvider: () -> Any,
) : ComponentBaseNode<IconNode>(nodeProvider), IconNodeContext
interface IconNodeContext : BaseNodeContext {
fun assertContentDescriptionEquals(value: String) = container.interact {
this.assertContentDescriptionEquals(value)
}
// ... (любые другие специфичные для иконок методы)
}
Как писать тесты: пример с TextInputNode
Давайте посмотрим, как теперь будет выглядеть Page Object для нашего TextInputNode
и сам тест.
Создаём TextInputNode:
class TextInputNode(
nodeProvider: () -> Any,
) : ComponentBaseNode<TextInputNode>(nodeProvider) {
val inputFieldNode = TextNode {
container.child("InputFieldTag")
}
val labelNode = TextNode {
container.child("LabelTag")
}
val leftIconNode = IconNode {
container.child("LeftIconTag")
}
val errorTextNode = TextNode {
container.child("ErrorTag")
}
}
Обратите внимание, что внутри TextInputNode
мы используем уже созданные TextNode
и IconNode
. Каждый из них знает, как найти свою внутреннюю часть, используя container.child()
. Это и есть та самая инкапсуляция, о которой мы говорили.
Пишем тест:
fun testTextInputNode() {
// Инициализируем TextInputNode, передавая ему провайдер ноды
val amountInputField2 = ViennaTextInputNode {
// В данном случае провайдером выступает функция из нативной библиотеки
rule.onNode(hasTestTag("AmountInputTag"))
}
// Здесь применяем функцию invoke из нашего DSL
amountInputField {
assertIsDisplayed()
labelNode {
assertIsDisplayed()
assertTextEquals("Сумма")
}
errorTextNode {
assertIsDisplayed()
assertTextEquals("Максимум 1 млн")
}
leftIconNode {
assertIsDisplayed()
assertContentDescriptionEquals("Иконка поиска")
}
inputFieldNode {
assertIsDisplayed()
assertTextEquals("10 000 000 ₽")
assertHasClickAction()
}
}
}
Как видите, тест стал гораздо более читаемым. Мы работаем напрямую с TextInputNode
и его внутренними Node
'ами, не углубляясь в рутину поиска по test-tag
'ам в каждом шаге.
Выгода от такого подхода
Внедрение Page Object'ов для каждого компонента дизайн-системы даёт нам ряд существенных преимуществ:
Прощай, рутина поиска тегов: Больше не нужно тратить время на поиск
test-tag
'ов внутренних компонентов. Page Object сам знает, где они находятся.Минимум дублирования кода: Логика поиска нод инкапсулирована внутри Page Object'а и используется многократно, в том числе на разных проектах за счет отдельной библиотеки.
Стабильность тестов: Тесты становятся гораздо более устойчивыми к изменениям в дизайн-системе. Если изменился
test-tag
внутри компонента, нужно поправить только соответствующий Page Object, а не все тесты, которые его используют.Улучшена проверка виджетов в самой ДС: За счет добавления UI-тестов для дизайн-системы, мы дополнительно проверяем разные состояния виджетов.
Читаемость и поддерживаемость: Тесты становятся понятнее как для опытных автоматизаторов, так и для новичков. Легче втянуться в процесс написания тестов.
Фреймворк-независимость: Благодаря абстракциям и
UiTestConfigurator
наше решение работает с любым UI-тестовым фреймворком (оберткой на Сompose testing), который вы используете в проекте (Kakao, Ultron и нативный Compose Testing).
UI-тесты на Compose могут быть источником боли, но с помощью системного подхода и внедрения Page Object'ов для каждого UI-компонента, можно упростить их написание, повысить стабильность и сделать процесс более приятным.
Надеюсь, наш опыт будет полезен и вам. Попробуйте применить эту идею в своих проектах и поделитесь результатами в комментариях!
Заглядывая в будущее: еще больше автоматизации рутины с AI
Нашим следующим шагом в автоматизации UI-тестов на Compose станет AI-агент в Android Studio с кастомными инструкциями. AI-агент будет анализировать виджеты на конкретном Compose Screen, сопоставлять их с тестовыми нодами и автоматически генерировать код тестового Compose Screen для использования в UI-тесте. Это позволит ускорить создание тестовой инфраструктуры и минимизирует работу руками.
Мы верим, что AI поможет с автоматизацией и упростит сложные UI-сценарии. Следите за обновлениями!