Автоматизация скриншот-тестирования предварительных просмотров Compose с использованием отражения

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

Showkase

Одним из способов автоматизации скриншот-тестирования является использование Showkase  —  библиотеки от Airbnb, предназначенной для генерации веб-компонентов, а также позволяющей с помощью Paparazzi автоматически тестировать все методы, аннотированные @Preview. Вот только для этого придется добавить Showkase в качестве зависимости для каждого модуля, в котором есть предварительные просмотры, требующие тестирования, что увеличит время сборки всех модулей, так как Showkase опирается на генерацию кода. Поэтому использование Showkase только для скриншот-тестов может оказаться не лучшим решением, поскольку это не основная функция библиотеки.

Отражение

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

Для тестов мы будем использовать библиотеку Paparazzi, разработанную Cashapp. Главное ее преимущество  —  отсутствие необходимости в эмуляторе для выполнения тестов, что делает их более быстрыми и надежными.

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

Пример проекта

Я реализовал решение, основанное на отражении, на примере проекта, форкнутого из официального Android-приложения Now. Большая часть кода находится в классе PreviewTests в модуле screenshot-test, созданном специально для тестов. Кроме того, в модуль core:ui были добавлены некоторые вспомогательные аннотации и классы.

Поиск всех предварительных просмотров

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

private fun findPreviewMethodsInCodebase(): Set<Method> {
val configuration = ConfigurationBuilder()
.forPackage(APPLICATION_PACKAGE)
.filterInputsBy(FilterBuilder().includePackage(APPLICATION_PACKAGE))
.addScanners(Scanners.MethodsAnnotated)
val reflections = Reflections(configuration)
return reflections.getMethodsAnnotatedWith(Preview::class.java) +
reflections.getMethodsAnnotatedWith(ThemePreviews::class.java) +
reflections.getMethodsAnnotatedWith(DevicePreviews::class.java)
}

Этот метод будет использован ComposablePreviewProvider для создания списка ComposablePreview, необходимого для запуска тестов.

Помимо поиска всех методов с аннотацией Preview, обработаем граничный случай: аннотации с несколькими предварительными просмотрами ThemePreviews и DevicePreviews, используемые в Android-проекте Now. Для простоты будем обращаться с ними, как с обычной аннотацией Preview, но при необходимости код ComposablePreviewProvider можно модифицировать для других способов обработки.

На этом этапе необходимо выбрать стратегию для автоматизации. В данном случае выберем стратегию создания тестов для всех предварительных просмотров (включая приватные методы). Однако в зависимости от своих потребностей вы можете генерировать тесты только для публичных предварительных просмотров или использовать пользовательскую аннотацию для явного обозначения функций, которые необходимо протестировать.

Я считаю, что генерирование тестов для всех предварительных просмотров по умолчанию является наиболее оптимальным, поскольку позволяет сократить время выполнения тестов. Если вы работаете над небольшим проектом, то временные затраты, скорее всего, будут незначительными. К примеру, в проекте, где изначально был реализован данный подход, на запуск более 250 тестов в CI ушло около минуты, включая время, потраченное на сборку модуля.

Игнорирование предварительных просмотров и Java Dynamic Proxy

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

override fun provideValues(): List<ComposablePreview> {
val composablePreviews = mutableListOf<ComposablePreview>()

findPreviewMethodsInCodebase()
.filterNot { method -> method.annotations.any { it is IgnoreScreenshotTest } }
.onEach { if (Modifier.isPrivate(it.modifiers)) it.isAccessible = true }
.forEach { method ->
composablePreviews.add(method.toComposablePreview())
}

return composablePreviews
}

Найдя все методы предварительного просмотра, отфильтровав те из них, которые необходимо проигнорировать, и сделав эти методы доступными, можно преобразовать их в ComposablePreviews. Это необходимо сделать потому, что последним параметром каждого метода, получаемого посредством отражения, будет экземпляр интерфейса Composer. Параметр Composer связывает написанный код composable-функции со средой выполнения и добавляется к каждому методу плагином compose compiler.

Получить к нему доступ напрямую нельзя, но можно использовать динамический прокси Java и InvocationHandler для получения экземпляра Composer при вызове метода out.

Именование тестов

Для запуска тестов мы будем использовать TestParameterInjector:

@RunWith(TestParameterInjector::class)
class PreviewTests {
@get:Rule val paparazzi: Paparazzi = PaparazziRuleProvider.get()

@Test
fun snapshot(
@TestParameter(valuesProvider = ComposablePreviewProvider::class)
composablePreview: ComposablePreview,
) {
paparazzi.snapshot { composablePreview() }
}
}

