Всем привет! Меня зовут Алина, я – старший разработчик клиентских мобильных приложений в компании «Совкомбанк Технологии». Сегодня поговорим о screenshot-тестах для Jetpack Compose и о том, как их можно автоматизировать через Preview, которые вы уже используете каждый день.
Будет полезно Android-разработчикам, тимлидам и техлидам, а также всем, кто интересуется тестированием и хочет повысить качество UI. Если вы сталкивались с ситуацией, когда визуальные баги проходили code review и добирались до прода - эта статья для вас.
Дальше обсудим:
1. Проблема: визуальные баги проходят мимо тестов
Давайте представим типичную ситуацию для разработчика: вы делаете фичу, код проходит ревью, тесты «зеленые». На первый взгляд все хорошо, вы отправляете её в релиз.
А потом на проде обнаруживается визуальный баг: текст наехал на текст, цвет оказался не тот, отступ съехал. Бывало такое?
В чем проблема – ведь тесты были пройдены успешно? Дело в том, что юнит-тесты проверяют только логику. Им все равно на то, как выглядит экран.
Визуальные баги – это норма жизни: сломанная верстка, не те цвета, обрезанный текст, наложенные друг на друга элементы. И все это спокойно проходит через обычные тесты.
UI-тесты и их ограничения
У нас, конечно, есть UI-тесты. Типичный пример: проверяем, что при клике на кнопку появляется нужный текст:
@Test fun clickButtonShowsText() { composeTestRule.setContent { ClickableTextExample() } composeTestRule .onNodeWithTag("message_text") .assertDoesNotExist() composeTestRule .onNodeWithTag("show_button") .performClick() composeTestRule .onNodeWithTag("message_text") .assertIsDisplayed() .assertTextEquals("Привет! Текст появился") }
UI-тесты отлично проверяют поведение: клики работают, навигация корректна, данные отображаются. Но они не проверяют:
Корректность цвета
Обрезание текста – текст не помещается в отведенной ему области
Наложение элементов друг на друга
Размер отступов
В UI-тестах сложно описать визуальные проверки. Мы можем узнать, что текст есть, но проверить его цвет, размер, позицию или всю верстку целиком довольно проблематично.
Кроме того, UI-тесты медленные в проверке визуала. Они запускают эмулятор или устройство, долго выполняются, а описывать в них визуальные проверки – очень трудоемко.
Поэтому логично разделить ответственность:
UI-тесты -> проверяют поведение
Screenshot-тесты -> проверяют отображение.
2. Screenshot-тесты: что это и зачем нужно
Screenshot-тесты сохраняют визуальное состояние UI в качестве эталона. И у них есть серьезные преимущества перед UI-тестами для визуальных проверок:
Быстрые – работают без эмулятора
Полные – проверяют всю картинку целиком, а не её отдельные элементы
Понятные – позволяют видеть разницу между картинками, а не читать логи.
Вот пример простого screenshot-теста:
@RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(sdk = [33]) class ClickableTextExampleScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun screenshot() { composeTestRule.setContent { ClickableTextExampleTextVisiblePreview() } composeTestRule.onRoot().captureRoboImage() } }
Рендерим компонент и делаем его снимок – все просто.

