Как использовать управляемые Gradle устройства с собственными девайсами

Недавно в Google представили новый функционал для Android Gradle Plugin  —  Firebase Test Lab для управляемых Gradle устройств. В нем используется API управляемых Gradle устройств для запуска тестов не на той же машине, где выполняется Gradle, а на удаленном виртуальном или физическом устройстве внутри Firebase Test Lab (это платная функция). Рассмотрим его функциональность и возможность использования собственной фермы устройств для такого же удаленного запуска тестов, как в Firebase Test Lab, и распараллеливания выполнения на нескольких устройствах.

Управляемые Gradle устройства

Управляемые Gradle устройства изначально выпущены для делегирования в Android Gradle процесса создания, запуска и закрытия эмуляторов.

android {
testOptions {
managedDevices {
devices {
register("pixel2api30", com.android.build.api.dsl.ManagedVirtualDevice) {
device = "Pixel 2"
apiLevel = 30
systemImageSource = "aosp"
}
}
}
}
}

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

Схема работы ManagedVirtualDevice.

Firebase Test Lab

Firebase Test Lab для управляемых Gradle устройств  —  это функционал, представленный на Android Dev Summit 2022. Тесты пользовательского интерфейса теперь запускаются в Test Lab прямо из Gradle, инструменты командной строки и веб-интерфейс пользователя не нужны:

plugins {
id 'com.google.firebase.testlab'
}

android {
testOptions {
managedDevices {
devices {
register("pixel2api30", com.google.firebase.testlab.gradle.ManagedDevice) {
device = "Pixel2"
apiLevel = 30
}
}
}
}
}
Схема работы Firebase Test Lab.

Возможно ли это с собственными устройствами?

Реализация пользовательского устройства

Мы реализовали интерфейс com.android.build.api.dsl.Device, использовав и com.android.build.api.dsl.ManagedVirtualDevice, и com.google.firebase.testlab.gradle.ManagedDevice. А что если реализовать пользовательский?

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

В созданном модуле плагина Gradle объявляем MyDevice и применяем его в модуле приложения build.gradle:

interface MyDevice : Device
android {
testOptions {
managedDevices {
devices {
register("myDevice", MyDevice) {}
}
}
}
}

При синхронизации проекта получаем исключение: Cannot create a MyDevice because this type is not known to this container («Не удается создать MyDevice, поскольку этот тип неизвестен этому контейнеру»). То есть контейнер android.testOptions.managedDevices.devices «не знает», как создать экземпляр MyDevice, потому что это интерфейс.

Как проблема решается с Android Gradle Plugin? Путем поиска com.android.build.api.dsl.ManagedVirtualDevice находим такой код:

dslServices.polymorphicDomainObjectContainer(Device::class.java).apply {
registerBinding(
com.android.build.api.dsl.ManagedVirtualDevice::class.java,
com.android.build.gradle.internal.dsl.ManagedVirtualDevice::class.java
)
}

С помощью registerBinding в контейнере указывается на открытый внутренний класс com.android.build.gradle.internal.dsl.ManagedVirtualDevice, когда клиенты API пытаются добавить в него что-то из типа com.android.build.api.dsl.ManagedVirtualDevice. В контейнере создается экземпляр класса ManagedVirtualDevice и указывается в лямбде метода register.

Сделаем то же, применив к проекту абстрактный класс, которым реализуется MyDevice, и пользовательский плагин Gradle:

internal abstract class MyDeviceImpl(
private val name: String,
): MyDevice {
override fun getName(): String = name
}

class MyDevicePlugin : Plugin<Project> {
override fun apply(target: Project) {
target.plugins.withType(AndroidBasePlugin::class.java) {
target.extensions.configure(CommonExtension::class.java) {
it.testOptions.managedDevices.devices.registerBinding(
MyDevice::class.java,
MyDeviceImpl::class.java,
)
}
}
}
}
plugins {
id `my-device-plugin`
}

