Всем привет! В этой статье я расскажу, как подключал скриншот-тестирование с помощью Roborazzi в проекте, с какими проблемами столкнулся в процессе и как их решал, а также поделюсь кодом.

Введение

Для начала — немного контекста. UiKit — это библиотека визуальных компонентов на Jetpack Compose, которая лежит в основе UI-слоя Android-приложения. Именно из этих компонентов собираются экраны и фичи, поэтому любые изменения в UiKit напрямую отражаются на внешнем виде интерфейса.

По этой причине UiKit логично покрывать скриншот-тестами в первую очередь. Любая правка в базовых компонентах может затронуть десятки экранов, а из-за обширной палитры и активного развития библиотеки такие визуальные изменения не всегда легко заметить при ручном тестировании — отличить на глаз 50 оттенков серого может быть проблематично.

Итог очевиден: плюсов у скриншот-тестирования много, а значит — самое время попробовать внедрить его на практике.

Подключение Roborazzi

Так как в проекте используется version catalog, добавим следующие строки в файл libs.versions.toml:

[versions]
... 
roborazzi = "1.52.0"
composable‑preview‑scanner = "0.7.2"
robolectric = "4.16"
junit = "4.13.2"
coil-compose = "3.3.0"

[libraries]
...
# Compose testing. Версии этих зависимостей берутся из Compose BOM, либо можно указать явно
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }

# Базовые зависимости для Roborazzi
roborazzi-core = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }

# Preview Scanner — если хотите добавлять тесты автоматически для ваших @Preview
roborazzi-previewScanner = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose-preview-scanner-support", version.ref = "roborazzi"}
composable-preview-scanner = { module = "io.github.sergio-sastre.ComposablePreviewScanner:android", version.ref = "composable-preview-scanner" }

# JUnit — если хотите добавлять тесты руками
junit = { group = "junit", name = "junit", version.ref = "junit" }

# Coil — если хотите тестировать компоненты, использующие Coil
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil-compose" }
coil-test = { group = "io.coil-kt.coil3", name = "coil-test", version.ref = "coil-compose" }

[plugins]
...
# Плагин Roborazzi для подключения в build.gradle.kts файле проекта
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }

Далее будет рассмотрено как тестирование с помощью Compose Preview, так и вручную добавленные тесты.

Ради соблюдения принципов многомодульности для скриншот-тестирования создадим отдельный модуль screenshot-tests. Но можно добавить тесты и в сам модуль с компонентами, подключив все необходимые зависимости в build.gradle.kts модуля:

import com.github.takahirom.roborazzi.ExperimentalRoborazziApi

plugins {
    ...
    id("io.github.takahirom.roborazzi")
}

android {
    ...
    testOptions {
        unitTests {
            isIncludeAndroidResources = true
            all {
                it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"

                // Roborazzi использует под капотом Robolectric, который тянет зависимости из Maven Central во время выполнения.
                // Если ваш CI находится в закрытом контуре, можно решить эту проблему настройкой
                it.systemProperties["robolectric.dependency.repo.url"] = your_repo_url
            }
        }
    }
}

roborazzi {
    // Путь до каталога, где будут храниться эталонные скриншоты
    outputDir.set(file("src/test/screenshots"))

    @OptIn(ExperimentalRoborazziApi::class)
    generateComposePreviewRobolectricTests {
        enable = true

        // В списке перечисляются пакеты, @Preview-функции из которых должны быть протестированы.
        // Я включил сюда пакет модуля UiKit, а также пакет, в котором будут храниться
        // тесты, написанные вручную.
        packages = listOf(
            "com.wayloren.uikit",
            "com.wayloren.screenshot_tests.previews"
        )

        robolectricConfig = mapOf(
            "sdk" to "[35]",
            "qualifiers" to "RobolectricDeviceQualifiers.Pixel7",
        )

        // Кастомный класс ComposePreviewTester. О нем подробнее рассказано далее.
        // На данном этапе его можно не указывать.
        testerQualifiedClassName =
            "com.wayloren.screenshot_tests.CustomAndroidComposePreviewTester"
    }
}

dependencies {
    // В блоке зависимостей подключаем модули проекта, которые хотим тестировать
    // и нужные библиотеки, которые были описаны в libs.versions.toml
    implementation(project(":uikit"))
}

Генерация эталонных скриншотов и настройка CI

