Dmitrii Nikitin 10 сентября 2023

📱 Тестирование изображений и анимаций в Android с помощью Kaspresso

Тестирование UI в Android-приложениях — это всегда баланс между тщательностью проверок и удобством поддержки. Проверить, что всё отображается корректно — нетривиальная задача и чаще всего фреймворки на основе Espresso сосредоточиваются логике и поведении: проверяют тексты, клики, переходы, цвета. Но как только речь заходит об изображениях или анимациях — часто эти проверки или совсем оставляют неавтоматизированным, либо оставляют это скриншотным фреймворкам.
1
📱 Тестирование изображений и анимаций в Android с помощью Kaspresso

В этой статье я покажу, как мы пошли другим путём — научили Kaspresso понимать, что именно нарисовано на экране. Расскажу, как мы сравниваем изображения и кадры анимации внутри обычных инструментов UI-тестирования — без тяжёлой артиллерии и лишней хрупкости.

Почему не скриншот-тестирование?

Когда речь заходит о визуальных тестах, первое, что приходит в голову — это фреймворки вроде Shot, Paparazzi, Screengrab или Facebook Screenshot Tests. Они кажутся идеальным решением: делают снимки экранов и сравнивают их с эталоном. Простая идея — максимальный охват.

Но на практике у такого подхода есть свои минусы:

  1. Хрупкость. Даже минимальные правки в UI (шрифт, цвет, тень, отступ) вызывают падения тестов. Эти изменения не всегда критичны — но требуют обновления эталонных снимков.
  2. Сложность CI-интеграции. Для корректной работы нужен стабильный рендеринг, часто эмулируемый окружением. Например, Paparazzi работает в JVM, но требует настройки рендеринга. А Shot — запускается на реальном устройстве, что усложняет поддержку в CI.
  3. Проблемы масштабируемости. Один экран в разных состояниях — это уже десятки скриншотов. А если вы добавляете тему, язык, ориентацию — их становится сотни.

Кроме того, состояние приложения, необходимое для теста, может быть сложно воспроизводимым: зарегистрированный пользователь с нужной верификацией, средствами на счёте, доступом к определённым инструментам или регионам.

Если это уже реализовано в обычных UI-тестах, дублировать ту же бизнес-логику и в скриншот-тестах – может быть дорого и нецелесообразно.

Поэтому в нашем случае мы пошли по пути легковесной проверки ключевых визуальных элементов — изображений и анимаций — прямо в UI-тестах, уже встроенных в пайплайн.

Что мы проверяем?

Вот базовый кейс: в приложении должно отображаться изображение. Как убедиться, что оно:

  1. Подгрузилось.
  2. Отображается полностью.
  3. Не искажено (не растянуто, не обрезано)?

Kakao и hasDrawable

В Kakao есть удобный матчер — KImageView.hasDrawable(). Вы просто передаёте ожидаемый drawable, и он сравнивается с текущим. При необходимости можно использовать toBitmap() — тогда учитываются, например, соотношение сторон или плотность пикселей.

Но у такого подхода есть два важных ограничения:

  1. Drawable не равен отрисованному изображению Метод сравнивает ImageView.getDrawable() — то есть объект, который назначен для отображения. Но это не гарантирует, что он действительно отрисовался на экране корректно. Если изображение обрезано, не загрузилось или перекрыто чем-то другим — тест всё равно пройдёт.
  2. Сравнение пиксель-в-пиксель — слишком строгое Для сравнения используется Bitmap.sameAs(). Этот метод требует абсолютной идентичности изображений — вплоть до каждого пикселя. Даже минимальные искажения, вызванные сжатием, масштабированием или несовпадением плотности — приведут к провалу теста, даже если различия незаметны глазу.
Хорошо разбираетесь в Kotlin? Готовы по полочкам разложить, чем он отличается от Java? Проверьте свои знания в нашем тесте. 22 вопроса, никаких уловок.

Как именно сравниваются изображения?

Можно было бы взять что-то готовое из мира компьютерного зрения и на этом этапе. Например:

  1. Core.absdiff из OpenCV — считает абсолютную разницу между двумя изображениями пиксель за пикселем.
  2. Метрики типа SSIM (Structural Similarity Index) — оценивают визуальное сходство по структуре, контрасту и яркости.
  3. Perceptual hash (pHash) — генерирует хэш, отражающий визуальное содержание, и сравнивает их.

Но на практике у таких решений есть свои «но»:

  1. OpenCV — мощный, но тяжёлый и может конфликтовать с другими библиотеками, особенно в Android-проектах.
  2. SSIM/pHash — слишком умны для простой задачи: они могут “простить” искажения, которые пользователь бы заметил.
  3. Подключение нативных библиотек увеличивает время сборки и может усложнить 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
        }
    })
}

    
  1. Сравниваем LottieAnimationView как обычный View с сохранённым эталоном через уже знакомый нам assertImageSimilar.

Как сохранять эталоны?

Для сохранения эталонов можно использовать тот же обход нужных View, что и в тестах, только вместо обработчика с assert подставить обработчик, который сохраняет изображения в файл.

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

Итог

Фреймворки вроде Espresso и Kaspresso — гибкие и отлично расширяются. Не бойтесь писать свои матчеры: немного усилий — и у вас уже не костыль, а удобный инструмент, идеально подходящий под ваш проект.

Мы научили машину видеть разницу между картинками: добавили сравнение изображений с допуском, проверки Lottie-кадров и возможность сохранять эталоны. И всё это встроили в привычные UI-тесты: один раз глазами проверили — дальше пусть страдает автотест.

📱 Библиотека мобильного разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика»

Комментарии

 
 

ВАКАНСИИ

Добавить вакансию
Backend developer (PHP / Go)
Москва, по итогам собеседования
Go-разработчик
по итогам собеседования
Senior Marketing Analyst
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

Подпишись

на push-уведомления