android {
testOptions {
managedDevices {
devices {
register("myDevice", MyDevice) {}
}
}
}
}

Снова синхронизируем и видим другое исключение:

Caused by: java.lang.IllegalStateException: Unsupported managed 
device type:
class
com.bumble.devicefarm.plugin.device.farm.DeviceFarmImpl_Decorate
d
at
com.android.build.gradle.internal.TaskManager.createTestDevicesF
orVariant(TaskManager.kt:1905)

Судя по трассировке стека, нужно добавить android.experimental.testOptions.managedDevices.customDevice=true в gradle.properties, и тогда в MyDevice реализуется ManagedDeviceTestRunnerFactory:

internal abstract class MyDeviceImpl(
private val name: String,
) : MyDevice, ManagedDeviceTestRunnerFactory {

override fun getName(): String = name

override fun createTestRunner(
project: Project,
workerExecutor: WorkerExecutor,
useOrchestrator: Boolean,
enableEmulatorDisplay: Boolean
): ManagedDeviceTestRunner =
MyDeviceTestRunner()

}

Интересна не фабрика, а возвращаемый в ней класс ManagedDeviceTestRunner:

interface ManagedDeviceTestRunner {

// возвращается true, только когда пройдены все тестовые сценарии; иначе — false
fun runTests(
managedDevice: Device,
runId: String,
outputDirectory: File,
coverageOutputDirectory: File,
additionalTestOutputDir: File?,
projectPath: String,
variantName: String,
testData: StaticTestData,
additionalInstallOptions: List<String>,
helperApks: Set<File>,
logger: Logger
): Boolean

}

В методе runTests много данных для запуска тестов, он вызывается для каждого модуля Gradle. testData здесь используется для получения и установки APK-файлов и запуска тестов посредством инструментирования.

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

В Android Studio тесты запускаются нажатием кнопки run («Запустить») рядом с именем теста или выполнением connectedAndroidTest через Gradle. Запустим их без этих инструментов.

В обоих подходах на устройстве используется команда am instrument, запускаемая через ADB. Подробнее  —  в официальной документации.

adb shell am instrument -w <test_package_name>/<runner_class>

Запустим тесты аналогично из метода runTests. Благодаря библиотеке dadb из mobile.dev, подключаемся к устройству по протоколу ADB напрямую, не вызывая исполняемый файл ADB.

Внутри метода runTests с помощью Dadb подключаемся к локальному эмулятору, устанавливаем APK и запускаем тест:

override fun runTests(...): Boolean {
// Подключаемся к локальному эмулятору
Dadb.create("localhost", 5555).use { dadb ->
// Устанавливаем APK-файлы приложения
val apks = testData.testedApkFinder.invoke(DadbDeviceConfigProvider(dadb))
// Пусто в случае библиотечного модуля
if (apks.isNotEmpty()) {
// Для поддержки пакетов приложения используем многократную установку
dadb.installMultiple(apks)
}
// Устанавливаем APK инструментирования
dadb.install(testData.testApk)

// Запускаем тесты
dadb.shell("am instrument -w ${testData.applicationId}/${testData.instrumentationRunner}")
}
return true
}

У метода runTests и класса StaticTestData много параметров. Возьмем лишь малую их часть  —  необходимый минимум для работы:

  • testData.testedApkFinder для получения устанавливаемых APK-файлов приложения. В случае библиотечного модуля вернется пустой список. С DeviceConfigProvider получается соответствующий список APK из пакета приложения.
  • testData.testApk  —  это APK инструментирования с кодом из папки androidTest.
  • testData.applicationId  —  идентификатор приложения, запускаемый с командой instrument.
  • testData.instrumentationRunner  —  тест-раннер наподобие androidx.test.runner.AndroidJUnitRunner, указываемый в файле build.gradle.

