Хабр, привет! Меня зовут Никита Евдокимов, я работаю старшим разработчиком в «Лаборатории Касперского», а также являюсь мейнтейнером репозитория Kaspresso. Это наш фреймворк для тестирования пользовательских интерфейсов на Android, основанный на Kakao, Espresso и UI Automator.
Недавно в нем появилась новая функция: сравнение скриншотов. С ней можно записывать скриншоты приложения, а на последующих прогонах автотестов сравнивать их с новыми скриншотами и отслеживать изменения в интерфейсе. В статье я пошагово покажу, как с ней работать, со скриншотами и примерами кода.
Материал подойдет для как опытных, так и начинающих специалистов в области автотестирования, а также для дизайнеров: функция облегчает автоматическое тестирование, с ней быстрее и проще проверять, соответствует ли разработанное приложение макету.
Основы функции и написание первого теста
При помощи новой функции сравнивать скриншоты достаточно просто, но для начала нужно подключить Kaspresso к проекту. Для интеграции достаточно добавить хранилище mavenCentral в ваш корневой файл
allprojects { repositories { mavenCentral() } }
и добавить в build.gradle необходимые зависимости:
dependencies { androidTestImplementation 'com.kaspersky.android-components:kaspresso:<latest_version>' // Allure support androidTestImplementation "com.kaspersky.android-components:kaspresso-allure-support:<latest_version>" // Jetpack Compose support androidTestImplementation "com.kaspersky.android-components:kaspresso-compose-support:<latest_version>" }
Подробную инструкцию по подключению также можно найти у нас на GitHub.
Затем следует написать автоматический тест, который приводит ваше приложение в нужное состояние и фиксирует скриншот.

Потом вы можете запустить тот же тест, но уже в режиме сравнения скриншотов. Функция сформирует новый снимок и сравнит с предыдущим.

Если скриншоты не отличаются, тест пройден. Если отличаются, все различия будут подсвечены, а тест упадет.

Далее по статье я буду писать автоматический тест для приложения-семпла из GitHub-репозитория в Kaspresso. Когда мы запускаем его, на экране отображается ряд кнопок:

Каждая из них открывает новый экран. Я напишу автоматический тест, который открывает экран Simple Fragment (скриншоты которого вы видели в начале статьи) и фиксирует его состояние.
Итак, начнем. Добавляем тестовый класс: он должен наследовать тип VisualTestCase. Этот тип — своего рода обертка над обычным тестом, в которой настроены автоматическое копирование нужных файлов на устройство и удобный доступ к API для сравнения скриншотов.
Для работы с самими скриншотами необходимы права на хранилище. Их я выдаю при помощи класса GrantPermissionRule:
class MyVisualTest : VisualTestCase() { @get:Rule val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.READ_MEDIA_IMAGES, ) }
Набор разрешений для разных версий эмуляторов Android может различаться. Так, для версии API 33 и выше понадобится выдача разрешения Manifest.permission.READ_MEDIA_IMAGES . При этом попытка выдачи такого разрешения на более старых версиях API выдаст ошибку. Это ограничение можно обойти следующей конструкцией:
@get:Rule val runtimePermissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { GrantPermissionRule.grant( Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.READ_MEDIA_IMAGES, ) } else { GrantPermissionRule.grant( Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, ) }
Это не единственный способ выдачи разрешений в контексте автотестов, существуют и другие методы. Если интересно, коллеги писали о них подробнее здесь же, на Хабре.
Для запуска приложения воспользуюсь ActivityScenarioRule. В данном случае запускается MainActivity, главная Activity приложения:
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
Объявляю тело тестового метода. Для корректной работы со сравнением скриншотов необходимо воспользоваться конструкцией runScreenshotTest:
@Test fun test() = runScreenshotTest { … }
В первом шаге теста открою нужный экран:
step("Open Simple Screen") { MainScreen { simpleButton { click() } } }
Конструкция step делает код более читаемым и облегчает поддержку теста. Если тест упадет, Kaspresso напишет в логах, на каком шаге что-то пошло не так, и сгенерирует дополнительные артефакты. Подробнее про step можно почитать здесь.
MainScreen — объект, реализующий концепцию PageObject. Он представляет собой описание содержимого экрана. Здесь я объявляю элементы, с которыми хочу взаимодействовать в рамках теста:
object MainScreen : KScreen<MainScreen>() { override val layoutId: Int = R.layout.activity_main override val viewClass: Class<*> = MainActivity::class.java val autoScrollScrollViewWithPaddingButton = KButton { withId(R.id.activity_main_auto_scroll_scrollView_with_padding_button) } val simpleButton = KButton { withId(R.id.activity_main_simple_sample_button) } val webViewButton = KButton { withId(R.id.activity_main_webview_sample_button) } val descriptionText = KTextView { withId(R.id.activity_main_title) } }
С PageObject также можно познакомиться в деталях в еще одной статье.
Съемка скриншотов
Теперь добавлю запись скриншота. Делаю я это с помощью вызова метода assertScreenshot:
step("assert UI") { assertScreenshot("screenshot_name") }
Kaspresso сохраняет снимок в памяти устройства, в папке Documents, по пути, соответствующему названию тестового класса и метода.

Чтобы получить скриншот из памяти устройства, я использую одну из функций фреймворка — автоматическое скачивание артефактов. Для этого необходимо запустить отладочный мост Android (ADB-сервер). Эта вспомогательная утилита позволяет отправлять команды с устройства с выполняемым тестом на хост, где запущен ADB-сервер. Самый свежий дистрибутив ADB-сервера — на GitHub, на странице Kaspresso, в папке Artefacts.
Чтобы запустить сервер, нужно открыть терминал и выполнить следующую команду:

