Цель статьи — предупреждение проблем и ознакомление с неочевидными моментами скриншот тестов в контексте Android. А также глубинное погружение в то, как это может работать и то, как мы это сделали в Альфе. 

Дисклеймер. Статья не является документацией, поэтому некоторые технические моменты были опущены. 

Суть проблемы

С ростом дизайн-системы в Альфа-Банке мы столкнулись с вопросом тестирования не только бизнес-логики проекта, но и его визуальной части. Рост количества компонентов дизайн системы влечет их связанность между собой. Причём связанность компонентов не обязательно должна быть сильной. Напротив, неявные связи больше привносят неочевидных поведений и непроверенных комбинаций, что в итоге отражается на количестве багов, вылавливаемых на бою. 

К примеру, у нас есть сложный компонент BannerWrapper, который хорошо показывает неявные связи.

data class BannerWrapperModel<T : BaseListItem>(
   val innerViewModel: T,
   @Deprecated(
       message = "Do not use it. If you need view with right icon, use RightIconWrapper.",
   )
   val rightIconModel: IconElementModel? = null,
   val backgroundColor: ColorSource? = null,
   val borderColor: ColorSource? = null,
   val bannerStyle: BannerWrapperStyle = BannerWrapperStyle.FILL,
   ...
): BaseListItem

То есть BannerWrapper — это компонент-обёртка, компонент, у которого нет или почти нет собственного визуала. Но при этом он способен расположить в себе по заданной логике любые компоненты дизайн системы включая их самих. 

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

Если у нас есть компонент-обертка, вмещающий в себя только один элемент одного типа из n произвольных компонентов отображения, то число комбинаций для проверки можно рассчитать по формуле:

Общее число размещений
Общее число размещений

Рассмотрим составные части формулы.

Число размещений дизайн компонентов без повторений внутри компонента-обертки:

Параметры, говорящие, что можно будет отправить на экран любой один компонент отображения, а также сам компонент-обертку:

Если для примера взять n=3, то получим:

Целых 19 комбинаций получается при наличии всего одного компонента-обёртки и трёх элементов отображения! И это в условиях жёстких рамок приведённого мной примера. Как вы можете догадаться, в реальных условиях цифры растут с невероятной скоростью, а тестировать такое количество руками никто не будет. 

Вариант решения

Описанная ранее проблема заключается в большом объеме ручной работы, что решается автоматизацией. И в нашем конкретном случае под автоматизацией я имею ввиду скриншот-тесты. Они смогут выявить несоответствия даже когда человек по невнимательности мог бы их не заметить. 

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

Таким образом, мы столкнулись со вторым вопросом, на который нам нужно ответить перед тем, как автоматизировать процесс проверки компонентов дизайн системы:

По каким правилам и в каком количестве мы будем добавлять новые скриншот-тесты при изменении или добавлении компонента? 

Я написал вопрос выше чтобы кто-то в будущем при интеграции скриншот-тестов задал его себе. Но ответить на него я, к сожалению, не могу, потому что ответ сильно зависит от архитектуры и особенностей конкретной дизайн-системы. Выделю лишь ключевые в моем понимании свойства, которые требуют проверки при их разных значениях:

  • Высота, ширина. Как при фиксированных значениях, так и при WRAP_CONTENT и MATCH_PARENT.

  • Вес (Linear Layout).

  • Привязки (Constraint Layout).

  • Разный объём текста.

  • Отступы у компонентов. В том числе, при их указании как у компонента-обертки, так и у дочерних элементов.

Также нужно учитывать темную тему при написании скриншот тестов. 

Выбор решения, эмулятор, сравнения картинок

Первой итерацией скриншот тестов было использование сторонней библиотеки Shot для работы со скриншот тестами. Однако, данное решение не отвечало нашим требованиям гибкости, из-за чего было принято решение написать свою библиотеку. 

Причины своего решения:

  • Нет кастомизации отчёта.

  • Нет автоматического перезапуска упавшего скриншот-теста заданное число раз, что усложняет автоматизацию при выносе прогона тестов на CI.

  • Проблемы на Windows.

  • Проблемы с флаканием тестов.

  • Зависимость от стороннего подрядчика. 

Важная особенностью скриншот тестов является выбор эмулятора. Для меня оказалось открытием, что emoji на разных версиях Android API отличаются, а это может привести к падению сравнения картинок. Из-за этого мы договорились запускать скриншот-тесты только на определённом эмуляторе определённой версии. А также указать внутри нашей библиотеки фиксированную ширину View, в рамках которой будет рисоваться интерфейс. 