Реализацию пользовательского DeviceConfigProvider опустим: там просто вызывается dadb.shell(“getptop name”).output для таких свойств, как локаль, плотность экрана, язык, регион и ABI. Детали реализации  —  в репозитории проекта.

Вот реализованная структура:

Схема текущей реализации MyDevice.

Получение результатов

Когда пройдены все тесты, в методе runTests возвращается true, в любых других случаях  —  false, но теперь всегда возвращается true. Разберемся, как получить результаты инструментирования.

По умолчанию, если запустить am instrument из командной строки, мало что увидим, кроме сбоев и конечного результата. Но у этой команды имеется два флага: -r и -m. Первым результаты возвращаются в виде текстового потока, вторым  —  в виде потока буферов протокола, поддерживаемого только в API >= 26. Ради краткости применим только второй.

Для создания экземпляра IInstrumentationResultParser, которым из am instrument принимаются «сырые» данные, в Android Gradle используется перечисление RemoteAndroidTestRunner.StatusReporterMode.PROTO_STD:

val mode = RemoteAndroidTestRunner.StatusReporterMode.PROTO_STD
val parser = mode.createInstrumentationResultParser(runId, emptyList())

Dadb.create(host, port).use { dadb ->
...
dadb
.openShell("am instrument -w ${mode.amInstrumentCommandArg} $arguments ${testData.applicationId}/${testData.instrumentationRunner}")
.use { stream ->
while (true) {
val packet: AdbShellPacket = stream.read()
if (packet is AdbShellPacket.Exit) break
parser.addOutput(packet.payload, 0, packet.payload.size)
}
parser.flush()
}
...
}

На этот раз вместо shell используем метод openShell и получаем доступ к «сырому» потоку данных, то есть потоку буферов протокола, и передаче данных в IInstrumentationResultParser.

Отчеты в форматах HTML и XML и неявные ожидания

О тестах и их статусах слушатели уведомляются с помощью IInstrumentationResultParser. Мы передали туда emptyList(). Какой парсер использовать? Имеется пара готовых реализаций, но правильный ответ  —  com.android.build.gradle.internal.testing.CustomTestRunListener, применяемый в Android Gradle plugin при запуске ManagedVirtualDevice:

val xmlWriterListener = CustomTestRunListener(
name,
projectPath,
variantName,
LoggerWrapper(logger),
)
xmlWriterListener.setReportDir(outputDirectory)
xmlWriterListener.setHostName("localhost:5555")
...
val parser = mode.createInstrumentationResultParser(runId, listOf(xmlWriterListener))
...
return !xmlWriterListener.runResult.hasFailedTests()

В CustomTestRunListener расширяется XmlTestRunListener и пишется XML-отчет о тестах, который может использоваться в Android Gradle, TeamCity или даже вами.

Если запустить myDeviceDebugAndroidTest без создания отчета CustomTestRunListener, com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestResultAggregationTask завершится исключением. Ожидается, что мы сгенерируем минимум один XML-отчет, который начинается с TEST- и применяется с помощью CustomTestRunListener. В задаче отчеты в форматах XML и HTML генерируются вместе, вот скриншот с ними:

Имея доступ к методу hasFailedTests, мы теперь применяем CustomTestRunListener и для возврата правильного значения из метода runTests.

Удаленное выполнение

При использовании Dadb.create передается не только localhost, но и любой IP-адрес. То есть код применяется для запуска тестов на удаленном эмуляторе или устройстве. Для этого сделаем MyDevice настраиваемым:

interface MyDevice : Device {

@get:Input
val host: Property<String>

@get:Input
val port: Property<Int>

}

internal abstract class MyDeviceImpl(
private val name: String,
) : RemoteDevice, ManagedDeviceTestRunnerFactory {

init {
// Параметры по умолчанию
host.convention("localhost")
port.convention(5555)
}

...
}
register("remoteDevice", MyDevice) {
it.host = "192.168.3.4"
it.port = 43617
}

