Введение в Тестирование Jetpack Compose
Jetpack Compose революционизировал разработку UI в Android с декларативным подходом, и тестирование UI в Compose требует принципиально иного мышления по сравнению с традиционным тестированием на основе View. В отличие от тестов Espresso (как обсуждается в Espresso & XCUITest: Mastering Native Mobile Testing Frameworks), которые полагаются на иерархии View, тестирование Compose использует дерево семантики—параллельную структуру, описывающую элементы UI для целей доступности и тестирования.
Фреймворк тестирования Compose предоставляет мощный, интуитивный API, который бесшовно интегрируется с JUnit и позволяет писать тесты, которые одновременно читаемы и поддерживаемы. Независимо от того, тестируете ли вы простые composable или сложные навигационные потоки, понимание основ тестирования Compose критически важно для обеспечения надежности UI.
Настройка Тестового Окружения
Конфигурация Зависимостей
Добавьте необходимые тестовые зависимости в ваш build.gradle.kts
:
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
(как обсуждается в [Mobile Testing in 2025: iOS, Android and Beyond](/blog/mobile-testing-2025-ios-android-beyond)) (как обсуждается в [Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing](/blog/appium-2-architecture-cloud)) }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM для выравнивания версий
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
// Тестирование Compose UI
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Инфраструктура тестирования
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
Обзор Структуры Тестов
Компонент | Назначение | Расположение |
---|---|---|
ui-test-junit4 | Основные API тестирования и ComposeTestRule | androidTest |
ui-test-manifest | Требуется для debug сборок | debug |
JUnit 4 | Test runner и утверждения | androidTest |
Espresso | Опционально, для совместимости с View | androidTest |
Понимание Дерева Семантики
Дерево семантики является основой тестирования Compose. Каждый composable может предоставлять семантическую информацию через модификаторы:
@Composable
fun LoginButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.testTag("login_button")
.semantics {
contentDescription = "Войти в ваш аккаунт"
role = Role.Button
}
) {
Text("Войти")
}
}
Ключевые Семантические Свойства
- testTag: Основной идентификатор для поиска узлов
- contentDescription: Описание для доступности
- text: Текстовое содержимое composable
- role: Семантическая роль (Button, Checkbox, и т.д.)
- stateDescription: Описание текущего состояния
Поиск Узлов с Помощью Matchers
// По test tag
composeTestRule.onNodeWithTag("login_button")
// По тексту
composeTestRule.onNodeWithText("Войти")
// По content description
composeTestRule.onNodeWithContentDescription("Войти в ваш аккаунт")
// Множественные узлы
composeTestRule.onAllNodesWithTag("list_item")
// Комплексные matchers
composeTestRule.onNode(
hasText("Войти") and hasClickAction() and isEnabled()
)
ComposeTestRule и Жизненный Цикл Тестов
Создание Test Rules
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_whenClicked_triggersCallback() {
var loginClicked = false
composeTestRule.setContent {
LoginButton(onClick = { loginClicked = true })
}
composeTestRule.onNodeWithTag("login_button")
.performClick()
assert(loginClicked)
}
}
Варианты Test Rules
Тип Rule | Случай Использования | Метод Создания |
---|---|---|
createComposeRule() | Тестирование отдельных composables | Ручная настройка содержимого |
createAndroidComposeRule<Activity>() | Тестирование с контекстом Activity | Автоматический запуск Activity |
createEmptyComposeRule() | Продвинутые сценарии с кастомной настройкой | Ручное управление Activity |
Управление Таймингом Тестов
@Test
fun animation_completesSuccessfully() {
composeTestRule.setContent {
AnimatedComponent()
}
// Ждать idle (анимации завершены)
composeTestRule.waitForIdle()
// Ждать конкретного условия
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithTag("animated_item")
.fetchSemanticsNodes().size == 5
}
}
Тестирование UI Компонентов с API Compose
Базовые Утверждения
@Test
fun userProfile_displaysCorrectInformation() {
val user = User("Иван Иванов", "ivan@example.com")
composeTestRule.setContent {
UserProfileCard(user = user)
}
composeTestRule.onNodeWithTag("user_name")
.assertExists()
.assertIsDisplayed()
.assertTextEquals("Иван Иванов")
composeTestRule.onNodeWithTag("user_email")
.assertTextContains("ivan@example.com")
}
Тестирование Видимости и Состояния Enabled
@Test
fun submitButton_isDisabledWhenFormIncomplete() {
composeTestRule.setContent {
RegistrationForm(
email = "",
password = ""
)
}
composeTestRule.onNodeWithTag("submit_button")
.assertIsNotEnabled()
// Заполнить поля формы
composeTestRule.onNodeWithTag("email_field")
.performTextInput("user@example.com")
composeTestRule.onNodeWithTag("password_field")
.performTextInput("БезопасныйПароль123")
composeTestRule.onNodeWithTag("submit_button")
.assertIsEnabled()
}
Тестирование Списков и Коллекций
@Test
fun productList_displaysAllItems() {
val products = listOf(
Product("Ноутбук", "$999"),
Product("Мышь", "$29"),
Product("Клавиатура", "$79")
)
composeTestRule.setContent {
ProductList(products = products)
}
composeTestRule.onAllNodesWithTag("product_item")
.assertCountEquals(3)
composeTestRule.onAllNodesWithTag("product_item")[0]
.assertTextContains("Ноутбук")
.assertTextContains("$999")
}
Тестирование Изменений Состояния и Перекомпоновки
Тестирование UI на Основе Состояния
@Test
fun counter_incrementsCorrectly() {
composeTestRule.setContent {
CounterScreen()
}
// Начальное состояние
composeTestRule.onNodeWithTag("counter_text")
.assertTextEquals("Счетчик: 0")
// Вызвать изменение состояния
composeTestRule.onNodeWithTag("increment_button")
.performClick()
// Проверить перекомпоновку
composeTestRule.onNodeWithTag("counter_text")
.assertTextEquals("Счетчик: 1")
}
Тестирование Интеграции с ViewModel
@Test
fun loadingState_showsProgressIndicator() {
val viewModel = ProfileViewModel().apply {
_uiState.value = UiState.Loading
}
composeTestRule.setContent {
ProfileScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithTag("loading_indicator")
.assertIsDisplayed()
// Симулировать загруженные данные
viewModel._uiState.value = UiState.Success(userData)
composeTestRule.onNodeWithTag("loading_indicator")
.assertDoesNotExist()
composeTestRule.onNodeWithTag("user_content")
.assertIsDisplayed()
}
Тестирование Взаимодействий Пользователя
Взаимодействия Click и Tap
@Test
fun favoriteButton_togglesState() {
var isFavorite by mutableStateOf(false)
composeTestRule.setContent {
FavoriteButton(
isFavorite = isFavorite,
onToggle = { isFavorite = !isFavorite }
)
}
composeTestRule.onNodeWithTag("favorite_button")
.assertHasContentDescription("Добавить в избранное")
.performClick()
composeTestRule.onNodeWithTag("favorite_button")
.assertHasContentDescription("Удалить из избранного")
}
Тестирование Ввода Текста
@Test
fun searchField_filtersResults() {
composeTestRule.setContent {
SearchableList(items = testItems)
}
composeTestRule.onNodeWithTag("search_field")
.performTextInput("Android")
composeTestRule.waitForIdle()
composeTestRule.onAllNodesWithTag("search_result")
.assertCountEquals(3)
// Очистить и проверить
composeTestRule.onNodeWithTag("search_field")
.performTextClearance()
composeTestRule.onAllNodesWithTag("search_result")
.assertCountEquals(testItems.size)
}
Тестирование Жестов
@Test
fun imageGallery_supportsSwipeGestures() {
composeTestRule.setContent {
ImageGallery(images = imageList)
}
// Индикатор текущего изображения
composeTestRule.onNodeWithTag("image_indicator")
.assertTextEquals("1 из 5")
// Выполнить свайп влево
composeTestRule.onNodeWithTag("image_pager")
.performTouchInput {
swipeLeft()
}
composeTestRule.onNodeWithTag("image_indicator")
.assertTextEquals("2 из 5")
}
Тестирование Навигации в Compose
Тестирование NavController
@Test
fun navigation_navigatesToDetailScreen() {
lateinit var navController: NavHostController
composeTestRule.setContent {
navController = rememberNavController()
AppNavGraph(navController = navController)
}
// Клик по элементу для навигации
composeTestRule.onNodeWithTag("item_1")
.performClick()
// Проверить, что навигация произошла
val currentRoute = navController.currentBackStackEntry?.destination?.route
assert(currentRoute == "detail/1")
// Проверить отображение экрана деталей
composeTestRule.onNodeWithTag("detail_screen")
.assertIsDisplayed()
}
Тестирование Навигации Назад
@Test
fun backButton_navigatesToPreviousScreen() {
lateinit var navController: NavHostController
composeTestRule.setContent {
navController = rememberNavController()
AppNavGraph(
navController = navController,
startDestination = "detail/123"
)
}
composeTestRule.onNodeWithTag("back_button")
.performClick()
assert(navController.currentDestination?.route == "home")
}
Мокирование и Внедрение Зависимостей
Мокирование Репозиториев
class ProductScreenTest {
private lateinit var fakeRepository: FakeProductRepository
@Before
fun setup() {
fakeRepository = FakeProductRepository()
}
@Test
fun errorState_displaysErrorMessage() {
fakeRepository.setError("Ошибка сети")
val viewModel = ProductViewModel(fakeRepository)
composeTestRule.setContent {
ProductScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithTag("error_message")
.assertIsDisplayed()
.assertTextContains("Ошибка сети")
}
}
class FakeProductRepository : ProductRepository {
private var error: String? = null
private var products: List<Product> = emptyList()
fun setError(message: String) {
error = message
}
override suspend fun getProducts(): Result<List<Product>> {
return error?.let { Result.failure(Exception(it)) }
?: Result.success(products)
}
}
Интеграция с Hilt Testing
@HiltAndroidTest
class DashboardScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var repository: TestRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun dashboard_loadsDataSuccessfully() {
repository.setTestData(dashboardData)
composeTestRule.onNodeWithTag("dashboard_content")
.assertIsDisplayed()
}
}
Screenshot Testing для Compose
Интеграция с Paparazzi
class ButtonScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "android:Theme.Material3.DayNight"
)
@Test
fun primaryButton_defaultState() {
paparazzi.snapshot {
PrimaryButton(text = "Нажми Меня", onClick = {})
}
}
@Test
fun primaryButton_disabledState() {
paparazzi.snapshot {
PrimaryButton(
text = "Нажми Меня",
onClick = {},
enabled = false
)
}
}
}
Библиотека Shot для Android
@RunWith(ScreenshotTestRunner::class)
class ProductCardScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun productCard_lightTheme() {
composeTestRule.setContent {
MyAppTheme(darkTheme = false) {
ProductCard(product = sampleProduct)
}
}
compareScreenshot(composeTestRule)
}
}
Тестирование Производительности
Отслеживание Перекомпоновок
@Test
fun lazyList_minimizesRecompositions() {
var recompositionCount = 0
composeTestRule.setContent {
LaunchedEffect(Unit) {
snapshotFlow { recompositionCount }
.collect { println("Перекомпоновок: $it") }
}
LazyColumn {
items(100) { index ->
RecomposeHighlighter {
recompositionCount++
ListItem(index = index)
}
}
}
}
// Прокрутить и проверить минимальные перекомпоновки
composeTestRule.onNodeWithTag("lazy_column")
.performScrollToIndex(50)
composeTestRule.waitForIdle()
// Только видимые элементы должны перекомпоноваться
assert(recompositionCount < 120) // ~20 видимых элементов буфер
}
Benchmark Testing
@RunWith(AndroidJUnit4::class)
class ComposeBenchmarkTest {
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun complexList_scrollPerformance() {
benchmarkRule.measureRepeated {
composeTestRule.setContent {
ComplexProductList(items = testData)
}
runWithTimingDisabled {
composeTestRule.waitForIdle()
}
composeTestRule.onNodeWithTag("product_list")
.performScrollToIndex(100)
}
}
}
Интеграция CI/CD
Конфигурация GitHub Actions
name: UI Тесты Compose
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ main ]
jobs:
instrumentation-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Настроить JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Дать права на выполнение gradlew
run: chmod +x gradlew
- name: Запустить тесты Compose
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
script: ./gradlew connectedAndroidTest
- name: Загрузить отчеты о тестах
if: always()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: app/build/reports/androidTests/
Test Sharding
# Разделить тесты между несколькими устройствами
./gradlew connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.numShards=4 \
-Pandroid.testInstrumentationRunnerArguments.shardIndex=0
Лучшие Практики и Общие Паттерны
1. Семантический Контент Вместо Test Tags
Избегать:
Button(
onClick = onClick,
modifier = Modifier.testTag("submit_button")
) {
Text("Отправить")
}
composeTestRule.onNodeWithTag("submit_button").performClick()
Предпочтительно:
Button(
onClick = onClick,
modifier = Modifier.semantics {
contentDescription = "Отправить форму"
}
) {
Text("Отправить")
}
composeTestRule.onNodeWithContentDescription("Отправить форму").performClick()
2. Извлекать Test Helpers
object ComposeTestHelpers {
fun SemanticsNodeInteraction.assertHasTextAndIsEnabled(text: String) {
assertTextEquals(text)
assertIsEnabled()
}
fun ComposeTestRule.waitForNodeWithTag(
tag: String,
timeoutMillis: Long = 3000
) {
waitUntil(timeoutMillis) {
onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
}
3. Изоляция Тестов
@Test
fun isolatedTest_resetsState() {
composeTestRule.setContent {
// Свежее состояние для каждого теста
var count by remember { mutableStateOf(0) }
CounterComponent(
count = count,
onIncrement = { count++ }
)
}
// Тестировать специфическое поведение без побочных эффектов
}
4. Значимые Утверждения
Плохая Практика | Хорошая Практика |
---|---|
Только assertExists() | assertIsDisplayed() + верификация контента |
Множественные performClick() без ожиданий | performClick() + waitForIdle() |
Тестирование деталей реализации | Тестирование видимого пользователю поведения |
Жестко заданные задержки | waitUntil() с условиями |
5. Тестирование с Фокусом на Доступность
@Test
fun form_isAccessible() {
composeTestRule.setContent {
RegistrationForm()
}
// Проверить семантические свойства
composeTestRule.onNodeWithTag("email_field")
.assertHasContentDescription("Адрес электронной почты")
.assert(hasImeAction(ImeAction.Next))
composeTestRule.onNodeWithTag("password_field")
.assertHasContentDescription("Пароль")
.assert(hasImeAction(ImeAction.Done))
.assert(hasPasswordSemantics())
}
6. Управление Тестовыми Данными
object TestData {
val sampleUser = User(
id = "test_123",
name = "Тестовый Пользователь",
email = "test@example.com"
)
val productList = List(20) { index ->
Product(
id = "product_$index",
name = "Продукт $index",
price = (index + 1) * 10.0
)
}
}
Заключение
Тестирование Jetpack Compose предоставляет надежный фреймворк для обеспечения качества UI в современных Android приложениях. Используя дерево семантики, ComposeTestRule и комплексные API тестирования, вы можете писать поддерживаемые тесты, которые проверяют как функциональность, так и пользовательский опыт. Ключевые выводы:
- Понимать дерево семантики как основу тестирования Compose
- Использовать подходящие matchers для надежного поиска UI элементов
- Тестировать изменения состояния и перекомпоновку для обеспечения реактивного поведения UI
- Внедрять комплексное тестирование взаимодействий для пользовательских потоков
- Интегрировать screenshot testing для обнаружения визуальных регрессий
- Оптимизировать для CI/CD с правильным sharding и отчетностью
- Следовать практикам доступности прежде всего для инклюзивного тестирования
По мере развития Compose, поддержание актуальности с лучшими практиками тестирования обеспечивает, что ваши Android приложения остаются надежными, производительными и доступными для всех пользователей.