В этой статье я покажу, как мы пошли другим путём — научили Kaspresso понимать, что именно нарисовано на экране. Расскажу, как мы сравниваем изображения и кадры анимации внутри обычных инструментов UI-тестирования — без тяжёлой артиллерии и лишней хрупкости.
Почему не скриншот-тестирование?
Когда речь заходит о визуальных тестах, первое, что приходит в голову — это фреймворки вроде Shot, Paparazzi, Screengrab или Facebook Screenshot Tests. Они кажутся идеальным решением: делают снимки экранов и сравнивают их с эталоном. Простая идея — максимальный охват.
Но на практике у такого подхода есть свои минусы:
- Хрупкость. Даже минимальные правки в UI (шрифт, цвет, тень, отступ) вызывают падения тестов. Эти изменения не всегда критичны — но требуют обновления эталонных снимков.
- Сложность CI-интеграции. Для корректной работы нужен стабильный рендеринг, часто эмулируемый окружением. Например, Paparazzi работает в JVM, но требует настройки рендеринга. А Shot — запускается на реальном устройстве, что усложняет поддержку в CI.
- Проблемы масштабируемости. Один экран в разных состояниях — это уже десятки скриншотов. А если вы добавляете тему, язык, ориентацию — их становится сотни.
Кроме того, состояние приложения, необходимое для теста, может быть сложно воспроизводимым: зарегистрированный пользователь с нужной верификацией, средствами на счёте, доступом к определённым инструментам или регионам.
Если это уже реализовано в обычных UI-тестах, дублировать ту же бизнес-логику и в скриншот-тестах – может быть дорого и нецелесообразно.
Поэтому в нашем случае мы пошли по пути легковесной проверки ключевых визуальных элементов — изображений и анимаций — прямо в UI-тестах, уже встроенных в пайплайн.
Что мы проверяем?
Вот базовый кейс: в приложении должно отображаться изображение. Как убедиться, что оно:
- Подгрузилось.
- Отображается полностью.
- Не искажено (не растянуто, не обрезано)?
Kakao и hasDrawable
В Kakao есть удобный матчер — KImageView.hasDrawable()
. Вы просто передаёте ожидаемый drawable, и он сравнивается с текущим. При необходимости можно использовать toBitmap() — тогда учитываются, например, соотношение сторон или плотность пикселей.
Но у такого подхода есть два важных ограничения:
- Drawable не равен отрисованному изображению Метод сравнивает
ImageView.getDrawable()
— то есть объект, который назначен для отображения. Но это не гарантирует, что он действительно отрисовался на экране корректно. Если изображение обрезано, не загрузилось или перекрыто чем-то другим — тест всё равно пройдёт. - Сравнение пиксель-в-пиксель — слишком строгое Для сравнения используется
Bitmap.sameAs()
. Этот метод требует абсолютной идентичности изображений — вплоть до каждого пикселя. Даже минимальные искажения, вызванные сжатием, масштабированием или несовпадением плотности — приведут к провалу теста, даже если различия незаметны глазу.
Как именно сравниваются изображения?
Можно было бы взять что-то готовое из мира компьютерного зрения и на этом этапе. Например:
Core.absdiff
из OpenCV — считает абсолютную разницу между двумя изображениями пиксель за пикселем.- Метрики типа SSIM (Structural Similarity Index) — оценивают визуальное сходство по структуре, контрасту и яркости.
- Perceptual hash (pHash) — генерирует хэш, отражающий визуальное содержание, и сравнивает их.
Но на практике у таких решений есть свои «но»:
- OpenCV — мощный, но тяжёлый и может конфликтовать с другими библиотеками, особенно в Android-проектах.
- SSIM/pHash — слишком умны для простой задачи: они могут “простить” искажения, которые пользователь бы заметил.
- Подключение нативных библиотек увеличивает время сборки и может усложнить CI.
Поэтому мы выбрали более простой и предсказуемый путь — написать проверку вручную.
Мы используем несколько шагов
- Градации серого:
Приводим оба изображения к одному цветовому пространству, чтобы убрать влияние цвета, уменьшить шум и сравнивать именно форму и содержание изображения, а не его цветовые нюансы.
private fun toGrayscale(bmpOriginal: Bitmap): Bitmap {
val height = bmpOriginal.height
val width = bmpOriginal.width
val bmpGrayscale = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
val paint = Paint()
val cm = ColorMatrix().apply { setSaturation(0f) }
paint.colorFilter = ColorMatrixColorFilter(cm)
Canvas(bmpGrayscale).drawBitmap(bmpOriginal, 0f, 0f, paint)
return bmpGrayscale
}
- Приведение к миниатюре:
Уменьшаем изображение, чтобы сгладить мелкие отличия и ускорить сравнение, сохранив при этом основную структуру.
bmp1 = ThumbnailUtils.extractThumbnail(bmp1, SAMPLE_SIZE, SAMPLE_SIZE)
- Расчёт расстояния Хэмминга:
Расстояние Хэмминга показывает, насколько отличаются два изображения по пикселям: оно считает количество несовпадений. Это простой способ оценить визуальное сходство после предварительной обработки.
private fun calcHammingDistance(a: IntArray, b: IntArray): Int {
var sum = 0
for (i in a.indices) {
sum += if (a[i] == b[i]) 0 else 1
}
return sum
}
- Проверка с порогом:
Сравниваем полученное расстояние с допустимым порогом: если различий меньше, чем порог, считаем изображения похожими. Это позволяет игнорировать незначительные отличия, вроде шума или сглаживания.
fun assertImageSimilar(bitmap1: Bitmap, bitmap2: Bitmap, threshold: Double) {
val similarity = calcSimilarity(bitmap1, bitmap2)
assert(similarity > threshold) {
"Images too different! Expected ≥ $threshold, actual: $similarity"
}
}
Порог подбирается эмпирически: для иконок — строго (например, 0.96), для реальных фото или отрендеренных анимаций — мягче.
А если хотим проверить не drawable
, а весь View
?
Можно рендерить View
в Bitmap
:
val bitmap = view.drawToBitmap()
И сравнивать его с заранее сохранённым эталоном. Это особенно полезно для кастомных компонентов и сложных анимаций.
Проверка анимаций (Lottie)
С Lottie всё сложнее. JSON сравнивать бесполезно — нас интересует не структура, а то, как оно выглядит. При обновлении Lottie-библиотеки могут появиться визуальные артефакты. Сама же вьюшка может обрезаться или неправильно масштабироваться. Именно поэтому визуальная проверка становится критически важной.
Так как анимация — это серия кадров. Мы можем их проверить путем проверки ее отдельных составляющих.
Что делаем:
- Останавливаем анимацию на нужном кадре:
fun BaseActions.setAnimationFrame(frame: Int) {
view.perform(object : ViewAction {
override fun perform(uic: UiController, view: View) {
val lottieView = view as LottieAnimationView
lottieView.pauseAnimation()
lottieView.frame = frame
}
})
}
- Сравниваем
LottieAnimationView
как обычный View с сохранённым эталоном через уже знакомый намassertImageSimila
r.
Как сохранять эталоны?
Для сохранения эталонов можно использовать тот же обход нужных View, что и в тестах, только вместо обработчика с assert подставить обработчик, который сохраняет изображения в файл.
Так, один раз вручную верифицировав корректность отображения, мы сохраняем эталонные изображения — и дальше уже автоматический тест сравнивает с ними, убеждаясь, что визуально ничего не изменилось.
Итог
Фреймворки вроде Espresso и Kaspresso — гибкие и отлично расширяются. Не бойтесь писать свои матчеры: немного усилий — и у вас уже не костыль, а удобный инструмент, идеально подходящий под ваш проект.
Мы научили машину видеть разницу между картинками: добавили сравнение изображений с допуском, проверки Lottie-кадров и возможность сохранять эталоны. И всё это встроили в привычные UI-тесты: один раз глазами проверили — дальше пусть страдает автотест.
Комментарии