Тесты для функций compose в андроид

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

© Habrahabr.ru