Затем используем Dadb.create(managedDevice.host.get(), managedDevice.port.get()):

Схема текущей реализации MyDevice.

Но это делается и без кода: достаточно вызвать adb connect IP:PORT, и удаленное устройство появится в списке устройств adb devices и в выпадающем списке Android Studio. Выполняется даже отладка тестов, которая невозможна с управляемыми Gradle устройствами. Это важно для следующих этапов.

Параллельное выполнение

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

В AndroidJUnitRunner поддерживается разбиение тестов на сегменты с запуском одного сегмента. Например, с am instrument -w -e numShards 2 -e shardIndex 0 выполняется каждый первый из двух тестов, а с -e shardIndex 1  —  каждый второй.

На практике это происходит так:

#!/usr/bin/env bash
./gradlew assembleAndroidTest
pids=
env ANDROID_SERIAL=emulator-5554 ./gradlew \
connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.numShards=2 \
-Pandroid.testInstrumentationRunnerArguments.shardIndex=0 \
-PtestReportsDir=build/testReports/shard0 \
-PtestResultsDir=build/testResults/shard0 \
&
pids+=" $!"
env ANDROID_SERIAL=emulator-5556 ./gradlew \
connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.numShards=2 \
-Pandroid.testInstrumentationRunnerArguments.shardIndex=1 \
-PtestReportsDir=build/testReports/shard1 \
-PtestResultsDir=build/testResults/shard1 \
&
pids+=" $!"
wait $pids || { echo "there were errors" >&2; exit 1; }
exit 0

Это не так удобно: приходится запускать параллельно две задачи connectedAndroidTest с кучей параметров и вручную объединять результаты XML для их отправки, например, в TeamCity.

В управляемых Gradle устройствах имеется поддержка разбиения тестов на сегменты с помощью параметра android.experimental.androidTest.numManagedDeviceShards=<number_of_shards>, но только в случае с ManagedVirtualDevice. Нам же нужно разбиение с устройствами, которыми мы управляем сами.

В управляемых Gradle устройствах мы работаем с абстракцией Device, которой потенциально может реализовываться множество устройств, представленных как единое целое. Введем новый тип устройства и в методе register отметим его как device:

interface MultipleDevices : Device {

@get:Input
val devices: ListProperty<String>

}
android {
testOptions {
managedDevices {
devices {
register("multipleDevices", com.example.MultipleDevices) {
it.devices.add("localhost:5555")
it.devices.add("localhost:5557")
}
}
}
}
}

Все, связанное с ADB, извлекается в класс AdbRunner с новым параметром ShardInfo(index, total). Параметры ShardInfo просто добавляются к выполнению dadb.openShell(“am instrument”) как есть.

Теперь реализуем ManagedDeviceTestRunner для параллельного выполнения тестов:

override fun runTests(...): Boolean {

val devices = managedDevice.devices.get().map {
// Разделяем «host:port» на пару <String, Int>
val split = it.split(':')
split[0] to split[1].toInt()
}

val threadPool = Executors.newCachedThreadPool()

val futures = devices.mapIndexed { index, (host, port) ->
threadPool.submit(Callable {
val runner = AdbRunner(
host = host,
port = port,
shardInfo = AdbRunner.ShardInfo(
index = index,
total = devices.size,
),
)
val result = runner.run(
// Имя устройства должно быть уникальным, чтобы для каждого создавался отдельный XML-отчет
name = "${managedDevice.name}-${host}-${port}",
runId = runId,
outputDirectory = outputDirectory,
projectPath = projectPath,
variantName = variantName,
testData = testData,
logger = logger,
)
result
})
}

val success = futures.all { it.get() }

threadPool.shutdown()

return success
}

