Введение в Тестирование 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 тестирования и ComposeTestRuleandroidTest
ui-test-manifestТребуется для debug сборокdebug
JUnit 4Test runner и утвержденияandroidTest
EspressoОпционально, для совместимости с ViewandroidTest

Понимание Дерева Семантики

Дерево семантики является основой тестирования 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 приложения остаются надежными, производительными и доступными для всех пользователей.