做移动端自动化测试的同学,大概率都经历过这种尴尬:模拟器上跑得好好的用例,一上真机就翻车;或者为了验证一个功能,IT 那边临时借来一筐真机,插上数据线手动跑半天。我这边团队之前也在这个坑里耗了很久,后来才慢慢趟出一条相对靠谱的路子——不搞“模拟器替代真机”,也不搞“所有用例真机全量跑”,而是把真机和模拟器放在同一个自动化体系里,按设备特性分配用例,协同完成从冒烟到回归的整个流程。这篇文章就聊聊这套协同方案怎么落地,涉及设备管理、用例打标、调度策略、CI 集成,还有一堆踩过的坑。
1. 为什么“协同”而不是“二选一”
1.1 模拟器的优势与硬伤
模拟器在自动化测试里最大的价值就一个字:快。我在本地起一个雷电模拟器实例,从启动到进入桌面不超过半分钟,开三个多开实例并行跑用例,也才占一台 16G 内存的机器。模拟器还支持直接通过adb connect 127.0.0.1:端口号连接到设备池,不像真机那样要跟 USB 口和驱动纠缠,远程机器也能轻松接入。对 CI 环境来说,这几乎是唯一可行的方案,因为机房里的机器不可能给你配一排手机当“测试外设”。
但模拟器的硬伤也很明显,尤其这几年做业务,遇到的坑越来越多:
- 定位、陀螺仪、重力感应这类传感器 API,模拟器要么不支持,要么给的数据是假的。你没法在模拟器上验证“用户走到某个地理围栏附近触发弹窗”这种真实场景。
- 微信、支付宝等超级 App 的登录态,在模拟器上经常被识别为风险环境,有些业务按钮压根不出现在界面上,自动化脚本直接迷路。
- 性能数据和真实设备差距很大,模拟器上 1 秒渲染完的画面,真机上可能发呆 3 秒,导致等待超时。
- 系统版本碎片化不够。你可以在云端租到 Android 8 到 Android 14 的真机,但模拟器通常只提供最新的一两个系统镜像,老版本兼容性问题根本覆盖不了。
1.2 真机不可替代的场景
真机的不可替代性,主要体现在“模拟器给了假答案”的场景。比如常见的小程序自动化,开发者工具里明明能跑通的逻辑,一到真机就报net::ERR_CONNECTION_RESET;再比如地图类功能,开发工具直接提示“movetomap location: fail 开发者工具暂时不支持此 API 调试,请使用真机”。这些场景不是脚本写得不好,而是环境本身就限制了能力,再怎么写也没用。
我的做法是把业务用例先按“是否依赖真机能力”分个类:纯 UI 操作、表单校验、列表加载这类用例,模拟器完全够用;但凡是涉及定位、扫码、推送、指纹、第三方支付、弱网、系统权限弹窗交互的用例,一律拉到真机池跑。这么做不是因为模拟器跑不过,而是模拟器跑了也没意义,报“通过”的结果只会给人虚假的安全感。
1.3 协同方案的核心策略
方案的核心不复杂,就是把设备视为“可调度的资源”,而不是把用例绑定在某一台设备上。我在 pytest 框架里实现了三层机制:
- 用例打标:用
@pytest.mark.simulator_only、@pytest.mark.real_only、@pytest.mark.both区分设备偏好。 - 设备注册:把所有可用设备(包括模拟器实例和真机)登记到一个简单的设备池,脚本启动时拉取设备列表和当前状态。
- 调度分流:根据用例标签,按策略把任务发到对应的设备组执行。
这样组合下来,日常提交代码后,先在模拟器组快速跑一遍冒烟,花费不到 5 分钟;代码合入后,再在真机池上跑完整回归。两套环境共用一套用例和维护体系,按设备和用例特性分流,而不是“两套环境两套脚本”互相打架。
2. 整体架构与工具选型
2.1 主框架选择:Appium 还是 Airtest
移动端自动化框架市面上数得出来的不少,但真正适合搭建协同方案的,我这里主要推荐两条路线:Appium 和 Airtest。两者定位不同,选择关键看场景。
我在早期团队用 Appium 比较多,因为它基于 WebDriver 协议,语法对做过 Web 自动化的人很友好,跨 App、跨平台能力强,Android 和 iOS 都能覆盖。缺点是环境配置重,依赖多,而且定位元素在某些自定义控件上不太稳定。
后来接手了一个游戏业务的项目,Appium 就力不从心了,因为游戏里大多是自绘 UI,没有原生控件树可查,这时候 Airtest 就派上了用场。Airtest 的强项是图像识别 + 少量 UI 结构识别,直接截图找图,逻辑简单,上手快,而且对模拟器和真机的适配都做得不错。
| 对比项 | Appium | Airtest |
|---|---|---|
| 定位方式 | 原生控件树为主 | 图像识别 + 控件识别 |
| 适用场景 | 原生 App、跨平台 UI 自动化 | 游戏、自绘 UI、混合应用 |
| 环境依赖 | 较重,需要 Node、Java、Appium Server | 轻量,Python 环境即可 |
| 多设备支持 | 通过 Appium Grid 支持较好 | 通过多开脚本支持较好 |
| 学习成本 | 中高 | 较低 |
我在实际项目里的建议是:如果你维护的是普通 App 或小程序,优先 Appium;如果你的业务大量依赖自定义绘制、或者 UI 频繁换皮导致控件定位不稳定,Airtest 可能更省心。协同方案本身并不绑定特定框架,只要框架能支持多设备并发调用即可。
2.2 设备管理与调度层
设备管理是协同方案的“地基”,没有这一步,后面的分流和并发都是空中楼阁。我的做法是维护一个轻量的设备注册中心,本质上就是一个 JSON 配置文件加一个定时扫描脚本:
{ "devices": [ { "name": "雷电模拟器-1", "type": "emulator", "address": "127.0.0.1:7555", "capabilities": ["basic_ui", "form"], "status": "online" }, { "name": "小米10-真机-01", "type": "real_device", "address": "192.168.1.101:5555", "capabilities": ["basic_ui", "location", "push"], "status": "online" } ] }调度层不用做得太重,我直接用了自研的调度脚本外加一个 Redis 队列:用例跑起来后,调度器根据用例标签去设备注册中心查当前可用的设备组,然后把任务投递到队列;每台设备空闲了就取下一条任务执行。相比直接套用现成的 Selenium Grid,这套轻量方案带来的额外代码量不大,但胜在可控性强,后续要加“按区域分配设备”或“设备故障自动隔离”都方便。
2.3 用例组织与打标
用例组织是整个协同方案里最容易被忽视、但影响最大的环节。我在项目里用了 pytest 的标签机制,把用例按设备能力拆成三个层级:
- 冒烟标签(
smoke):核心的登录、首页加载、关键按钮点击,这些用例必须在 5 分钟内跑完,全部可以放在模拟器上。 - 主流程标签(
core_flow):支付、下单、消息推送等核心业务链路,需要真机环境验证,放在真机池跑。 - 回归标签(
full_regression):覆盖所有功能模块,模拟器和真机各占一部分,通过数据驱动参数区分设备偏好。
具体到用例代码,加上装饰器就完事了:
import pytest @pytest.mark.smoke @pytest.mark.simulator_only def test_login_success(): """登录流程冒烟测试""" ... @pytest.mark.core_flow @pytest.mark.real_only def test_pay_with_fingerprint(): """指纹支付——只能在真机验证""" ... @pytest.mark.full_regression @pytest.mark.both def test_order_list_pagination(): """订单列表分页,真机模拟器都跑""" ...这样打标之后,CI 里执行命令就非常清晰,后面细说。
3. 环境准备与设备接入实操
3.1 模拟器集群的配置细节
模拟器接入自动化,首要问题是端口和 adb 连接。以雷电模拟器为例,它默认的 adb 端口是 5555,但如果你开了多开实例,每个新实例的端口会递增(比如 5557、5559)。连接命令很简单:
adb connect 127.0.0.1:7555 adb devices -l但有几个细节需要提前处理,否则后面跑起来很头痛:
- 固定模拟器分辨率:不要用默认的 1280x720 这种带虚拟按键的分辨率,建议固定为 1080x1920 或 1440x2560,并且关闭虚拟导航栏。UI 自动化最忌讳的就是设备分辨率不一致,同一行文字在不同屏幕密度下换行位置不同,元素定位直接漂移。
- 开启 root 权限:模拟器上建议开启 root,这样可以配合 adb 命令去修改系统设置、清后台、模拟弱网等。真机上这些操作受限制,模拟器反倒方便。
- 关闭窗口动画:通过设置里的“开发者选项”,把窗口动画缩放、过渡动画缩放、动画程序时长缩放全部调成关闭状态。否则每次点击操作都带有几百毫秒的动画,脚本要么等待超时,要么因为动画打断了后续操作而报错。
- 多开实例的 adb 连接:雷电提供一个
ldconsole命令行工具,可以列出所有实例和它们的运行状态,我通常在 CI 机器上通过ldconsole启动指定数量的实例,再逐个adb connect,保证设备池就绪。
3.2 真机设备接入与清理
真机接入的麻烦程度比模拟器高一个量级,但只要把流程标准化,也能做到“插上就能跑”。
我这边真机池的设备是通过 WiFi 连接的,减少布线复杂度。每台手机提前打开“开发者选项”里的“USB 调试”和“WiFi 调试”(或通过 USB 先执行adb tcpip 5555,再拔线通过adb connect连接)。连接之后的注意事项:
- 设备命名要规范:我会在设备注册中心用“品牌-型号-用途-编号”方式命名,比如
小米-10-真机回归-01,避免用设备序列号这种无意义的 ID 去辨认。 - 清理脚本需要沉淀:真机跑完一轮用例后,必须恢复初始状态。我的清理脚本做三件事:卸载安装的测试包、清除应用数据、恢复系统设置(比如关闭飞行模式、重置权限弹窗状态)。如果不清理,下一轮用例会因为上一轮留下的弹窗、登录态、通知消息而失败。
- 测试账号隔离:真机上如果复用同一个测试账号,很容易出现“其中一台设备把另一台挤下线”的情况,尤其是微信登录场景。我在方案里给每台设备分配独立的账号,并把账号信息维护在配置中心,保证用例幂等。
3.3 统一驱动的封装
为了让用例不关心底层跑在模拟器还是真机上,我封装了一层DeviceDriver,把启动、点击、输入、截图等操作统一收口:
class DeviceDriver: def __init__(self, device_info): self.address = device_info["address"] self.type = device_info["type"] self.appium = None # 实际初始化时根据框架选择 def start_app(self, package, activity): # 启动应用 pass def click(self, locator): # 点击元素或坐标 pass def screenshot(self, save_path): # 截图,用于失败归档 pass def get_device_log(self): # 抓取 logcat 日志,方便排查崩溃 pass用例层的代码不需要关心self.device到底是模拟器还是真机,因为 DeviceDriver 内部已经处理了差异。这个封装踩了不少坑才逐渐稳定,比如有的模拟器上点击坐标需要乘一个分辨率缩放系数,真机则不需要;这些差异在驱动层消化掉,不污染业务用例。
4. 核心协同流程:从开发到发布的自动化
4.1 用例分层与调度规则实战
调度规则定下来之后,整个流程就变得机械而可靠。我在 CI 流水线里配置了三个阶段的自动化测试任务:
- 提交触发(MR / PR 触发):只跑
smoke且simulator_only的用例。这些用例数量控制在 50 条以内,全部丢给模拟器池,目标是在 5 分钟内给出“核心功能是否被破坏”的初步结论。 - 合入触发(Merge 到主分支):跑
core_flow用例,全部丢给真机池。这里的关键业务链路需要真实设备能力,耗时通常 20 到 30 分钟。 - 每日夜间回归:执行
full_regression全部用例,按用例标签自动分配给模拟器和真机。模拟器组负责覆盖率和并发速度,真机组负责关键链路和真机专项能力。
调度命令大概长这样:
# 模拟器冒烟 pytest -m "smoke and simulator_only" --device-pool emulator \ --alluredir=./reports/smoke # 真机核心链路 pytest -m "core_flow and real_only" --device-pool real_device \ --alluredir=./reports/core # 夜间全量回归 pytest -m "full_regression and (simulator_only or real_only or both)" \ --device-pool all --alluredir=./reports/full这套调度方式的收益是“弹性”:白天提交多,模拟器池可以轻松扩容到 10 个实例并行,每次跑到峰值的消耗也不大;夜间回归真机不足时,还能把部分低风险用例临时降级到模拟器,用“覆盖率换时间”。
4.2 结果回传与报告聚合
设备多了,结果聚合就容易乱。我在方案里规定:每台设备执行完用例后,统一输出 JUnit XML 和 Allure 原始数据,然后由报告服务做聚合。
针对失败用例,我会自动收集三件套:
- 失败瞬间的设备截图
- 设备端 logcat 日志(最近 200 行)
- 应用崩溃堆栈(如果检测到 crash)
这三样数据会随失败记录一并上传,测试人员打开报告就能直接定位,不需要去翻设备日志。实践下来,“失败现场信息是否完整”决定了排查一个 bug 的时长,之前未做采集时排查一个偶现问题要半小时,现在基本控制在 5 分钟以内。
4.3 与 CI/CD 集成
协同方案要真正“跑起来”而不是躺在本地脚本里,必须接到 CI 流水线。我这边用的 GitLab CI,pipeline 大概是这样:
stages: - smoke_test - real_device_test - full_regression smoke_test: stage: smoke_test script: - python run.py --mode smoke tags: - emulator-runner real_device_test: stage: real_device_test script: - python run.py --mode real tags: - real-device-runner full_regression: stage: full_regression script: - python run.py --mode full tags: - emulator-runner - real-device-runner only: - schedules有几个 CI 集成时的细节值得提醒:
- CI 机器上要常驻一个“设备巡检服务”,每 5 分钟检查模拟器进程是否存活、真机 adb 是否在线。设备掉线后要自动恢复,该重启的重启,该重新连接的重新连接。
- 失败率监控很重要。我设置了阈值:模拟器组失败率高于 10%,真机组失败率高于 5%,就自动阻塞发布流程。这个比例不是拍脑袋定的,而是根据历史三个月数据算出来的 99 分位值,避免偶尔的设备抖动阻塞上线。
- CI 流水线里的测试任务尽量做成可重跑的。偶发的网络超时、App 闪退导致的失败,不要拒绝重试。我这边对“可重试类型的失败”最多重试 2 次,减少人工介入。
5. 高频问题与排查实录
5.1 模拟器不支持特定 API,怎么提前发现
文章开头提到的地图类 API 报错很典型:开发工具里调用.movetomap location,直接提示“开发者工具暂时不支持此 API 调试,请使用真机”。这类问题如果在冒烟阶段才暴露,其实已经晚了。我的做法是建立“环境能力探测”预检查:
在每轮测试开始前,脚本会先向设备发送一组环境探测用例,包括:
- 是否能调用定位接口
- 是否支持指纹/人脸识别
- 是否存在微信登录风险弹窗
探测结果写入设备的能力档案,调度器分配用例时直接过滤掉“不支持的能力”。这样就不会出现用例跑了半天,最后才发现设备根本不支持该 API 的尴尬。
5.2 小程序真机调试连接失败,常见原因
net::ERR_CONNECTION_RESET这个报错,做小程序自动化的同学应该都烦过。真机调试时经常是首次连接成功,第二次就连不上,报连接被重置。排查下来主要原因有两个:
- 开发者工具和手机端的调试基础库版本不匹配。工具升级后,手机端微信小程序调试组件需要同步更新,否则旧版本无法建立长连接。
- 本地代理冲突。开发者工具默认走系统代理,如果 CI 机器上代理配置不合规(比如挂了公司代理),请求会被重置。
解决办法:统一固定开发者工具版本,不要随便升;CI 机器上关闭系统代理或设置白名单,确保servicewechat.com和相关调试域名可以直连。
另外还有一个常见原因:真机上“不是小程序开发者”无法开启调试权限。处理方式是在小程序后台把相关测试微信号加到开发者白名单,不然代码里写再多 debug 配置也白搭。
5.3 非预期弹窗导致的用例失败
自动化测试最大的敌人不是功能 bug,而是“非预期弹窗”。新版用户协议弹窗、积分红包弹窗、App 更新提示弹窗、系统权限弹窗……任意一个,都能让精心写好的定位直接失效。
我的解法分三层:
- 第一层:设备层面提前处理。通过 adb 命令预设“允许所有权限”,同时关闭 App 的新版本检测提示。
- 第二层:脚本里加“弹窗雷达”。在点击任何元素前,先快速检测屏幕上是否存在已知弹窗特征(按钮文案、控件 ID),存在就优先关闭。
- 第三层:重试机制。如果弹窗雷达也没有覆盖到新弹窗,用例失败后自动点击设备“返回键”或“Home 键”并重试一次。
这三层叠加之后,弹窗导致偶发失败的比例从 20% 降到了 3% 左右,剩余的 3% 基本是新弹窗首次出现,人工补录一次即可。
5.4 设备掉线、端口冲突与账号互踢
设备多了,“掉线”是家常便饭。模拟器用久了内存暴涨,直接卡死;真机 WiFi 信号不好,adb 连接断开。我的处理策略:
- 超时重连:设备池巡检脚本发现设备失联超过 30 秒,自动执行
adb connect重新接入,失败则重启模拟器或提示人工检查真机。 - 端口冲突:多台模拟器同时连接时,分给每台实例固定的 adb 端口,并在注册中心做好端口占用记录,避免冲突。
- 账号互踢:微信登录类用例分开设备账号,配置中心维护每台设备的账号清单,一条用例用完后强制退出登录并清理 token,防止设备间的状态互相污染。
5.5 结果稳定性
最后补充一个容易被忽略的点:用例的稳定性依赖“等待策略”,而不是无脑sleep。之前团队有人图省事,在每个操作后都加 3 秒固定等待,结果用例执行时间翻倍,而且一上真机就超时。
我现在强制使用显式等待:元素出现等 5 秒,不存在再抛超时;页面跳转等待目标 Activity 出现;网络请求等待接口返回。这一步看起来不起眼,实际上对真机模拟器混合跑的场景至关重要,因为真机和模拟器的响应速度差异很大,固定等待要么模拟器上浪费时间,要么真机上等待不足。
6. 协同方案的长期演进
方案跑通之后,后面可以持续投入的方向,我这里给出几个明确的建议。
6.1 从“设备池”到“云真机”
自建真机池的物理瓶颈迟早会遇到:设备数量有限、硬件维护成本高、机型覆盖不全。不少团队会选择接入云真机服务,把部分用例调度到云端设备执行。协同方案里的设备注册和调度层其实天然支持这种扩展——把云设备也当作“远程 device”注册进池子即可。我建议先统计本地设备池的利用率,如果超过 80%,再考虑引入云真机作为弹性资源池,不要一上来就全量迁移。
6.2 引入 AI 辅助定位
传统控件定位在 UI 频繁改版时维护成本很高,近几年 AI 辅助测试的实践也越来越多。我在方案里留了一个“第二定位通道”:当控件定位失败时,尝试通过 Airtest 的图像识别找元素位置;两者都失败才报错。这样改版后即使原生控件属性变化,只要界面元素长得以往相似,用例仍然能继续跑。这个配合在模拟器和真机上都表现不错,但要注意图片素材需要针对不同分辨率分别准备,否则识别率会掉。
6.3 数据构造与状态管理
测试用例的稳定性,很大程度上取决于数据是否干净。我之前踩过最大的坑就是“共用测试环境”,A 用例改了一条订单状态,B 用例依赖的数据就找不到了。后来我在方案里加入了“测试数据即代码”的概念:每条用例开始时,通过接口或数据库脚本独立构造所需数据;用例结束后清理。这样即使模拟器和真机并行跑同一套用例,也不会互相干扰。
6.4 环境巡检与健康分
给每台设备算一个“健康分”是一个值得尝试的小创新。我这边每台设备在跑完一轮用例后,会根据用例通过率、执行时长、CPU 和内存占用,给设备打分。低于 85 分的设备自动从调度池中下线,减少带病设备对结果的影响。这个方法实施后的一个季度,整体用例通过率提升了 12%,本质上就是“不让一台状态异常的机器拖垮整个测试信号”。
最后分享一个我在日常维护里最大的体会:真机和模拟器的协同,本质上不是技术选型问题,而是测试资源调度问题。不要试图让某一种设备覆盖所有场景,而是明确各自的适用范围,然后用同一套用例基线把它们串起来。做顺了以后,开发提测到冒烟反馈的时间能压到 5 分钟以内,回归跑得稳稳当当,测试人员也能从“手动点手机”里解放出来,把时间花在真正需要人工判断的探索性测试上。