Хабр, привет! Меня зовут Никита Евдокимов, я работаю старшим разработчиком в «Лаборатории Касперского», а также являюсь мейнтейнером репозитория 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: здесь мы помогаем пользователям фреймворка, сообщаем о новых фичах, и в целом — делимся полезным опытом и экспертизой :)