Всем привет! В этой статье я расскажу, как подключал скриншот-тестирование с помощью 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 выглядит так:
Разработчик обновляет существующий или добавляет новый компонент, создаёт для него
@Preview.Оформляет merge request.
Если
validate:screenshotsзавершился с ошибкой, проводится ревью отчёта Roborazzi: изменения сравниваются с макетами.Если всё корректно, запускается
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. Даже при добавлении новых компонентов или обновлении существующих можно быть уверенным, что визуальное качество остаётся под контролем.
