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