Представьте себе мир, где каждый раз, когда вы вносите изменения в код вашего приложения, вы уверены, что ничего не сломалось. Где ошибки обнаруживаются еще до того, как пользователи успеют их заметить. Где ваш код не только работает, но и документируется автоматически, улучшая архитектуру проекта с каждым тестом. Звучит как мечта? На самом деле это реальность, если вы правильно используете тесты. В этой статье мы погрузимся в мир тестирования 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. Ну и локально, если что-то пошло не так.