Как оказалось, скриншот-тесты не самая стабильная технология, которая заставляет нас периодически видеть что-то подобное: 

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

Для работы с Java файлами как с картинками мы использовали библиотеку Scrimage, с помощью которой удалось удобно и лаконично произвести сравнение картинок, а также сформировать их композицию, вызывая соответствующие методы. 

Техническая сторона скриншот тестов

Техническую часть я разделили на 4 раздела:

  1. Создание и настройка ActivityScenario.

  2. Встраивание в AndroidJUnitRunner своей настройки тестов. 

  3. Функционал для работы тестов.

  4. Написание Gradle Plugin для удобной работы с тестами. Сравнение скриншотов. Генерация отчета. 

#1. Создание и настройка ActivityScenario

Мы в Альфе решили начать путь скриншот-тестов с покрытия всех компонентов, которые поддерживаются во внутренней библиотеке ServerDrivenUI. А это значит, что на вход параметризированному тесту будут поступать строки, содержащие Json, по которому будет создаваться элемент интерфейса, рисуемый в рамках фрагмента. После чего данный фрагмент размещается в рамках активности с передачей нужных параметров. 

internal class SnapshotConfiguredActivity : FragmentActivity() {


   override fun attachBaseContext(newBase: Context?) {
       super.attachBaseContext(newBase?.configure())
   }


   override fun onCreate(savedInstanceState: Bundle?) {
       applyThemeId()
       applyWindowStyle()
       fragmentConfigItem = null
       super.onCreate(savedInstanceState)
   }


   private fun Activity.applyThemeId() {
       fragmentConfigItem?.theme?.run { setTheme(this) }
   }


   private fun Activity.applyWindowStyle() {
       window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
   }


   private fun Context.configure(): Context {
       val newConfiguration = Configuration(resources.configuration)
       fragmentConfigItem?.uiMode?.run { newConfiguration.uiMode = configurationResourceCode }
       newConfiguration.fontScale = 1f
       return createConfigurationContext(newConfiguration)
   }
}

Имея активность, настраиваемую параметрами извне, её можно запустить через функционал из androidx.test.core:

ActivityScenario.launch(SnapshotConfiguredActivity::class.java)

В нашем случае компоненты дизайн-системы размещались во фрагменте. Имея инстанс ActivityScenario, добавление на экран фрагмента аналогично обычному методу, лишь за тем исключением, что может понадобится довести фрагмент не дальше определенного состояния через вызов setMaxLifecycle (fragment, initialState) на транзакции. 

#2. Интеграция в AndroidJUnitRunner своей настройки тестов

Для внедрения в работу ранера достаточно расширить класс AndroidJUnitRunner и указать его в конфигурациях модуля. В нашем случае данная надстройка — синглтон, сохраняющий на время выполнения тестов объект Instrumentation для доступа к файловой системе эмулятора и выдачи разрешений на чтение и запись данных. 

Выдача разрешения выглядит примерно так:

instrumentation.uiAutomation.executeShellCommand("pm grant ${context.packageName} $permission")

Получение пути до директории для скриншотов:

private val storageBaseUrl = Environment.getExternalStoragePublicDirectory(
   Environment.DIRECTORY_DOWNLOADS
).absolutePath

Не буду дублировать документацию о причинах такого подхода в получении пути. Можете об этом ознакомиться в документации Storage updates in Android 11.

#3. Функционал для работы тестов

Скриншот-тест у нас представляет из себя функцию, в которую передается экземпляр фрагмента, в рамках которого нарисовалась view по переданному Json и название скриншота. 

Ввиду того, что скриншот-тесты могут быть запущены на разных машинах, я задал фиксированную ширину для экрана. Для этого понадобится главный поток, к которому можно обратиться через объект Instrumentation, запустив операцию по работе с view через runOnMainSync, а также waitForIdleSync для ожидания завершения работы. 

Также необходимо: 

  • cкрыть курсор для всех EditText;

  • отключить горизонтальный и вертикальный ScrollBar, а так же overScroll.

Таким образом, мы можем получить настроенный объект отображения в качестве объекта Bitmap:

fragment.view!!.measureView().drawToBitmap(config)

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

#4. Gradle Plugin для удобной работы с тестами, сравнение скриншотов, генерация отчета 

В нашем случае выполнение скриншот-тестов состоит из нескольких этапов:

  • Выполнение задачи connectedDebugAndroidTest, после чего на эмуляторе в нужной директории сохраняются скриншоты.

  • Выполнение кастомизированной Gradle задачи.

Логика работы Gradle задачи такая:

