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

Прежде всего начнем с рассмотрения способов захвата визуального представления View или ViewGroup для Android. Самым простым способом создания изображения для View может быть его отрисовка методом draw на Canvas, созданный в памяти. Для определения размера необходимо последовательно measure и layout. Например, можно использовать следующую реализацию:

fun saveGolden(context: Context, bitmap: Bitmap, name: String) {
    val file = File(context.filesDir.absolutePath+File.separator+name)
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, file.outputStream())
}

fun compareWithGolden(context: Context, bitmap: Bitmap, goldenName: String):Boolean {
    val file = File(context.filesDir.absolutePath+File.separator+goldenName)
    val goldenBitmap = BitmapFactory.decodeStream(file.inputStream())
    return bitmap.sameAs(goldenBitmap)
}

fun captureView(view: View, width: Int? = null, height: Int? = null): Bitmap {
    view.measure(
        makeMeasureSpec(width ?: 0, if (width != null) EXACTLY else UNSPECIFIED),
        makeMeasureSpec(height ?: 0, if (height != null) EXACTLY else UNSPECIFIED)
    )
    view.layout(0, 0, view.measuredWidth, view.measuredHeight)
    val bitmap =
        Bitmap.createBitmap(view.measuredWidth, view.measuredHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    view.background?.draw(canvas)
    view.draw(canvas)
    return bitmap
}

Функция captureView возвращает Bitmap-объект для переданного View (или ViewGroup), при этом можно переопределить ширину/высоту или использовать измеренные размеры View. Функция saveGolden сохраняет Bitmap в png-файл (важно не использовать сжатие, чтобы артефакты компрессии не привели к ошибкам при сравнении). Функция compareWithGolden сравнивает полученный bitmap с эталонным и возвращает true при полном совпадении результатов.

Таким образом мы можем делать сравнение визуального представления и эталона для любых View, включая сложные иерархии с вложенными ViewGroup. Метод compareWithGolden можно также использовать при запуске тестов через assert-проверку результата на true. Основная особенность метода в том, что здесь не используется отображение на экране и, как следствие, использовать метод в инструментальном тестировании может быть затруднительно, поскольку в этом случае ожидается проверка текущего состояния на экране. Как можно захватить текущее состояние экрана?

При выполнении инструментального теста можно использовать методы пакета androidx.test.runner.screenshot. Класс ScreenShot содержит статические методы capture() для захвата текущего состояния экрана и capture(activity:Activity) для сохранения состояния указанной Activity. Также есть вариант метода для захвата View: capture(view: View). Результатом выполнения будет объект класса ScreenCapture, из которого можно получить финальное изображение через обращение к свойству bitmap. Дальнейшие действия аналогично ранее рассмотренным сценариям сохранения и сравнения с эталонным изображением.

При использовании оберток вокруг Espresso процесс подготовки снимка экрана может быть даже проще, например в Kaspresso можно вызвать функцию captureScreenshot, которая создаст файл с указанным названием, содержащий текущее отображаемое состояние экрана.

Но все же с захватом полного экрана есть несколько важных ограничений. Прежде всего в сохраняемое изображение будут добавлены также элементы управления оболочки Android (включая строку оповещений) и это не позволит выполнять сравнение с эталонным изображением. Можно решить проблему либо через захват Activity, либо сохранением фрагмента изображения с определением расположения View на экране.

Объект View содержит информацию о координатах и размере (метод getLocationOnScreen заполняет массив целых чисел - координаты x и y, свойства width и height содержат размеры элемента). Дальше можно создать новый Bitmap на основе существующего. Например, для этого можно использовать подобную функцию:

fun getViewOnScreen(screen: Bitmap, view: View): Bitmap {
    var coords = intArrayOf(0, 0)
    view.getLocationOnScreen(coords)
    return Bitmap.createBitmap(screen, coords[0], coords[1], view.width, view.height)
}

Важно отметить, что методы захвата могут не сработать в Android O и более новых, в этом случае вместо них нужно использовать PixelCopy:

fun captureView(view: View, activity: Activity, callback: (Bitmap) -> Unit) {
    activity.window?.let { window ->
        val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
        val locationOfViewInWindow = IntArray(2)
        view.getLocationInWindow(locationOfViewInWindow)
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                PixelCopy.request(
                    window,
                    Rect(
                        locationOfViewInWindow[0],
                        locationOfViewInWindow[1],
                        locationOfViewInWindow[0] + view.width,
                        locationOfViewInWindow[1] + view.height
                    ), bitmap, { copyResult ->
                        if (copyResult == PixelCopy.SUCCESS) {
                            callback(bitmap) }
                        }
                    },
                    Handler(Looper.getMainLooper())
                )
            }
        } catch (e: IllegalArgumentException) {
//process error
            e.printStackTrace()
        }
    }
}

Также в ряде случаев не работает и этот метод и необходимо использовать методы MediaProjection для создания виртуального экрана и захвата экрана.

Кроме Android View также нужно рассмотреть способ захвата изображений из @Composable-функций (для Jetpack Compose). Здесь ситуация немножко проще, поскольку элементы дерева Composable содержат метод captureToImage(), из которого можно получить Bitmap .asAndroidBitmap(). Также для захвата экрана с Compose может использоваться библиотека Compose Screenshot.

Для сравнения изображения с эталоном и определения разности можно использовать библиотеку Shot. Также она поддерживает захват экрана для Android Activity и Jetpack Compose. При необходимости можно создать собственную функцию для пиксельного сравнения Bitmap и визуализации различающихся пикселей при необходимости сложной визуализации различий.

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

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


  1. basnopisets
    14.10.2022 10:40
    +2

    Shot уже не так хорош и по нескольким причинам: во-первых он уже не так активно поддерживается. Я сам открывал реквест с фиксом и ждал аппрува пару месяцев. В котлин-слеке автор библиотеки мне написал, что раньше он её развивал в рамках своих рабочих обязанностей, сейчас же ему за это уже не платят и поэтому он занимается поддержкой раз в два-три месяца, а то и реже. То есть для работы придется делать форк, если понадобится что-то срочно пофиксить.

    Во-вторых, Shot использует совсем уж наивный алгоритм для сравнения скриншотов - просто побайтовый проход и сравнение на равенство. Для нас это было слишком хрупким решением. Вместо этого я написал свою обертку над ImageMagick - там гораздо более интересные алгоритмы можно использовать, например PHash.

    Ну и в третьих, Shot требует доступ к ADB и если для тестирования используется облачная ферма, FTL например, то Shot не вариант, поскольку там доступа к ADB нет