Представьте себе мир, где каждый раз, когда вы вносите изменения в код вашего приложения, вы уверены, что ничего не сломалось. Где ошибки обнаруживаются еще до того, как пользователи успеют их заметить. Где ваш код не только работает, но и документируется автоматически, улучшая архитектуру проекта с каждым тестом. Звучит как мечта? На самом деле это реальность, если вы правильно используете тесты. В этой статье мы погрузимся в мир тестирования Android-приложений с использованием Jetpack Compose, рассмотрим различные виды тестов и научимся настраивать и писать инструментальные тесты для ваших Compose функций.
Зачем нужны вообще тесты?
Обеспечение качества кода
Проверять крайние случаи, к��торые может не учесть разработчик.
Тесты для регрессии
Само документация кода
Улучшение архитектуры кода
Основные виды тестов
Unit тесты
Mockito чтобы делать моки – объекты реальных классов с измененным поведением
Robolectric – нам нужен в случае, когда мы хотим протестировать код, который зависит от компонентов андроид или связан с контекстом.
Интеграционные тесты
Тестируем как работают разные компоненты системы друг с другом. Например база данных с приложением.
Инструментальные тесты
Можем протестировать уже сам ui. Например. Проверить что при нажатии на кнопку отобразится текст.
Screenshot тесты
Верифицирует совпадение скринов и кода.
Инструментальные тесты
Я предлагаю для наших compose функций использовать инструментальные тесты.
Они включены в основой фреймворк compose. Подключаются через gradle.
toml file:
compose-bom = "2024.08.00"
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
Gradle app:
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
androidTestImplementation(libs.compose.ui.test.junit4)Практика
После того как они подключатся, будет автоматически создана папка androidTest. Внутри нее и будут создаваться наши тесты.
Я решил написать простенький тест для одного из экранов своего приложения. На нем можно ввести данные своей карты.

Что значат эти строки?
@RunWith(AndroidJUnit4::class)
class AddPaymentInstrumentedTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun testAddPaymentScreen() {
composeTestRule.setContent {
//тут будут наши тесты
}
}
}Если вкратце, то с помощью них мы создаем среду, в которой мы будем вызывать наши compose функции. Правилом может быть набор функция before или after. Но его удобнее внедрять в тесты и использовать.
Если подробно:
AndroidJUnit4 – Это аннотация, которая указывает, что тесты в данном классе должны выполняться с использованием AndroidJUnit4 тестового раннера.
createComposeRule — это функция, которая создает правило для тестирования Jetpack Compose UI компонентов.
Правило ComposeTestRule, созданное с помощью createComposeRule, предоставляет набор методов для тестирования Compose UI. Оно позволяет устанавливать содержимое Compose, взаимодействовать с UI-элементами и проверять их состояние.
И зачастую мы хотим ограничить ввод символов в поля карты.
Например в поле номер карты мы хотим вводить только 16 символов, при этом они могут быть только цифрами.
Давайте напишем для этого тест.
Сначала нам нужно найти ноду – так называется во фркймворке тестрования элемент в дереве ui.
Детали реализации
Поиск можно делать либо по тегу, либо по тексту. Для удобства, тег можно добавить в поле modifier. Он так и называется testTag(). Не путать с тэгом, который у нас был в XML. Этот можно использовать лишь для тестов. А после этого мы можем проверить, какая информация находится в поле. В этом нам помогает система matcher-ов и assertion-ов. Это классы, в которые можно передать необходимое условие и проверить, удовлетворяется ли оно. Например assertTestEquals(). Или assertIsDispayed(). Матчеров достаточно много, поэтому я прикрепил ссылку, чтобы не запутаться.
assertTestEquals
Что касается матчера assertTestEquals() – он проверяет идентичность текста. Причем как hint-а, так и введенного текста. Для этого мы передаем параметры hint и edit text. Это может поначалу вызвать недоумение, но так это работает.
Какие кейсы тестируем
Итак мы написали тесты для всех наших кейсов:
Задаем стейт с которым создается функция
val cardNumber = "1234"
val hint = context.getString(R.string.card_number_label)
var state by mutableStateOf(
AddPaymentState(cardNumber = cardNumber)
)
composeTestRule.setContent {
AddPaymentScreen({
when (it) {
is AddPaymentAction.CardNumberEntered -> {
state = state.copy(cardNumber = it.cardNumber)
}
else -> {}
}
}, state)
}Начальное состояние поля
//check current cardNumber state
onNodeWithTag(CARD_NUMBER_TEST_TAG)
.assertIsDisplayed()
onNodeWithTag(CARD_NUMBER_TEST_TAG)
.assertTextEquals(hint, cardNumber, includeEditableText = true)Состояние после ввода НЕ цифр
//strings are not allowed
onNodeWithTag(CARD_NUMBER_TEST_TAG).performTextInput("test")
onNodeWithTag(CARD_NUMBER_TEST_TAG)
.assertTextEquals(hint, cardNumber, includeEditableText = true)Состояние после ввода цифр
//digits are allowed
val digitInput = "567855657787"
val digitWithSpacesInput = "5678 5565 7787"
onNodeWithTag(CARD_NUMBER_TEST_TAG).performTextInput(digitInput)
onNodeWithTag(CARD_NUMBER_TEST_TAG)
.assertTextEquals(hint, "$cardNumber $digitWithSpacesInput", includeEditableText = true)Состояние после ввода больше чем ограничение на макс кол-во символов
//no more then 16 digits allowed
val moreInput = "5678"
onNodeWithTag(CARD_NUMBER_TEST_TAG).performTextInput(moreInput)
onNodeWithTag(CARD_NUMBER_TEST_TAG)
.assertTextEquals(hint, "$cardNumber $digitWithSpacesInput", includeEditableText = true)Для удобства, разные тесты можно разбить на отдельные функции, тогда они не будут завершаться после падения одного.
Как выглядит ошибка в тестах
java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Номер карты,12341])
Semantics of the node:
Node #14 at (l=44.0, t=242.0, r=1036.0, b=462.0)px, Tag: 'cardNumber'
EditableText = '1234 1'
TextSelectionRange = 'TextRange(0, 0)'
ImeAction = 'Default'
Focused = 'false'
Text = '[Номер карты]'
Actions = [GetTextLayoutResult, SetText, InsertTextAtCursor, SetSelection, PerformImeAction, OnClick, OnLongClick, PasteText, RequestFocus, SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution]
MergeDescendants = 'true'
Has 7 siblings
Selector used: (TestTag = 'cardNumber')Как видим, дается достаточно подробное описание того, где свалился тест, и что пошло не так. А также самой ноды, которая сломала тесты.
Что еще можно протестировать? Частые случаи
Нажатие на кнопку - можем как имитировать нажатие, так и проверять, было ли оно произведено
Enabled/disabled состояние. Например состояние кнопки
Visibility - видимость элемента
и многое другое
Итог
Вот и все. В итоге мы научились запускать инструментальные тесты для compose функций. Остался добавить что это достаточно трудоемкая операция. Запускается на девайсе. И запускать их при каждой ci сборке может быть затратно. Поэтому можно настроить сервис, который будет запускать такие тесты, например, раз в день – ночью. Например облачный сервис типа AWS. Ну и локально, если что-то пошло не так.
