В этой статье я покажу, как мы пошли другим путём — научили 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 с сохранённым эталоном через уже знакомый намassertImageSimilar.
Как сохранять эталоны?
Для сохранения эталонов можно использовать тот же обход нужных View, что и в тестах, только вместо обработчика с assert подставить обработчик, который сохраняет изображения в файл.
Так, один раз вручную верифицировав корректность отображения, мы сохраняем эталонные изображения — и дальше уже автоматический тест сравнивает с ними, убеждаясь, что визуально ничего не изменилось.
Итог
Фреймворки вроде Espresso и Kaspresso — гибкие и отлично расширяются. Не бойтесь писать свои матчеры: немного усилий — и у вас уже не костыль, а удобный инструмент, идеально подходящий под ваш проект.
Мы научили машину видеть разницу между картинками: добавили сравнение изображений с допуском, проверки Lottie-кадров и возможность сохранять эталоны. И всё это встроили в привычные UI-тесты: один раз глазами проверили — дальше пусть страдает автотест.
Комментарии