Проблема классического подхода
Screenshot-тесты нужно писать вручную в большом количестве.
В классическом подходе мы прописываем каждый тест:
@Test fun testButtonDefault() { composeTestRule.setContent { Button() } captureScreenshot("button_default") }
Но что если у вас 50 экранов по 5 состояний на каждый? Получается 250 тестов, которые нужно написать вручную.
В итоге такие тесты пишутся «для галочки», быстро устаревают, и обычно никто не хочет заниматься их поддержкой.
3. Решение: автоматическая генерация из Preview
А что если поступить иначе? У нас уже есть готовые UI состояния – наши Preview.
Preview – это источник правды. В них все состояния UI, актуальные данные, мы используем их каждый день.
Идея проста – автоматически генерировать screenshot-тесты из Preview. Это понятное решение, преимущества которого очевидны:
Ноль ручной работы
Тесты всегда актуальны
Легко раскатать на весь проект
Как это работает
Схема простая:
Preview функции -> Scanner находит все -> Генерация тестов -> Скриншоты
Технологический стек
Jetpack Compose + Preview – UI фреймворк с Preview
ComposablePreviewScanner – сканирование Preview
Roborazzi – создание скриншотов
TestParameterInjector – параметризация тестов
Robolectric – Android окружение для тестов
GitLab CI – автоматизация в pipeline
Подключение зависимостей
Уровень проекта (build.gradle.kts)
alias(libs.plugins.roborazzi.plugin) apply false
Уровень модуля (build.gradle.kts)
alias(libs.plugins.roborazzi.plugin) android { testOptions.unitTests.isIncludeAndroidResources = true } dependencies { testImplementation(libs.robolectric) testImplementation(libs.roborazzi.compose) testImplementation(libs.roborazzi.compose.preview) testImplementation(libs.composable.preview.scanner) testImplementation(libs.test.parameter.injector) }
Version Catalog (libs.versions.toml)
[versions] robolectricVersion = "4.16" roborazziVersion = "1.50.0" previewScannerVersion = "0.7.1" testParameterInjectorVersion = "1.19" [libraries] robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectricVersion" } roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi- compose", version.ref = "roborazziVersion" } roborazzi-compose-preview = { module = "io.github.takahirom.roborazzi:roborazzi-compose-preview-scanner-support", version.ref = "roborazziVersion" } composable-preview-scanner = { module = "io.github.sergio- sastre.ComposablePreviewScanner:android", version.ref = "previewScannerVersion" } test-parameter-injector = { module = "com.google.testparameterinjector:test- parameter-injector", version.ref = "testParameterInjectorVersion" } [plugins] roborazzi-plugin = { id = "io.github.takahirom.roborazzi", version.ref = "roborazziVersion" }
Код и реализация
Теперь рассмотрим самое интересное – код.
Шаг 1: Пишем Preview
Сперва мы пишем стандартный Preview:
Compose Preview: @Preview(showBackground = true, name = "Loading") @Composable private fun SimpleButtonLoadingPreview() { SimpleButton(text = "Нажми меня", state = ButtonState.LOADING) } @Preview(showBackground = true, name = "Disabled") @Composable private fun SimpleButtonDisabledPreview() { SimpleButton(text = "Нажми меня", state = ButtonState.DISABLED) } @Preview(showBackground = true, name = "Error") @Composable private fun SimpleButtonErrorPreview() { SimpleButton(text = "Нажми меня", state = ButtonState.ERROR) }
Чем больше состояний, тем больше Preview. Для каждого состояния: Loading, Disabled, Error – свой Preview.