Важная особенность его работы заключается в следующем: он будет вызывать метод toString() каждого передаваемого ему параметра и использовать его результат в качестве суффикса для имени теста, которое также будет названием зафиксированного снапшота.

Чтобы получить осмысленное имя, можно переопределить этот метод при создании обертки:

private fun Method.toComposablePreview(...): ComposablePreview {
val proxy = Proxy.newProxyInstance(...) as ComposablePreview

return object : ComposablePreview by proxy {
override fun toString(): String {
return buildList<String> {
add(declaringClass.simpleName)
add(name)
}.joinToString("_")
}
}
}

PreviewParameterProvider

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

Чтобы можно было вызывать их и при этом иметь осмысленные имена, создадим вспомогательный класс:

abstract class NamedPreviewParameterProvider<T> : PreviewParameterProvider<T> {
abstract val nameToValue: Sequence<Pair<String?, T>>
final override val values: Sequence<T>
get() = nameToValue.map { it.second }
}

// пример использования
class ExampleProvider: NamedPreviewParameterProvider<String>() {
override val nameToValue = sequenceOf(
"One" to "1",
"Two" to "2",
null to "A parameter that will be ignored for tests"
)
}

Код в ComposablePreviewProvider.provideValues будет проверять каждый метод на предмет использования параметров с помощью вспомогательной функции:

private fun Method.findPreviewParameterAnnotation(): PreviewParameter? {
return this.parameterAnnotations
.flatMap { it.toList() }
.find { it is PreviewParameter } as PreviewParameter?
}

Если это так, то можно получить тип класса PreviewParameterProvider, создать экземпляр и получить доступ к его значениям для создания нескольких ComposablePreviews. В качестве суффикса для имени теста и снапшота будем использовать либо имя, либо индекс параметра:

val providerAnnotation = method.findPreviewParameterAnnotation()

if (providerAnnotation == null) { ... } else {
// Создание экземпляра PreviewParameterProvider.
val provider = providerAnnotation.provider.createInstanceUnsafe()

// Получение последовательности с именем и значением каждого параметра,
// с которым должен быть вызван [Preview].
val nameToValue = if (provider is NamedPreviewParameterProvider<*>) {
provider.nameToValue.mapNotNull { (name, value) ->
// игнорировать предварительные просмотры с параметром, имеющим имя null
if (name == null) return@mapNotNull null

name to value
}
} else {
provider.values.mapIndexed { index, value ->
index.toString() to value
}
}

// Создание [ComposablePreview] для каждой пары имени и значения.
nameToValue.forEach { (nameSuffix, value) ->
composablePreviews.add(method.toComposablePreview(nameSuffix, value))
}
}

Пользовательская конфигурация

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

@Target(AnnotationTarget.FUNCTION)
annotation class ScreenshotTestParameters(
val renderingMode: TestRenderingMode = TestRenderingMode.SHRINK,
)

enum class TestRenderingMode { NORMAL, SHRINK }

После несложных изменений в коде PreviewTests можно извлекать любые данные, переданные через эту аннотацию, и использовать их для изменения снапшота теста по своему усмотрению. В примере репозитория они использованы для изменения режима рендеринга Paparazzi в одном из предварительных просмотров с Shrink, выбранного для тестов по умолчанию, на Normal.

Устранение неполадок

При тестировании записи ./gradlew screenshot-test:recordPaparazziProdRelease или проверке ./gradlew screenshot-test:verifyPaparazziProdRelease мы не увидим подробностей о причине неудачи теста, если что-то не так с самим тестом:

com.google.samples.apps.nowinandroid.screenshottest.PreviewTests > 
snapshot[NewsFeedKt_NewsFeedLoadingPreview] FAILED
java.lang.IllegalArgumentException at PreviewTests.kt:65

32 tests completed, 1 failed

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

Заключение

Предложенное решение по автоматизации скриншот-тестирования предварительных просмотров Composable занимает всего пару часов, интегрируется в проект и имеет близкие к нулю затраты на обслуживание (перезапись снапшотов после любых изменений пользовательского интерфейса). При этом оно позволяет быть уверенным в том, что любое изменение пользовательского интерфейса не приведет к неожиданным сбоям, и сделает вашу работу с Jetpack Compose еще более эффективной.

Ссылки

  1. Пример проекта.
  2. Paparazzi.
  3. Библиотека Reflections на Github.
  4. Площадка для скриншот-тестирования в Android.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Roman Kamyshnikov: Automate screenshot testing for Compose previews via reflection

Предыдущая статьяГрафовые сверточные сети: введение в GNN
Следующая статьяМалоизвестный пакет Go, который пригодится при выполнении SQL-миграций