移动端 UI 自动化:跑用例前做一道「环境健康预检」(adb + Appium)
做移动端 UI 自动化,真正稳定跑起来之后,你会发现一大半的「用例失败」根本不是用例的锅,而是环境三件套出了岔子:
- adb 连不上 / 设备
offline - 连错了设备(多设备时连到了另一台)
- 被测 app 没装 / 装的是旧包 / 版本不对
- Appium 服务没起来
而这些环境问题报出来的错往往很晦涩:session not created、an unknown server-side error、element not found……你对着用例代码查半天,最后发现是 adb 掉线了。
解法很简单:跑整个套件之前,先做一道「健康预检」,逐项检查、fail fast,并把失败原因用人话讲清楚。 这篇就讲这道预检该查什么、怎么查、怎么封装。
命令以 Android(adb)为主;包名、设备号用占位符(
com.example.app、<udid>),按你自己的填。Appium 默认端口4723。
预检该查哪几项
按「从底层到上层」的顺序,挂在前面的会让后面全废,所以逐项卡:
1. adb 本身可用 + SDK 环境变量
adb version
echo "$ANDROID_HOME" # 没设的话很多工具会找不到 platform-tools
2. 设备在线(并处理 offline)
adb devices -l
输出里设备状态必须是 device,不能是 offline / unauthorized:
List of devices attached
emulator-5554 device product:... model:...
offline 是高频坑:adb 守护进程重启、设备休眠、USB 抖动,都会让设备短暂 offline,这时候所有命令都打不进去。一条命令自愈:
adb reconnect offline # 把 offline 的设备踢一下重连
# 还不行就重启 adb 守护进程
adb kill-server && adb start-server
3. 多设备时,锁定目标设备
机器上经常同时插着真机 + 模拟器。所有 adb 命令都带 -s <udid> 显式指定目标,否则 adb 会因为「设备不唯一」报错,或者连错机:
adb -s emulator-5554 shell ...
测试框架侧也要把目标设备固定下来(Appium capabilities 里的 udid / deviceName),别让它自己挑。
4. 被测 app 已安装 + 版本核对(很容易被忽略)
这一项专门防「装的包不对 / 没更新,白跑一整轮」——尤其是改了客户端、重新打包之后,最怕跑了半天发现手机上还是旧包。
adb -s emulator-5554 shell dumpsys package com.example.app | grep -E "versionCode|versionName"
输出:
versionCode=10234 minSdk=23 targetSdk=35
versionName=1.2.3
把 versionCode 和「你期望的那个包」对一下,不一致直接报错停下,别让用例在错的包上瞎跑。
5. Appium 服务存活
curl -s http://127.0.0.1:4723/status
健康时返回 {"value":{"ready":true,...}}。连不上就是服务没起来——先把它拉起来再说,否则 session 根本创建不了。
6.(可选)确认 app 已拉到前台
adb -s emulator-5554 shell dumpsys activity activities | grep -E "mResumedActivity|topResumedActivity"
能看到你的包名在前台,说明 app 真的起来了,而不是卡在启动页 / 被系统弹窗挡住。
封装成 pytest 预检(跑在所有用例之前)
把上面几项写成一个 session 级、autouse 的 fixture,整个套件开始前跑一次,任何一项不过就 pytest.exit,并打印人话。这样失败信息一眼能懂,不用再去翻晦涩的 Appium 报错。
import re
import subprocess
import urllib.request
import pytest
UDID = "emulator-5554"
PKG = "com.example.app"
APPIUM = "http://127.0.0.1:4723/status"
EXPECT_VERSION_CODE = None # 填上就强校验装机版本,比如 10234
def _adb(*args: str) -> str:
return subprocess.run(
["adb", "-s", UDID, *args],
capture_output=True, text=True, timeout=30,
).stdout
def _die(msg: str):
pytest.exit(f"[预检失败] {msg}", returncode=3)
@pytest.fixture(scope="session", autouse=True)
def preflight():
print("[预检] === 环境健康检查 ===")
# 1) 设备在线(带一次 offline 自愈)
devices = subprocess.run(["adb", "devices"], capture_output=True, text=True).stdout
if f"{UDID}\tdevice" not in devices:
subprocess.run(["adb", "reconnect", "offline"], capture_output=True)
import time; time.sleep(3)
devices = subprocess.run(["adb", "devices"], capture_output=True, text=True).stdout
if f"{UDID}\tdevice" not in devices:
_die(f"设备 {UDID} 不在线(offline/未连接)。先 adb reconnect 或检查模拟器/数据线。")
print(f"[预检] 设备 {UDID} 在线 ✓")
# 2) app 已安装 + 版本
pkg_info = _adb("shell", "dumpsys", "package", PKG)
if "versionCode" not in pkg_info:
_die(f"被测 app {PKG} 没装。先 adb install 安装。")
m = re.search(r"versionCode=(\d+)", pkg_info)
code = m.group(1) if m else "?"
vn = (re.search(r"versionName=(\S+)", pkg_info) or [None, "?"])[1]
print(f"[预检] {PKG} 已安装 versionCode={code} versionName={vn} ✓")
if EXPECT_VERSION_CODE and code != str(EXPECT_VERSION_CODE):
_die(f"装机版本 {code} ≠ 期望 {EXPECT_VERSION_CODE},可能没更新到最新包,先重装。")
# 3) Appium 服务存活
try:
with urllib.request.urlopen(APPIUM, timeout=5) as r:
assert r.status == 200
except Exception as e:
_die(f"Appium 服务连不上({APPIUM}):{e}。先把 appium 起来。")
print("[预检] Appium 服务存活 ✓")
print("[预检] === 通过 ===")
要点:
-
scope="session" + autouse=True:整个套件只跑一次,且自动生效,所有用例前置。 -
pytest.exit:一项不过就整轮停,而不是让上百条用例各自以晦涩的错失败一遍——省时间、省排查。 -
失败信息第一句写人话:
[预检失败] 设备不在线…,而不是把一堆 adb 原始输出甩出来。 - 版本强校验是性价比之王:改了客户端 / 重新打包后,这一条能挡住「在旧包上白跑一轮」的尴尬。
小结 / Checklist
跑移动 UI 自动化前,按这张表卡一遍环境,能挡掉一大半「假失败」:
- ✅ adb 可用、
ANDROID_HOME已设 - ✅ 目标设备在线(
device而非offline);offline 用adb reconnect/ 重启 daemon 自愈 - ✅ 多设备一律
-s <udid>锁定,别让框架自己挑 - ✅ 被测 app 已安装,且
versionCode和期望的包对得上 - ✅ Appium 服务
/status返回ready:true - ✅(可选)app 已在前台
- ✅ 全部封装成 session 级 autouse 预检,一项不过就
pytest.exit+ 人话报错
一句话:把环境问题挡在用例之前,用例失败就只剩用例自己的问题。 这道预检写一次,长期省下的排查时间远超它本身。