Обратите внимание:

  • Использование ThreadPool в Gradle  —  плохая практика, лучше заменить на WorkerExecutor, пригодный экземпляр которого получается из параметров ManagedDeviceTestRunnerFactory. Но я пока его не задействую, потому что StaticTestData не сериализуется и копировать его свойства в отдельное хранилище сериализуемых данных, чтобы передать его в WorkerExecutor, мне не хочется.
  • Имя name должно быть уникальным для каждого сегмента. Оно передается в CustomTestRunListener, которым сгенерируется XML-отчет с именем в имени файла. По всем собранным потом и объединенным XML-отчетам видно, какой тест на каком устройстве выполнялся:
Отчет в формате HTML

Разбиение тестов на сегменты очень важно, когда тестов пользовательского интерфейса много. В репозитории я подготовил по 100 тестов пользовательского интерфейса для двух модулей: библиотечного и модуля приложения. В одном модуле тесты запускаются примерно 1 мин. 10 с., в обоих примерно 2 мин. 33 с. Запуск того же набора тестов на MultipleDevices завершается за примерно 1 мин. 31 с.  —  почти вдвое меньше необходимого.

Эмуляторы   Test_time  
----------- -----------
1 2 мин. 33 с.
2 1 мин. 31 с.
3 43 с.
Схема текущей реализации MultipleDevices.

Параллельное удаленное выполнение

Но даже с таким значительным улучшением мы не избавились от одной проблемы: эмуляторы нужно запускать локально, что чревато большим расходом ресурсов на ноутбуках разработчиков.

Решим ее, создав сервер с возможностью размещения на нем десятков эмуляторов и следующим HTTP API:

GET /lease?devices=%number%

200 OK
[
{
host: "10.10.0.3",
port: 5555,
release_key: "ab34fd2d158f9"
},
...
]

POST /release
[ "ab34fd2d158f9", ... ]

200 OK

С сервера возвращается набор устройств или эмуляторов, гарантированно используемых только нами, если не освободить их соответствующим API-вызовом.

Этот «посредник устройств» полезен и для непрерывной интеграции, ее обычный поток состоит из сборки приложения и запуска тестов в эмуляторах. Оба эти этапа  —  задачи с интенсивным расходом ресурсов процессора и памяти, их параллельное выполнение чревато снижением производительности. Передавая эмуляторы другим серверам, мы ограничиваем работу сборочного сервера сборкой приложения и проверкой результатов тестов и не делимся его ресурсами с эмуляторами.

Реализация на стороне Gradle довольно проста и аналогична MultipleDevices:

interface DeviceFarm : Device {

@get:Input
val shards: Property<Int>

}

class DeviceBroker {

fun lease(amount: Int): Collection<Device> {
TODO("Make a network request to acquire devices, should wait if no available")
}

fun release(devices: Collection<Device>) {
TODO("Make a network request to release devices to make them available for others")
}

class Device(
val host: String,
val port: Int,
val releaseToken: String,
)

}

internal class DeviceFarmTestRunner : ManagedDeviceTestRunner {

override fun runTests(...): Boolean {
val broker = DeviceBroker()
val devices = broker.lease(managedDevice.shards.get())

devices.forEachIndexed { shardIndex, device ->
...
}

broker.release(devices)

return success
}

}
Схема текущей реализации DeviceFarm.

Результаты

Мы реализовали пользовательский Device для интеграции с управляемыми Gradle устройствами. Device  —  это абстракция с одним устройством или эмулятором, размещенным локально или удаленно на веб-сервисе типа Firebase Test Lab, или пользовательской фермой устройств со службой-посредником.

В случае с одним устройством реальных преимуществ нет: оно подключается к ADB напрямую через adb connect без потери функциональности, например возможности подключения отладчика.

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

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

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

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

Читайте нас в TelegramVK и Дзен


Перевод статьи Yury: How to use Gradle Managed Devices with your own devices

Предыдущая статья7 методов оптимизации производительности React
Следующая статьяВыполнение одновременных сетевых запросов в Java: быстро и эффективно