Шаг 2: Кастомная аннотация для настройки threshold
У нас есть кастомная аннотация RoborazziConfig, в которой можно задать порог сравнения для конкретного Preview. Это удобно, когда у разных компонентов разные требования к точности:
annotation class RoborazziConfig(val comparisonThreshold: Double)
Пример использования: помечаем Preview этой аннотацией и указываем threshold 0.01, то есть разрешаем 1% различий.
@RoborazziConfig(0.01) @Preview(showBackground = true, name = "Error") @Composable private fun SimpleButtonErrorPreview() { SimpleButton(text = "Нажми меня", state = ButtonState.ERROR) }
Шаг 3: Helper-функция для пути к файлу
Далее идет простая обертка для генерации пути к файлу скриншота:
fun filePath(name: String): String { val file = File(DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH, "$name.png") return file.path }
Шаг 4: Маппер опций Compose
Маппер опций Compose – инструмент для автоматического переноса настроек. Он берет все параметры из аннотации Preview: устройство, размер экрана, фон, локаль, UI режим, масштаб шрифта и переносит их в настройки Roborazzi автоматически:
object RoborazziComposeOptionsMapper { @OptIn(ExperimentalRoborazziApi::class) fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): RoborazziComposeOptions = RoborazziComposeOptions { val previewInfo = preview.previewInfo previewDevice(previewInfo.device.ifBlank { Devices.NEXUS_5 }) size( widthDp = previewInfo.widthDp, heightDp = previewInfo.heightDp ) background( showBackground = previewInfo.showBackground, backgroundColor = previewInfo.backgroundColor ) locale(previewInfo.locale) uiMode(previewInfo.uiMode) fontScale(previewInfo.fontScale) } }
Что получается в результате? Если в Preview выбрана темная тема или конкретное устройство, скриншот будет сделан с этими настройками.
Шаг 5: Маппер опций сравнения
Второй маппер используется для опций сравнения. Он берет нашу аннотацию RoborazziConfig и настраивает порог различий. Если аннотации нет, то используются дефолтные настройки:
object RoborazziOptionsMapper { fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): RoborazziOptions = preview.getAnnotation<RoborazziConfig>()?.let { config -> RoborazziOptions( compareOptions = RoborazziOptions.CompareOptions( resultValidator = ThresholdValidator(config.comparisonThreshold.toFloat()) ) ) } ?: RoborazziOptions() }
Шаг 6: Тестовый Application
На этом шаге нужен тестовый пустой Application класс для Robolectric:
class TestApplication : Application()
Шаг 7: Сканирование всех Preview
Здесь наступает ключевой момент – сканирование всех Preview.
Для этого мы используем AndroidComposablePreviewScanner, указываем наш package, включаем информацию об аннотациях и даже приватные Preview:
private val cachedPreviews: List<ComposablePreview<AndroidPreviewInfo>> by lazy { AndroidComposablePreviewScanner() .scanPackageTrees("com.example.myapp") // Укажите ваш package .includeAnnotationInfoForAllOf(RoborazziConfig::class.java) .includePrivatePreviews() .getPreviews() }
Что происходит? Scanner ищет все функции с аннотацией @Preview в указанном пакете, включает приватные превью и сохраняет все аннотации.
Шаг 8: Provider для параметризации
Provider – это мост между Scanner'ом и тестами. Он предоставляет список всех найденных Preview:
object ComposablePreviewProvider : TestParameterValuesProvider() { override fun provideValues(context: Context?): List<ComposablePreview<AndroidPreviewInfo>> = cachedPreviews }
Шаг 9: Главный тестовый класс
Главный тестовый класс помечен аннотацией @RunWith с RobolectricTestParameterInjector – это позволяет параметризовать тесты:
@RunWith(RobolectricTestParameterInjector::class) class RoborazziComposePreviewTests { @OptIn(ExperimentalRoborazziApi::class) private fun snapshot(preview: ComposablePreview<AndroidPreviewInfo>) { captureRoboImage( filePath = filePath(AndroidPreviewScreenshotIdBuilder(preview).build()), roborazziOptions = RoborazziOptionsMapper.createFor(preview), roborazziComposeOptions = RoborazziComposeOptionsMapper.createFor(preview), ) { preview() } } @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config( sdk = [30], application = TestApplication::class, instrumentedPackages = [ // Добавьте свои пакеты при необходимости ] ) @Test fun snapshotTest( @TestParameter(valuesProvider = ComposablePreviewProvider::class) preview: ComposablePreview<AndroidPreviewInfo>, ) { snapshot(preview) } }
Метод snapshot делает всю «магию»: берет Preview, генерирует путь к файлу, применяет настройки из мапперов и создает скриншот.
Обратите внимание, это один тест, который запускается для каждого Preview. TestParameter получает список от Provider'а, и для каждого Preview вызывается метод snapshot.
Важны эти настройки:
GraphicsMode.NATIVE – для корректного рендеринга
SDK 30 – стабильная версия для тестов
TestApplication –тестовый Application
Все просто: добавили Preview, и тест появился автоматически. Никакой ручной работы.
Демонстрация на примере
Давайте рассмотрим конкретный пример. Вот простой компонент ProductCard – карточка товара с картинкой, названием и ценой:
@Composable fun ProductCard( imageUrl: String, title: String, price: String, modifier: Modifier = Modifier ) { Card(modifier = modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(200.dp) .background(Color.LightGray) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = price, style = MaterialTheme.typography.titleLarge ) } } }
Создаем Preview
Создаем Preview для обычного состояния:
@Preview(showBackground = true) @Composable private fun ProductCardPreview() { ProductCard( imageUrl = "", title = "Смартфон Samsung", price = "79 990 руб." ) }
Добавляем Preview для состояния с длинным текстом. Важно проверить, что верстка не ломается:
@Preview(showBackground = true) @Composable private fun ProductCardLongTextPreview() { ProductCard( imageUrl = "", title = "Смартфон Samsung Galaxy S24 Ultra с большим экраном и мощной батареей", price = "129 990 руб." ) }

Генерация эталонных скриншотов
Запускаем генерацию:
./gradlew :app:recordRoborazziDebug --tests "*RoborazziComposePreviewTests"
Результат – эталонные скриншоты для каждого состояния. Они сохраняются в build/outputs/roborazzi/ и становятся базой для сравнения.
Сценарий: разработчик меняет код
Теперь рассмотрим сценарий, в котором разработчик меняет код. Например, уменьшает padding с 16dp до 8dp и удаляет maxLines:
@Composable fun ProductCard(/* ... */) { Card(modifier = modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(8.dp)) { // Было 16.dp // ... Text( text = title, style = MaterialTheme.typography.titleMedium, // maxLines = 1, // Удалено overflow = TextOverflow.Ellipsis ) // ... } } }
Запуск проверки
Запускаем команду для сравнения:
./gradlew :app:verifyRoborazziDebug --tests "*RoborazziComposePreviewTests"
Происходит генерация новых скриншотов и сравнение их с эталонными.
Результат
При обнаружении различий генерируются три изображения:
Expected – эталонный скриншот
Actual – актуальный скриншот
Compare – различия, отмеченные красным цветом

Мы видим визуальное изменение. У нас есть два варианта развития событий:
Если изменение верное – происходит обновление эталона командой record
Если изменение неверное – вносятся изменения в код
Практические метрики
В рассматриваемом проекте около 84 Preview, примерное время их сборки составляет 35 секунд. Это значительно быстрее, чем запуск UI-тестов на эмуляторе.
4. Интеграция в GitLab CI
Теперь расскажу вам о том, как можно интегрировать рассмотренный подход в CI/CD.
Цель: автоматизировать визуальную регрессию UI при code review. Разработчики и ревьюеры должны видеть какие изменения были внесены в интерфейс в рамках MR.
Этапы
Запись эталонных скриншотов при пуше в release ветку
Сравнение скриншотов в MR: при создании или обновлении MR скачиваются эталонные скриншоты, они сравниваются с текущими, сохраняются файлы различий
Публикация результата в виде комментария к MR
Важный момент: изоляция тестов
Проблема в том, что стандартная команда recordRoborazziDebug запускает все unit-тесты, а не только screenshot-тесты.
Решение этой проблемы – это фильтрация по классу:
# Для записи эталонов ./gradlew :app:recordRoborazziDebug --tests "*RoborazziComposePreviewTests" # Для сравнения ./gradlew :app:compareRoborazziDebug --tests "*RoborazziComposePreviewTests"
Пример конфигурации GitLab CI
stages: - test variables: SCREENSHOTS_BRANCH: "screenshots/${CI_COMMIT_REF_NAME}" # Запись эталонов при пуше в release record_screenshots: stage: test script: - ./gradlew :app:recordRoborazziDebug --tests "*RoborazziComposePreviewTests" - # Сохранение скриншотов (в артефакты или отдельную ветку) only: - /^release\/.*/ artifacts: paths: - app/build/outputs/roborazzi/ expire_in: 30 days # Сравнение в MR compare_screenshots: stage: test script: - # Скачивание эталонных скриншотов - ./gradlew :app:compareRoborazziDebug --tests "*RoborazziComposePreviewTests" only: - merge_requests artifacts: when: on_failure paths: - app/build/outputs/roborazzi/ expire_in: 7 days allow_failure: true
Что происходит в Merge Request?
При добавлении нового кода генерируются скриншоты
Они сравниваются с эталонными
При различиях артефакты становятся доступны для ревьюера в MR
Ревьюер видит изменения визуально
Обновление эталона
Если изменение корректное, мерджим MR в релизную ветку: эталонные скриншоты при этом обновляются автоматически, и следующие MR будут сравниваться уже с новым эталоном.
Варианты хранения эталонов
Вариант № 1. Скриншоты хранятся в отдельных ветках Git
Преимущество: можно просматривать diff прямо в MR
Недостаток: нужен скрипт автоматической очистки старых веток
Вариант № 2. GitLab Artifacts – скриншоты хранятся как артефакты pipeline
Преимущество: проще в реализации
Недостаток: нельзя просматривать diff в MR напрямую, их нужно скачивать

Альтернативный подход: встроенная генерация Roborazzi
Стоит отметить, что существует и другой вариант автоматизации. У Roborazzi есть встроенная функция generateComposePreviewRobolectricTests, которая автоматически генерирует тесты.
Конфигурация максимально простая:
// build.gradle.kts roborazzi { @OptIn(ExperimentalRoborazziApi::class) generateComposePreviewRobolectricTests { enable = true packages = listOf("com.example.myapp") includePrivatePreviews = true } }
Добавляем блок в build.gradle.kts, указываем пакеты, плагин сам создает тестовый класс. Эта функция автоматически генерирует тестовые классы, сканирует все Preview, создает тесты. Все работает из коробки.
Сравнение подходов
Подход | Встроенный Roborazzi | Самописный код |
Плюсы | Минимум кода, быстрое внедрение | Полный контроль, гибкая кастомизация |
Минусы | Меньше контроля, сложнее кастомизировать | Больше кода |
Оба подхода имеют право на жизнь. Встроенный плагин подойдет для быстрого старта и простых сценариев. Самописное решение эффективно, когда нужна гибкость и полный контроль над процессом.
Ограничения
Конечно, есть и ограничения:
Мы проверяем только статические состояния – анимация и интерактивность не тестируются.
Preview должны быть полными и валидными, иначе тесты будут бесполезны.
Возможны ложные срабатывания: различия в шрифтах, отличия в рендеринге, особенности платформы. Но фиксированные параметры окружения и использование RoborazziConfig с настройкой threshold помогают обойти это ограничение.
Где можно применять подход
В дизайн-системе – для всех компонентов UI kit
Для экранов в разных состояниях – Loading, Success, Error, Empty
Для кастомных View-элементов со сложной версткой, которую важно не сломать
5. Результаты внедрения
Что мы имели до внедрения screenshot-тестов:
Нет автоматической проверки визуала
Баги доходят до продакшена
Что имеем после внедрения screenshot-тестов:
Все Preview покрыты автоматически
Требуется минимальная поддержка
Баги обнаруживаются на code review
Визуальные регрессии перестают доходить до прода
Дополнительные бонусы:
Уверенность в рефакторинге – можно смело менять UI код, зная, что любое визуальное изменение будет замечено
Экономия времени команды – не нужно вручную писать тесты и проверять визуал, а ревьюеры сразу видят изменения
Заключение
Подводя итоги, сформулируем ключевые выводы:
Screenshot-тесты – это не лишняя работа, а инструмент защиты качества.
Сочетание UI-тестов и Screenshot-тестов дают нам комплексный подход, позволяющий проверить как поведение, так и визуал.
Автоматизация через Preview – это дешево, надежно и быстро. Все необходимое уже есть в вашем коде
Внедряйте screenshot-тесты и защищайте качество продукта.
А как у вас обстоят дела с визуальными багами? Ловите их на code review или они все же добираются до прода? Используете ли screenshot-тесты в своих проектах? Делитесь опытом в комментариях - интересно узнать, какие подходы работают у вас.
Полезные ссылки
Roborazzi – библиотека для screenshot-тестирования
ComposablePreviewScanner – сканер Preview функций
TestParameterInjector – параметризация тестов от Google
