UIAutomator: дамп иерархии и разбор XML:
uiautomator - это одновременно фреймворк для тестов на Kotlin/Java и консольная утилита на самом устройстве. Нас интересует вторая: она выгружает дерево UI текущего экрана в XML.
Код: Выделить всё
$ adb shell uiautomator dump
UI hierchary dumped to: /sdcard/window_dump.xml
Код: Выделить всё
$ adb shell uiautomator dump /sdcard/ui.xml
$ adb pull /sdcard/ui.xml .
/sdcard/ui.xml: 1 file pulled. 2.1 MB/s (48211 bytes in 0.022s)
Код: Выделить всё
$ adb exec-out uiautomator dump /dev/tty
Код: Выделить всё
<node index="0" text="Войти" resource-id="ru.example.app:id/btn_login"
class="android.widget.Button" package="ru.example.app"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" scrollable="false"
bounds="[84,1648][996,1816]"/>
Код: Выделить всё
$ xmllint --xpath 'string(//node[@resource-id="ru.example.app:id/btn_login"]/@bounds)' ui.xml
[84,1648][996,1816]
Код: Выделить всё
tap_by_id() {
adb shell uiautomator dump /sdcard/d.xml >/dev/null
adb pull /sdcard/d.xml /tmp/d.xml >/dev/null
b=$(xmllint --xpath "string(//node[@resource-id='$1']/@bounds)" /tmp/d.xml)
read x1 y1 x2 y2 <<< "$(echo "$b" | tr -d '[]' | tr ',' ' ')"
adb shell input tap $(( (x1+x2)/2 )) $(( (y1+y2)/2 ))
}
tap_by_id "ru.example.app:id/btn_login"
Код: Выделить всё
ERROR: could not get idle state.Код: Выделить всё
java.lang.IllegalStateException: UiAutomationService ... already registered!Monkey: стресс-тест:
Monkey - генератор псевдослучайных событий, живет прямо на устройстве (com.android.commands.monkey). Минимальный запуск:
Код: Выделить всё
$ adb shell monkey -p ru.example.app -v 500
:Monkey: seed=1718012345 count=500
:AllowPackage: ru.example.app
:Sending Touch (ACTION_DOWN): 0:(517.0,1228.0)
...
Events injected: 500
// Monkey finished
Код: Выделить всё
$ adb shell monkey -p ru.example.app -s 42 --throttle 300 \
--pct-touch 60 --pct-motion 25 --pct-appswitch 5 --pct-syskeys 0 \
--ignore-timeouts --monitor-native-crashes -v -v 5000
Код: Выделить всё
// CRASH: ru.example.app (pid 8412)
// Short Msg: java.lang.NullPointerException
// Long Msg: java.lang.NullPointerException: Attempt to invoke virtual method ...
Код: Выделить всё
$ adb shell 'kill $(pidof com.android.commands.monkey)'
MonkeyRunner, не путать с monkey. Это хостовый инструмент из старого пакета tools Android SDK: скрипты на Jython 2.5 (синтаксис Python 2), управление через ADB. Google его давно не развивает, пакет tools убран из SDK Manager, для нового кода берите uiautomator2, Appium или Maestro. Но в легаси встречается:
Код: Выделить всё
# legacy_smoke.py, синтаксис Python 2
from com.android.monkeyrunner import MonkeyRunner, MonkeyDevice
device = MonkeyRunner.waitForConnection(15)
device.installPackage('build/app-debug.apk')
device.startActivity(component='ru.example.app/.MainActivity')
MonkeyRunner.sleep(3)
device.press('KEYCODE_BACK', MonkeyDevice.DOWN_AND_UP)
device.takeSnapshot().writeToFile('smoke.png', 'png')
Код: Выделить всё
$ monkeyrunner legacy_smoke.py
Все три стоят на плечах ADB, просто прячут его. Appium с драйвером uiautomator2 при старте сессии делает то, что вы умеете из глав 7 и 19: ставит на устройство служебные APK и пробрасывает порт.
Код: Выделить всё
$ adb shell pm list packages | grep appium
package:io.appium.settings
package:io.appium.uiautomator2.server
package:io.appium.uiautomator2.server.test
$ adb forward --list
RF8N31XXXXX tcp:8200 tcp:6790
Код: Выделить всё
adb shell am instrument -w io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner
Espresso исполняется внутри процесса приложения, но запускается все равно через ADB:
Код: Выделить всё
$ adb shell am instrument -w -r \
-e class ru.example.app.LoginTest \
ru.example.app.test/androidx.test.runner.AndroidJUnitRunner
INSTRUMENTATION_STATUS: class=ru.example.app.LoginTest
INSTRUMENTATION_STATUS: numtests=3
...
OK (3 tests)
Код: Выделить всё
adb shell pm list instrumentationMaestro описывает сценарии в YAML и сам ходит к устройству через adb: при первом запуске ставит свой драйвер (instrumentation на базе UiAutomator) и пробрасывает gRPC-порт через adb forward.
Код: Выделить всё
# login_flow.yaml
appId: ru.example.app
---
- launchApp
- tapOn: "Войти"
- inputText: "qa@example.ru"
- tapOn: "Продолжить"
- assertVisible: "Профиль"
Код: Выделить всё
$ maestro test login_flow.yaml
Running on RF8N31XXXXX
> Flow: login_flow
[Passed] Launch app "ru.example.app"
[Passed] Tap on "Войти"
[Passed] Assert visible "Профиль"
CI/CD с реальными устройствами:
Эмуляторы в облаке хороши (глава 21), но камеру, NFC, push на китайских прошивках и реальный тепловой тротлинг они не покрывают. Реальные устройства вешают на self-hosted агент. Подготовка устройства, один раз:
Код: Выделить всё
$ adb shell settings put global window_animation_scale 0
$ adb shell settings put global transition_animation_scale 0
$ adb shell settings put global animator_duration_scale 0
$ adb shell svc power stayon usb
GitHub Actions, self-hosted runner:
Код: Выделить всё
name: e2e-real-device
on: [pull_request]
jobs:
e2e:
runs-on: [self-hosted, android-lab]
env:
ANDROID_SERIAL: RF8N31XXXXX
steps:
- uses: actions/checkout@v4
- name: Device health check
run: |
adb start-server
adb devices -l
adb wait-for-device
[ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = "1" ]
- name: Clean state
run: |
adb logcat -c
adb uninstall ru.example.app || true
- name: Run instrumented tests
run: ./gradlew connectedDebugAndroidTest
- name: Collect logs on failure
if: failure()
run: adb logcat -d > logcat.txt
- uses: actions/upload-artifact@v4
if: failure()
with:
name: device-logs
path: logcat.txt
Jenkins, декларативный pipeline:
Код: Выделить всё
pipeline {
agent { label 'android-lab' }
environment { ANDROID_SERIAL = 'RF8N31XXXXX' }
options { lock(resource: 'pixel-8-rack-2') }
stages {
stage('Prepare') {
steps { sh 'adb logcat -c && adb shell pm clear ru.example.app || true' }
}
stage('E2E') {
steps { sh './gradlew connectedDebugAndroidTest' }
}
}
post {
failure {
sh 'adb logcat -d > logcat.txt || true'
sh 'adb bugreport bugreport.zip || true'
archiveArtifacts artifacts: 'logcat.txt, bugreport.zip', allowEmptyArchive: true
}
}
}
Грабли CI, проверено на себе. Никогда не пишите adb kill-server в шагах джобы: adb-сервер на агенте один на всех, и вы убьете чужой прогон. USB-хабы без внешнего питания теряют устройства под нагрузкой, берите активные. Телефоны, годами висящие на 100% заряда, вздуваются; нужны хабы с управлением питанием или ограничение заряда в прошивке. На Xiaomi/HyperOS отладка по USB иногда отключается после OTA, поэтому health check в начале джобы обязателен.
Если своя стойка не нужна, есть облачные фермы: Firebase Test Lab (gcloud firebase test android run), BrowserStack, AWS Device Farm. Для локальной фермы из собственных телефонов смотрите DeviceFarmer (бывший OpenSTF), это веб-доступ к устройствам поверх все того же ADB. С учетом проблем с оплатой зарубежных сервисов из СНГ стойка из десятка бэушных Xiaomi плюс DeviceFarmer закрывает большинство задач.
В главе 25 поговорим про обратную сторону: что бывает, когда CI-агент с авторизованными ADB-ключами достается не тем людям, и как этого не допустить.