Roborazzi подключен, превью компонентов уже были написаны во время разработки, а значит, можно смотреть, что у нас получилось. Для начала создадим эталонные скриншоты командой ./gradlew recordRoborazziDebug

Настроим CI для проверки соответствия компонентов эталонным скриншотам при каждом merge request. В случае ошибки отчёт будет храниться в артефактах в течение одного дня:

validate:screenshots ?:
  stage: test
  when: on_success
  only:
    - merge_requests
  artifacts:
    when: on_failure
    expire_in: "1 day"
    paths:
      - "screenshot-tests/build/reports"
      - "screenshot-tests/build/outputs"
      - "screenshot-tests/build/test-results"
  dependencies:
    - build ?
  needs:
    - build ?
  script:
    - ./gradlew verifyRoborazziDebug
  extends:
    - .linux-mobile-docker-runners
    - .android-cache-common

Но при первом запуске на CI проверка validate:screenshots завершилась с ошибкой! В отчёте были картинки с отличиями всего в несколько пикселей, невидимыми глазу, как на изображении ниже:

Неуловимые для глаза несоответствия в логотипах платёжных систем
Неуловимые для глаза несоответствия в логотипах платёжных систем

Решение ошибки верификации

Причина оказалась в различии рендеринга на разных ОС: эталонные скриншоты были созданы на macOS, а CI работает на Linux. Из-за этого появились незначительные смещения пикселей, которые Roborazzi фиксирует как изменения.

Решить это можно с помощью того самого кастомного ComposePreviewTester, о котором я упоминал раньше. Базовая реализация класса взята из AndroidComposePreviewTester, который есть в Roborazzi, нас интересует только настройка roborazziOptions:

@ExperimentalRoborazziApi
class CustomAndroidComposePreviewTester : ComposePreviewTester<AndroidPreviewJUnit4TestParameter> {

    override fun testParameters(): List<AndroidPreviewJUnit4TestParameter> {
        ...
    }

    override fun test(testParameter: AndroidPreviewJUnit4TestParameter) {
        ...
        preview.captureRoboImage(
            roborazziOptions = RoborazziOptions(
                compareOptions = RoborazziOptions.CompareOptions(
                    imageComparator = SimpleImageComparator(
                        // максимально допустимое евклидово расстояние между цветами сравниваемых пикселей
                        maxDistance = MAX_DISTANCE,
                        // вертикальное смещение окна сдвига
                        vShift = 1,
                        // горизонтальное смещение окна сдвига
                        hShift = 1
                    )
                )
            ),
            ...
        )
    }
}

// Коэффициент подобран как минимальное евклидово расстояние между цветами палитры
internal const val MAX_DISTANCE: Float = 0.027F

Собственно, установка параметров vShift и hShift помогает решить проблему верификации скриншотов на разных ОС: в случае, если цвета пикселей при сравнении не совпали, будет проверяться окно сдвига.

Здесь же сразу зададим параметр maxDistance — максимальное допустимое евклидово расстояние между двумя цветами. Посчитав попарно все расстояния для цветов из нашей палитры, мы подобрали такой коэффициент, чтобы фиксировать различия между самыми близкими цветами.

Теперь команда ./gradlew verifyRoborazziDebug завершается успешно как на локальной машине с любой ОС, так и на CI.

Обновление эталонных скриншотов

Проблему с верификацией мы решили, но осталась ещё одна: те же различия в рендеринге проявляются и при генерации эталонных скриншотов. Например, разработчик на macOS сгенерировал эталонные изображения и закоммитил их в репозиторий. Его коллега на Windows добавляет новый компонент и запускает ./gradlew recordRoborazziDebug, но вместе с новым скриншотом обновляются и старые, хотя менять их не планировалось.

Решение нашлось достаточно быстро — перенести генерацию эталонных скриншотов с локальных машин разработчиков на CI. Настраиваем джобу с ручным запуском, которая обновляет скриншоты и делает коммит:

validate:update-screenshots:
  stage: test
  allow_failure: true
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
    - when: never
  dependencies:
    - build ?
  needs:
    - build ?
  script:
    - ./gradlew recordRoborazziDebug
    # Опционально перед коммитом можно настроить параметры git config 
    - git status
    - git add .
    - |
      if git diff --cached --quiet; then
        echo "No changes to commit"
      else
        git commit -m "Update screenshots"
        git push "https://<CI_USER>:<CI_TOKEN>@<CI_SERVER>/<PROJECT_PATH>.git" "HEAD:<BRANCH_NAME>"
      fi
  extends:
    - .linux-mobile-docker-runners
    - .android-cache-common