Либо просто стягиваем скриншоты с эмулятора в проект. В данном случае мы имеем дела с эталонными скриншотами

Либо:

  • стягиваем скриншоты с эмулятора в репозиторий;

  • сравниваем новые скриншоты с эталонными;

  • копируем эталонные и временные скриншоты в /build каталог;

  • генерируем отчет на основании сравнения.

Далее я вкратце ознакомлю вас с технической стороной описанных выше этапов. 

Указание зависимости между задачами делается очень просто: когда вы создаете свою Gradle задачу, есть возможность выполнить какую-то другую в первую очередь:

task.dependsOn(otherTask)

Развилка в работе кастомизированной задачи определяется через наличие свойства у Gradle команды, запускаемой для выполнения скриншот-тестов:

project.providers.gradleProperty("record").orNull

Важная особенность: для работы с эмулятором через ADB вам понадобится путь до этого самого ADB. У меня получилось достать его через android-расширение:

val baseExtension = project.extensions.getByName("android") as BaseExtension
val adbPath = baseExtension.adbExecutable.path

Неочевидной особенностью оказался способ задания ограничения на выполняемые скриншот-тесты. Была задача при выполнении моей Gradle команды запускать не все инструментальные тесты, а только нужные. К счастью AndroidJUnitRunner оказался достаточно гибким в этом плане, и ему как раз можно передать путь до аннотации, чтобы запускались только те инструментальные тесты, которые помечены помечены заданной аннотацией. 

val flavor = variant.mergedFlavor
val argumentsMap = flavor.testInstrumentationRunnerArguments as? MutableMap<String, String>
argumentsMap?.put("annotation", "ru.alfabank...Screenshotable")

Код выше предоставляет возможность задать аргументы для нашего раннера. И в данную мапу я кладу путь до аннотации. 

Так же при написании Gradle задачи понадобился экстеншен, чтобы я мог из build.gradle модуля настраивать библиотеку для скриншот тестов. На данный момент настройка заключается в установке толерантности скриншот-тестов и создаётся примерно так:

interface MyExtension {
   val tolerance: Property<Int>
}

Gradle plugin:

project.extensions.create("myExtension", MyExtension::class.java)

После этого можно в рамках задачи обратиться к объекту расширения и получить из него значение:

task.tolerance.set(myExtension.tolerance.convention(DEFAULT_TOLERANCE_PERCENT))

build.gradle:

myExtension {
   tolerance 1
}

Для стягивание скриншотов с эмулятора я воспользовался объектом ExecOperations, который внутри Gradle задачи я запрашиваю через @get:Inject abstract val execOperations: ExecOperations. Команда копирования данных выглядит так:

$adbPath -s $device pull $pathToPull $pathToSave

Применив проверку на соответствии размеров, а так же попиксельное сравнение картинок,... 

val originalImagePixels = originalImage.pixels()
val temporalImagePixels = temporalImage.pixels()
val differentPixels = originalImagePixels
   .zip(temporalImagePixels)
   .filter { (a, b) -> a != b }

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

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

Помимо имеющихся картинок для лучшей визуализации расхождений в скриншотах, формируется их композиция. То есть, если в результате сравнения выявлены расхождения, превышающие заданные, то формируется ещё одна картинка с визуализацией расхождения. 

temporalImage.composite(RedComposite(1.0), originalImage)

По итогу сравнения мы получаем список моделей следующего контракта:

val originalPath: String,
val temporalPath: String,
val diffPath: String,
val isSimilar: Boolean

В репозитории нет необходимости держать картинки, помимо эталонных, поэтому дифф и временные скриншоты переносим в /build каталог модуля. 

Заключительный этап — генерация отчета по итогу выполнения скриншот тестов. В нашем случае отчёт из себя представляет html-страницу, содержащую те скриншоты, которые не прошли сравнение. index.html так же сохраняется в /build директории. 

html(
   head = head(),
   style = style(),
   body = body(
       navbar = navbar(),
       mainContainer = mainContainer(
           summaryResult = "Passed $passedTestsCount tests out of $testsCount",
           screenshotsTableBody = generateScreenshotsTableBody(comparations)
       )
   )
)

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

Пример упавшего теста в отчете
Пример упавшего теста в отчете

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

Поддержка windows.

Особенность поддержки windows заключалась в дву моментах:

  • Необходимо подкорректировать пути, заменив / на File.separator.

  • Не забыть добавить в PATH путь до ADB.

Итог

Надеюсь мои небольшие выкладки помогли разобраться в принципе работы нашего решения по скриншот тестированию, сформировав понимание ряда проблем, а также их решения. 

Комментарии (0)