Всем привет! Меня зовут Алина, я – старший разработчик клиентских мобильных приложений в компании «Совкомбанк Технологии». Сегодня поговорим о screenshot-тестах для Jetpack Compose и о том, как их можно автоматизировать через Preview, которые вы уже используете каждый день.

Будет полезно Android-разработчикам, тимлидам и техлидам, а также всем, кто интересуется тестированием и хочет повысить качество UI. Если вы сталкивались с ситуацией, когда визуальные баги проходили code review и добирались до прода - эта статья для вас.

Дальше обсудим:

  1. Почему визуальные баги проходят мимо тестов

  2. Screenshot-тесты: что это и как работают

  3. Автоматизация через Preview: пошаговая реализация

  4. Интеграция в CI/CD и подводные камни

  5. Результаты внедрения

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 – различия, отмеченные красным цветом

Мы видим визуальное изменение. У нас есть два варианта развития событий:

  1. Если изменение верное – происходит обновление эталона командой record

  2. Если изменение неверное – вносятся изменения в код

Практические метрики

В рассматриваемом проекте около 84 Preview, примерное время их сборки составляет 35 секунд. Это значительно быстрее, чем запуск UI-тестов на эмуляторе.

4. Интеграция в GitLab CI

Теперь расскажу вам о том, как можно интегрировать рассмотренный подход в CI/CD.

Цель: автоматизировать визуальную регрессию UI при code review. Разработчики и ревьюеры должны видеть какие изменения были внесены в интерфейс в рамках MR.

Этапы

  1. Запись эталонных скриншотов при пуше в release ветку

  2. Сравнение скриншотов в MR: при создании или обновлении MR скачиваются эталонные скриншоты, они сравниваются с текущими, сохраняются файлы различий

  3. Публикация результата в виде комментария к 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?

  1. При добавлении нового кода генерируются скриншоты

  2. Они сравниваются с эталонными

  3. При различиях артефакты становятся доступны для ревьюера в MR

  4. Ревьюер видит изменения визуально

Обновление эталона

Если изменение корректное, мерджим 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

Самописный код

Плюсы

Минимум кода, быстрое внедрение

Полный контроль, гибкая кастомизация

Минусы

Меньше контроля, сложнее кастомизировать

Больше кода

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

Ограничения

Конечно, есть и ограничения:

  1. Мы проверяем только статические состояния – анимация и интерактивность не тестируются.

  2. Preview должны быть полными и валидными, иначе тесты будут бесполезны.

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

Где можно применять подход

  • В дизайн-системе – для всех компонентов UI kit

  • Для экранов в разных состояниях – Loading, Success, Error, Empty

  • Для кастомных View-элементов со сложной версткой, которую важно не сломать

5. Результаты внедрения

Что мы имели до внедрения screenshot-тестов:

  • Нет автоматической проверки визуала

  • Баги доходят до продакшена

Что имеем после внедрения screenshot-тестов:

  • Все Preview покрыты автоматически

  • Требуется минимальная поддержка

  • Баги обнаруживаются на code review

  • Визуальные регрессии перестают доходить до прода

Дополнительные бонусы:

  • Уверенность в рефакторинге – можно смело менять UI код, зная, что любое визуальное изменение будет замечено

  • Экономия времени команды – не нужно вручную писать тесты и проверять визуал, а ревьюеры сразу видят изменения

Заключение

Подводя итоги, сформулируем ключевые выводы:

  1. Screenshot-тесты – это не лишняя работа, а инструмент защиты качества.

  2. Сочетание UI-тестов и Screenshot-тестов дают нам комплексный подход, позволяющий проверить как поведение, так и визуал.

  3. Автоматизация через Preview – это дешево, надежно и быстро. Все необходимое уже есть в вашем коде

Внедряйте screenshot-тесты и защищайте качество продукта.

А как у вас обстоят дела с визуальными багами? Ловите их на code review или они все же добираются до прода? Используете ли screenshot-тесты в своих проектах? Делитесь опытом в комментариях - интересно узнать, какие подходы работают у вас.

Полезные ссылки

  1. Roborazzi – библиотека для screenshot-тестирования

  2. ComposablePreviewScanner – сканер Preview функций

  3. TestParameterInjector – параметризация тестов от Google