Таким образом, общий сценарий добавления/обновления компонентов UiKit выглядит так:

  1. Разработчик обновляет существующий или добавляет новый компонент, создаёт для него @Preview.

  2. Оформляет merge request.

  3. Если validate:screenshots завершился с ошибкой, проводится ревью отчёта Roborazzi: изменения сравниваются с макетами.

  4. Если всё корректно, запускается validate:update-screenshots, и эталонные изображения обновляются централизованно.

Расширение возможностей

Попользовавшись такой конфигурацией скриншот-тестирования, я пришел к выводу, что надо добавить несколько возможностей.

Игнорирование отдельных @Preview

Допустим, у нас есть компонент, имеющий большое количество различных состояний, например поле для ввода текста, которое может быть заполнено, не заполнено, с ошибкой и т.д. В таком случае мы хотим в файле с компонентом оставить только базовое превью, а тестирование всех состояний вынести отдельно. Для этого добавим следующую аннотацию:

/**
 * Аннотация для пометки Compose‑preview функций, которые **не должны** генерироваться
 * в тестах Roborazzi при автоматическом сканировании `@Preview`‑функций.
 * */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class SkipRoborazziTest

Чтобы аннотация заработала, в классе CustomAndroidComposePreviewTester надо добавить одну строчку:

@ExperimentalRoborazziApi
class CustomAndroidComposePreviewTester : ComposePreviewTester<AndroidPreviewJUnit4TestParameter> {
    override fun testParameters(): List<AndroidPreviewJUnit4TestParameter> {
      ...
      return AndroidComposablePreviewScanner().scanPackageTrees(*options.scanOptions.packages.toTypedArray())
            .excludeIfAnnotatedWithAnyOf(SkipRoborazziTest::class.java)
      ...
    }
}    

Пример использования:

@Preview
@SkipRoborazziTest
@Composable
private fun InputTextFieldPreview() {
    DomTheme {
        InputTextField(
            labelText = "Label",
            enabled = true,
            text = "Text",
            supportingText = "Supporting text",
            isLocked = false,
            placeholder = "placeholder",
            isError = false,
            errorText = "Some error text",
            onInfoIconClick = {
            },
        )
    }
}

Таким образом разработчик при работе с компонентом может пользоваться базовой превью-функцией, но в тестирование она не попадёт. А полный набор превью, покрывающий все состояния компонента, выносится в отдельный файл в модуле screenshot-tests:

@Preview
@Composable
private fun InputTextFieldDefaultPreview() {
    MyAppTheme {
        Column(
            modifier = Modifier.background(MyAppTheme.colors.baseWhite),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
//            Empty Enabled
            InputTextField(
                labelText = "Label",
                text = "",
                enabled = true,
            )
//            Empty Disabled
            InputTextField(
                labelText = "Label",
                text = "",
                enabled = false,
            )
//            Filled Enabled
            InputTextField(
                labelText = "Label",
                text = "Value",
                enabled = true,
            )
//            Filled Disabled
            InputTextField(
                labelText = "Label",
                text = "Value",
                enabled = false,
            )
            ...
        }
    }
}

...

@Preview
@Composable
private fun InputTextFieldErrorPreview() {
    MyAppTheme {
        Column(
            modifier = Modifier.background(MyAppTheme.colors.baseWhite),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
//            Empty Enabled
            InputTextField(
                labelText = "Label",
                text = "",
                isError = true,
                errorText = "Text about error here",
                onInfoIconClick = {},
                enabled = true,
            )
//            Empty Disabled
            InputTextField(
                labelText = "Label",
                text = "",
                isError = true,
                errorText = "Text about error here",
                onInfoIconClick = {},
                enabled = false,
            )
//            Filled Enabled
            InputTextField(
                labelText = "Label",
                text = "Value",
                isError = true,
                errorText = "Text about error here",
                onInfoIconClick = {},
                enabled = true,
            )
//            Filled Disabled
            InputTextField(
                labelText = "Label",
                text = "Value",
                isError = true,
                errorText = "Text about error here",
                onInfoIconClick = {},
                enabled = false,
            )
        }
    }
}

Тестирование без @Preview

Также рассмотрим возможность добавлять тесты руками. Например, в моем случае это пригодилось для тестирования компонента баннеров, который для загрузки фоновой картинки использует компонент AsyncImage библиотеки Coil. Чтобы на скриншоте отображалась картинка, а не шиммер , напишем следующий тест:

