ADB自动化测试
一个自动化脚本通常从“运行 adb shell”开始,却经常在三处失去可解释性:连接到了错误的设备, 把“命令失败”与“transport 断开”混成同一个错误,或者超时后留下了仍在运行的远端进程。可靠的 自动化不是把更多命令串起来,而是把一次测试拆成可观测的状态转换:选择设备、等待状态、执行请求、 读取 stdout/stderr/exit、保存失败上下文、逆序清理。
本文面向已经读过 ADB架构、ADB shell机制 和 dumpsys诊断 的读者。你需要理解 transport、shell: service、 shell protocol 和 Binder dump 的基本边界;不要求预先使用 pytest、JUnit 或设备农场。本文不展开 UIAutomator/Espresso、JUnit runner 和设备租约系统,只解释 ADB 层如何让脚本获得可靠输入,并给出 一个可改造的 Python runner。读完后,你应能判断失败发生在选择、等待、命令、断言还是清理阶段。
1. 连接断言
1.1 adb_commandline
Android 17 的 adb_commandline() 先解析 -d、-e、-s、-t 等参数;若没有显式选择,才读取 ANDROID_SERIAL,最后把 selector 交给 adb_set_transport()。-s SERIAL 选择序列号,-t ID 选择当前 ADB server 中的 transport id,-d 选择 USB,-e 选择本地 transport。
源码文件:packages/modules/adb/client/commandline.cpp
if (transport_type == kTransportAny && serial == nullptr) {
serial = getenv("ANDROID_SERIAL");
}
adb_set_transport(transport_type, serial, transport_id);| 选择方式 | owner | 适用场景 | 失败边界 |
|---|---|---|---|
adb -s SERIAL | server 的 serial 映射 | 固定物理设备或模拟器 | serial 不存在或状态不可用 |
adb -t ID | 当前 server 的 transport 记录 | 固定一次已建立连接 | transport 重连后 id 失效 |
ANDROID_SERIAL | client 环境 | 单设备脚本的默认目标 | 被命令行 -s 覆盖 |
-d / -e | transport 类型过滤 | 只跑 USB 或 emulator | 多个候选仍可能歧义 |
自动化应在第一个业务步骤前记录 adb get-serialno、adb get-state 和 adb devices -l。这些值是 后续判断“设备换了”还是“测试失败”的输入,不是装饰性日志。
1.2 serial状态
serial 描述设备身份,transport id 描述 ADB server 当前持有的一次 transport。设备重启或断线重连后, serial 可能不变而 transport id 改变。因此长时间运行的 runner 可以用 serial 重新选择设备,但不能 把旧 transport id 当作永久身份。
server socket 也属于隔离边界。并行运行多个 ADB server 时,应显式设置 -P PORT 或 ADB_SERVER_SOCKET,否则一个测试可能看到另一个测试的设备列表、forward 和连接状态。
2. 等待设备
2.1 设备路由
当首个参数匹配 wait-for- 时,adb_commandline() 调用 wait_for_device()。它把 wait-for-device、wait-for-disconnect 等补全为 host service,再通过 ADB server 等待 transport 状态;它不是在 shell 中循环解析 adb devices 输出。
源码文件:packages/modules/adb/client/commandline.cpp
if (!strncmp(argv[0], "wait-for-", strlen("wait-for-"))) {
const char* service = argv[0];
if (!wait_for_device(service)) return 1;
if (argc == 1) return 0;
--argc;
++argv;
}adb wait-for-device shell ... 会先等待,再让同一个 client 继续执行后续命令;adb devices 只是 当前快照,没有“之后何时进入可执行状态”的同步语义。send_shell_command() 在设备不可达时也会 等待并重连,但调用方仍需持有总 deadline。
2.2 设备状态
wait-for-device 证明 ADB transport 已进入相应状态,不证明 Android 已完成 boot、PackageManager 已扫描结束或 System UI 已可交互。需要系统服务的测试应在其后增加业务就绪条件,例如:
adb -s SERIAL wait-for-device
adb -s SERIAL shell 'while [ "$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done'
adb -s SERIAL shell cmd package list packages >/dev/null循环也必须受 host deadline 控制;设备属性永远不变化时,远端循环不会自行退出。
3. 结果约束
3.1 进程输出
设备支持 shell_v2 时,read_and_dump_protocol() 按 packet id 处理 stdout、stderr 和 exit。 若连接意外关闭且没有 exit packet,client 默认返回 255;本地输出消费者提前关闭时则可能得到 SIGPIPE + 128。这些结果不能简化为“命令输出为空”。
| 结果 | 来源 | runner 解释 |
|---|---|---|
| exit=0/非零 | 远端 kIdExit | 命令自身成功或失败 |
| stderr 有内容 | kIdStderr | 诊断文本,不自动等于失败 |
| exit=255 且缺少 exit packet | 异常断开 | transport 或进程生命周期问题 |
| local SIGPIPE | host 输出 callback | 消费者提前关闭,不应重试业务动作 |
源码文件:packages/modules/adb/client/commandline.cpp
} else if (protocol->id() == ShellProtocol::kIdExit) {
exit_code = static_cast<uint8_t>(protocol->data()[0]);
}ADB 的设备测试直接验证这些边界:test_shell_nocheck_failure 用 false 断言 rc 非零; test_pty_logic 用 stdout、stderr 和 exit 17 断言三路结果;关闭 shell protocol 的 -x 路径则 观察到 stderr 合并到 stdout,并且不能取得远端 exit packet。
3.2 退出码采集
adb shell 'cmd; echo $?' 会让业务输出和状态输出共享 stdout。目标命令自己输出数字、没有换行或 修改 shell 控制流时,解析器可能误判。shell protocol 已提供独立 exit packet,runner 应直接消费 本地 adb 进程返回码,并原样保存 stdout/stderr。
4. deadline/重试
4.1 Deadline
ADB 的等待和远端命令都可能长时间阻塞。下面的 host helper 创建独立进程组,在超时后先 TERM, 短暂等待后再 KILL,并保留已经收到的输出:
源码文件:packages/modules/adb/test_device.py
import os
import signal
import subprocess
def run_with_deadline(argv, timeout, env=None):
proc = subprocess.Popen(
argv,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
text=True,
)
try:
out, err = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGTERM)
try:
out, err = proc.communicate(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
out, err = proc.communicate()
raise TimeoutError((argv, out, err))
return proc.returncode, out, err进程组清理只能保证 host 侧 adb 子进程被终止。远端进程是否收到 SIGINT、SIGHUP 或 EOF,取决于 shell 模式和连接关闭路径;测试若创建了明确 pid,应在取消后用只读查询确认它是否仍存在。
4.2 重试边界
连接建立、get-state、dumpsys 等只读动作可以在有界次数内重试。pm install、am start、 wm size 和文件写入可能已经生效,即使 host 超时也不能盲目重放。正确顺序是先查询 owner 状态, 再决定继续等待、恢复或重新执行。
| 失败阶段 | 自动重试 | 原因 |
|---|---|---|
| selector 找不到设备 | 否 | 配置错误不会因快速重试消失 |
| transport 短暂 offline | 有界重试 | 连接尚未建立业务请求 |
| 只读 dump 超时 | 可缩小范围后重试 | 不改变系统状态 |
| 写命令返回前断开 | 先查询状态 | 请求可能已经提交 |
| 断言失败 | 否 | 应收集证据,不应改变现场 |
5. 测试状态机
5.1 am start
一个步骤至少有命令、selector、deadline、断言和清理函数。命令返回后,断言读取的是实际状态 owner; 例如 am start 返回不代表首帧已显示,应再查询 Activity/Window 状态。
| 阶段 | runner 保存 | 状态 owner | 结束条件 |
|---|---|---|---|
| prepare | serial、server socket | ADB server selector | transport 唯一且可用 |
| execute | stdout/stderr/rc | adbd 与远端进程 | exit packet 或连接关闭 |
| assert | 期望值、观测值 | Android service/文件系统 | 查询返回并满足条件 |
| collect | logcat/dumpsys 路径 | 诊断服务与 host 文件 | 工件命令有界结束 |
| cleanup | 逆序动作及结果 | 临时文件、进程、forward | 每项成功或记录失败 |
5.2 清理约束
若测试依次创建临时目录、安装包、启动 Activity、建立 forward,清理顺序应反过来:移除 forward、 停止 Activity/应用、卸载包、删除临时目录。清理命令要限定到本次测试创建的资源,重复执行也应安全。
6. 最小runner
6.1 步骤记录
教学示例:下面是本文构造的最小 runner,不是 AOSP 源码。
from dataclasses import dataclass
from pathlib import Path
import subprocess
import time
@dataclass
class StepResult:
name: str
returncode: int
stdout: str
stderr: str
elapsed: float
class AdbRunner:
def __init__(self, serial, artifact_dir):
self.adb = ['adb', '-s', serial]
self.artifacts = Path(artifact_dir)
self.artifacts.mkdir(parents=True, exist_ok=True)
def run(self, name, args, timeout=30, check=True):
start = time.monotonic()
proc = subprocess.run(
self.adb + args,
text=True,
capture_output=True,
timeout=timeout,
)
result = StepResult(
name, proc.returncode, proc.stdout, proc.stderr,
time.monotonic() - start,
)
(self.artifacts / f'{name}.stdout').write_text(result.stdout)
(self.artifacts / f'{name}.stderr').write_text(result.stderr)
if check and result.returncode != 0:
raise RuntimeError(result)
return result
def prepare(self):
self.run('wait', ['wait-for-device'], timeout=30)
state = self.run('state', ['get-state'], timeout=5).stdout.strip()
if state != 'device':
raise RuntimeError(f'unexpected state: {state!r}')check=True 只把非零 rc 变成异常,不丢掉 stdout/stderr;清理可以使用 check=False,但仍需保存其 结果。生产 runner 应把前面的进程组 deadline helper整合进来,因为 subprocess.run(timeout=...) 只负责等待和终止直接子进程,复杂 wrapper 仍需要明确的进程树策略。
6.2 失败工件
失败时至少保存目标 serial、get-state、devices -l、失败步骤的 stdout/stderr/rc、最近一段 logcat -d 和相关 service 的 dumpsys。设备仍可用时再收集 bugreport;设备 offline 时,不能让 bugreport 的二次失败覆盖原始错误。
教学示例:下面是在最小 runner 上补充的失败收集函数,不是 AOSP 源码。
def collect_failure(runner):
runner.run('failure-state', ['get-state'], timeout=5, check=False)
runner.run('failure-logcat', ['logcat', '-d', '-t', '200'],
timeout=10, check=False)
runner.run('failure-activity',
['shell', 'dumpsys', 'activity', 'activities'],
timeout=10, check=False)adb devices -l 是 host 级命令,不应该带 -s,实际 runner 可另建 run_host()。这个差异也说明: 每个命令都要先判断它由 host server 消费,还是需要具体 transport。
7. 测试边界
7.1 ADB验证
| 测试 | 输入 | 关键断言 | 没有证明 |
|---|---|---|---|
test_shell_nocheck_failure | false | rc 非零、stdout 为空 | transport 重连策略 |
test_pty_logic | stdout/stderr + exit 17 | 三路结果分别匹配 | 所有旧设备支持 v2 |
test_non_interactive_sigint | sleep 60 后 SIGINT | 远端 pid 在 3 秒内消失 | 已提交事务会回滚 |
test_adb.py wait case | 延迟注册 emulator | wait 后 state=device | framework 已 boot completed |
test_push_error_reporting | 写只读目录 | 权限或只读错误可见 | 目标文件总能保留 |
测试的价值不只是“有覆盖率”,而是为 runner 提供输入、断言和证明边界。把同样结构用于业务测试, 可以避免把一次偶然通过写成全局保证。
7.2 失败定位
- 立刻报多设备:检查 selector 与
devices -l,不要增加重试次数。 - 等待超时:区分设备不存在、unauthorized、offline、ADB ready 与 framework ready。
- rc 非零但连接正常:读取 stderr 和 exit,回到远端命令前置条件。
- 超时后再次执行产生重复状态:先查询 owner,确认第一次请求是否已经生效。
没有设备时,可以由 Android 17 源码确认 selector 优先级、wait service 路由、shell exit packet 和 测试断言;不能证明某台产品的 boot 时间、厂商服务就绪条件、USB 稳定性或设备农场隔离策略。
8. 运行结果
拿一个真实步骤回答五个问题:selector 是什么,它的生命周期多长;等待的是 transport 还是业务 服务;stdout、stderr、exit 和断开如何区分;断言读取哪个 owner,何时生效;失败收集和逆序清理 分别做什么。
如果这些问题无法回答,脚本仍然只是命令串。能够回答它们,才能把 ADB 的源码行为、runner 的 deadline 策略和 Android 服务的异步状态放在同一条可验证主线上。
