Недавно в 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
запускаются тесты пользовательского интерфейса, подключать устройство и запускать эмулятор перед этим не нужно. Среда этого тестового прогона на разных машинах одинакова.
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
}
}
}
}
}
Возможно ли это с собственными устройствами?
Реализация пользовательского устройства
Мы реализовали интерфейс 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. Детали реализации — в репозитории проекта.
Вот реализованная структура:
Получение результатов
Когда пройдены все тесты, в методе 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())
:
Но это делается и без кода: достаточно вызвать 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-отчетам видно, какой тест на каком устройстве выполнялся:
Разбиение тестов на сегменты очень важно, когда тестов пользовательского интерфейса много. В репозитории я подготовил по 100 тестов пользовательского интерфейса для двух модулей: библиотечного и модуля приложения. В одном модуле тесты запускаются примерно 1 мин. 10 с., в обоих примерно 2 мин. 33 с. Запуск того же набора тестов на MultipleDevices
завершается за примерно 1 мин. 31 с. — почти вдвое меньше необходимого.
Эмуляторы Test_time
----------- -----------
1 2 мин. 33 с.
2 1 мин. 31 с.
3 43 с.
Параллельное удаленное выполнение
Но даже с таким значительным улучшением мы не избавились от одной проблемы: эмуляторы нужно запускать локально, что чревато большим расходом ресурсов на ноутбуках разработчиков.
Решим ее, создав сервер с возможностью размещения на нем десятков эмуляторов и следующим 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
}
}
Результаты
Мы реализовали пользовательский Device
для интеграции с управляемыми Gradle устройствами. Device — это абстракция с одним устройством или эмулятором, размещенным локально или удаленно на веб-сервисе типа Firebase Test Lab, или пользовательской фермой устройств со службой-посредником.
В случае с одним устройством реальных преимуществ нет: оно подключается к ADB напрямую через adb connect
без потери функциональности, например возможности подключения отладчика.
А вот реализуя Device
с несколькими размещаемыми удаленно устройствами, мы высвобождаем вычислительные ресурсы ноутбуков разработчиков и серверов сборки непрерывной интеграции.
Кроме того, применяя разбиение на сегменты, мы распараллеливаем выполнение тестов и в случае с двумя устройствами вдвое увеличиваем его скорость.
Код доступен в репозитории, но учтите: это всего лишь проверка концепции и для использования в продакшене не предназначено. А код — отличная основа для внедрения этого подхода в вашей организации. Детали реализации в значительной степени зависят от инфраструктуры. Наконец, сам код не защищен от ошибок, его следует переписать, сделав более надежным.
Читайте также:
- Основы Android-разработки в Revolut
- Повторное использование UI в Android - 5 главных ошибок
- Автоматизация создания файлов для нового экрана с плагином для Android Studio
Читайте нас в Telegram, VK и Дзен
Перевод статьи Yury: How to use Gradle Managed Devices with your own devices