class BannerTest : ScreenshotTest() {

    @OptIn(DelicateCoilApi::class)
    @Before
    fun before() {
        val context = composeTestRule.activity.applicationContext

        // Создаем Fake Engine для Coil, который для картинок из ресурсов сразу отдаёт SuccessResult
        val engine = FakeImageLoaderEngine.Builder()
            .intercept(
                { it is Int },
                {
                    SuccessResult(
                        image = BitmapFactory.decodeResource(
                            context.resources,
                            it.request.data as Int,
                        ).asImage(),
                        request = it.request,
                    )
                },
            )
            .build()

        val imageLoader = ImageLoader.Builder(context)
            .components { add(engine) }
            .build()

        SingletonImageLoader.setUnsafe(imageLoader)
    }

    @Test
    fun standaloneBanner() = runScreenshotTest {
        MyAppTheme {
            StandaloneBanner(
                banner = BannerModel(
                    id = "id",
                    style = BannerStyle.DARK,
                    backgroundImageModel = R.drawable.uikit_banner_dark_background,
                    title = "Short banner title",
                    text = "Short banner description",
                    closeable = true,
                    badgeText = null,
                ),
                onClick = {},
            )
        }
    }

    @Test
    fun bannerPager() = runScreenshotTest {
        MyAppTheme {
            BannerPager(
                banners = listOf(
                    BannerModel(
                        id = "id",
                        style = BannerStyle.LIGHT,
                        backgroundImageModel = R.drawable.uikit_banner_light_background,
                        title = "Light banner title",
                        text = "Light banner description",
                        closeable = true,
                        badgeText = null,
                    ),
                    BannerModel(
                        id = "id",
                        style = BannerStyle.DARK,
                        backgroundImageModel = R.drawable.uikit_banner_dark_background,
                        title = "Dark banner title",
                        text = "Dark banner description",
                        closeable = true,
                        badgeText = null,
                    ),
                ),
                onBannerClick = {},
                onCloseBannerClick = {},
            )
        }
    }

    ...
}

Код базового класса ScreenshotTest и функции runScreenshotTest:

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = RobolectricDeviceQualifiers.Pixel7)
abstract class ScreenshotTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @get:Rule
    val roborazziRule = RoborazziRule(
        composeRule = composeTestRule,
        captureRoot = composeTestRule.onRoot(),
        options = RoborazziRule.Options(
            roborazziOptions = RoborazziOptions(
                recordOptions = RoborazziOptions.RecordOptions(resizeScale = 0.7),
                compareOptions = RoborazziOptions.CompareOptions(
                    imageComparator = SimpleImageComparator(
                        maxDistance = MAX_DISTANCE,
                        vShift = 1,
                        hShift = 1,
                    ),
                ),
            ),
        ),
    )
}

@OptIn(ExperimentalTestApi::class, ExperimentalRoborazziApi::class)
fun ScreenshotTest.runScreenshotTest(nameSuffix: String? = null, content: @Composable () -> Unit) {
    val environment = AndroidComposeUiTestEnvironment { composeTestRule.activity }
    try {
        environment.runTest {
            setContent(content)
            val output = when {
                nameSuffix.isNullOrEmpty() -> "${roboOutputName()}.png"
                else -> "${roboOutputName()}_$nameSuffix.png"
            }
            onRoot().captureRoboImage("src/test/screenshots/$output")
        }
    } finally {
        composeTestRule.activityRule.scenario.close()
    }
}

Все параметры настроены так же, как и для тестирования по превью

Заключение

Внедрение Roborazzi в UiKit позволило повысить стабильность UI и автоматизировать контроль визуальных изменений. Скриншот-тесты помогают сразу заметить мелкие отклонения в компонентах, которые сложно уловить глазом, особенно если речь идет об оттенках одного цвета или паддингах в несколько dp.

В статье я показал, как настроить генерацию эталонных скриншотов на CI, избежать проблем с различиями платформ, использовать кастомный ComposePreviewTester и игнорировать отдельные превью-функции для упрощения тестирования.

В результате внедрения скриншот-тестов UiKit получает прозрачный, воспроизводимый и безопасный процесс проверки UI. Даже при добавлении новых компонентов или обновлении существующих можно быть уверенным, что визуальное качество остаётся под контролем.

Комментарии (0)