А чтобы включить автоматическое скачивание артефактов, необходимо определить аргумент, передаваемый в конструктор класса теста: KaspressoBuilder. Он позволяет нам кастомизировать поведение Kaspresso.
class MyVisualTest : VisualTestCase( kaspressoBuilder = Kaspresso.Builder.simple {} )
По умолчанию скачивание артефактов отключено. Чтобы его включить, необходимо переопределить параметры. Можно указать путь к рабочей директории, в которой выполняется ADB-сервер, — именно по нему будут скачаны артефакты, а также Regex, с которым они будут сравниваться.
kaspressoBuilder = Kaspresso.Builder.simple { artifactsPullParams = ArtifactsPullParams( artifactsRegex = ".*screenshot.*".toRegex() ) }
В данном случае артефакты будут качаться в ту же директорию, в которой запущен ADB-сервер.

Сравнение скриншотов
Теперь, когда у меня есть эталонный скриншот, я могу запустить тест в режиме сравнения. Режим, в котором запускаются тесты сравнения скриншотов, можно переключить двумя способами.
Первый: указать функцию в конструкторе класса. По умолчанию включен режим записи.
kaspressoBuilder = Kaspresso.Builder.simple { visualTestParams = VisualTestParams(testType = VisualTestType.Compare) }
Второй: указать поле в gradle.properties.
kaspresso.visualTestType="Compare"
Если сейчас запустить тест, то ранее записанный скриншот будет передан на устройство. Как только код дойдет до строчки assertScreenshot, будет записан новый скриншот и произойдет его сравнение с предыдущим. В текущем виде тест завершится успешно, оба скриншота полностью соответствуют друг другу.
Но давайте я изменю код таким образом, чтобы тест упал.
@Test fun test() = runScreenshotTest { step("Open Simple Screen") { MainScreen { simpleButton { click() } } SimpleScreen { button1 { click() } } } }
Если перейти на экран и нажать кнопку Button1, появится кнопка Button2.

Этого будет достаточно. Запускаю тест еще раз — он падает.

Сообщение об ошибке говорит, что скриншоты не соответствуют друг другу.
Поскольку я настроил автоматическое скачивание артефактов, то могу посмотреть следующие данные:

Все артефакты для сравнения лежат в папке Artifacts:

Как работает это сравнение
Представляем каждый файл как массив, значениями элементов которого являются цветовые значения пикселей в формате RGB. Для каждого элемента проверяем значение в каждом цветовом канале. Если отличие выше порогового, то увеличиваем сумму отличающихся пикселей. Затем просто считаем отношение отличающихся пикселей к общему.
Здесь важно подчеркнуть важный момент. Из-за особенностей рендеринга эмуляторов на разных системах скриншоты могут слегка отличаться для одних и тех же входных данных. Поэтому необходимо допускать небольшую погрешность при подсчетах. В Kaspresso для этого служат 2 параметра, задаваемых в VisualTestParams: tolerance и colorTolerance. Первый задает допустимое количество отличающихся пикселей в процентах, а второй — допустимое отличие значений в каждом RGB-канале для каждого пикселя.
Ниже упрощенная реализация сравнения:
fun compare(): Boolean { val screenshotPixels = IntArray(pixelsCount) val originalPixels = IntArray(pixelsCount) screenshot.getPixels(screenshotPixels, 0, width, 0, 0, width, height) original.getPixels(originalPixels, 0, width, 0, 0, width, height) var totalDelta = 0 for (pixelIndex in 0 until pixelsCount) { val areColorsCorrect = checkColors(screenshotPixels[pixelIndex], originalPixels[pixelIndex]) if (!areColorsCorrect) { totalDelta++ } } val diffValue = totalDelta * 100.0f / (width * height) if (diffValue > visualTestParams.tolerance) { return false } return true } private fun checkColors(rgb1: Int, rgb2: Int): Boolean { val colorTolerance = visualTestParams.colorTolerance val r1 = Color.red(rgb1) val g1 = Color.green(rgb1) val b1 = Color.blue(rgb1) val r2 = Color.red(rgb2) val g2 = Color.green(rgb2) val b2 = Color.blue(rgb2) return abs(r1 - r2) <= colorTolerance && abs(g1 - g2) <= colorTolerance && abs(b1 - b2) <= colorTolerance }
Интеграция с Allure
В нашей реализации сравнения скриншотов есть интеграция с Allure:

Здесь к упавшему шагу прикреплены те же артефакты, что я показывал выше: отличия, оригинальный скриншот и новый.
Если вы хотите использовать сравнение скриншотов совместно с Allure, вам необходимо знать следующее: вместо VisualTestCase ваш тест должен наследоваться от AllureVisualTestCase:
class AllureVisualTest : AllureVisualTestCase()
В данном случае параметры сравнения скриншотов передаются в тело withForcedAllureSupport:
class AllureVisualTest : AllureVisualTestCase( kaspressoBuilder = Kaspresso.Builder.withForcedAllureSupport( visualTestParams = VisualTestParams(testType = VisualTestType.Compare) ) )
Если вы хотите, чтобы тест падал при первом неудачном сравнении, можно задать значение true флагу fail early.
class AllureVisualTest : AllureVisualTestCase( failEarly = true )
В этом случае тест будет пройден до конца, а неудачные шаги вы сможете посмотреть в отчете.
Резюме
Надеюсь вам, как и мне, эта новая функция упростит design review и процесс автотестов в целом. Если вы тоже интересуетесь этой темой — присоединяйтесь к нашему сообществу Kaspresso: здесь мы помогаем пользователям фреймворка, сообщаем о новых фичах, и в целом — делимся полезным опытом и экспертизой :)
