做自动化测试最怕听到一句话:“你这边不是全过吗?手机上一用就崩。”这个场景我遇到过太多次。模拟器上跑得干干净净的用例,一旦换到真机上,各种弹窗、网络错误、元素找不到全冒出来。后来我把真机与模拟器自动化测试协同方案落地到团队,才慢慢把这种“环境过得,用户过不得”的局面扭转过来。
这套方案说白了,不是要把所有用例在两套环境里各跑一遍,而是让它们各司其职:模拟器负责快速反馈,真机负责发布前把关,中间用一个调度层把两类设备统一管理,并把结果汇总到同一份报告里。如果你正在搭自动化测试框架,或者已经跑通了一个平台的用例但一接真机就翻车,那这篇经验应该能帮你省点弯路。
1. 先统一认知:真机和模拟器到底在测什么
1.1 为什么模拟器会给你一种“测试已通过”的错觉
模拟器本质是用软件模拟出硬件和系统环境,Android Emulator 跑在 qemu 上,iOS Simulator 其实是一个运行在 Mac 上的应用,并不是苹果官方意义上的硬件模拟器。它只模拟了大部分指令和系统调用,不可能复现所有硬件行为。很多用例在模拟器上能通过,不代表功能真的没问题,只是模拟器在“配合表演”。
举个例子,模拟器里点一个“支付成功”按钮,可能直接跳过了真实支付 SDK 的交互过程,结果返回了一个模拟成功状态;真机上却会拉起真实的收银台,走完整个支付回调链路。像定位、传感器、摄像头、NFC、推送通道、震动反馈这些能力,模拟器大多只能给一个近似值,甚至直接不给。真机上这些涉及硬件或系统服务的模块,才是自动化测试最容易翻车的地方。
还有一个容易被忽略的点是性能差异。模拟器共享宿主机 CPU 和内存,用例执行速度跟真机完全不一样。你手写一个sleep(3)等待页面转圈消失,在模拟器上正好够用,到真机上可能页面还没渲染完,直接定位失败。所以模拟器通过率虚高,本质是它帮你过滤掉了一部分“环境敏感”差异,但问题并没有消失,只是被推迟到了真机阶段。
1.2 真机独有的问题:碎片化、权限弹窗和网络差异
真机自动化真正难处理的是碎片化。Android 国产 ROM 各家都有自己的权限管理策略:小米有“授权管理”,华为有“应用启动管理”,vivo 有“后台高耗电”限制,三星、OPPO 也有各自的安全管控入口。模拟器上你一条adb shell pm grant就能解决权限问题,真机上用户第一次打开 App 可能会弹一个“是否允许访问设备信息”,脚本如果不处理这个弹窗,后面的步骤全废。
网络差异同样很致命。真机走蜂窝网络或者公司 Wi-Fi,服务端接口返回时间随机波动,有时候一个请求要等 8 秒,页面才拿到数据。而且真机上会有系统级弹窗,比如“Wi-Fi 连接需要认证”“SIM 卡已变更”“手机存储空间不足”,这些在模拟器里几乎看不见,却是真机自动化的日常。这也是“自动化测试非预期弹窗导致失败”这个搜索词出现频率居高不下的原因。
1.3 协同不等于“两台都跑一遍”
理解了两类设备的差异,就能判断:不是所有用例都需要双端跑。协同方案的核心是“分层”和“调度”。我们最终的目标是,在模拟器上快速发现大部分代码级问题,把少部分高价值用例放到真机上验证发布风险,再把两边的结果合并成同一份报告,让开发和技术负责人一眼看出“是功能坏了,还是环境抽风了”。
千万别把协同做成“同一套 case,两个 job 各跑一遍”。那样只是把问题从单台设备扩展成双倍维护成本。用例脚本要双端复用,但要明确哪些用例跑哪个环境,环境之间的差异由调度层屏蔽,脚本层只需要通过 capabilities 感知当前跑在什么平台上。
2. 设备矩阵分层:把用例分成三条跑道
2.1 第一层:模拟器跑高频冒烟与接口回归
我团队里定了一个“15 分钟冒烟集”,每次代码合并后触发。逻辑很直接:新提交的代码先在模拟器上跑登录、首页加载、列表页滑动、详情页打开、购物车操作、下单入口这些高频路径,大概 15 到 20 条用例,并发开 4 个模拟器,5 分钟内出一个结果。
为什么把冒烟放模拟器?因为反馈要快。模拟器启动快、并发成本低、不占用手机资源,适合验证“代码有没有明显编译错误、主要功能链路通不通”。接口回归也用同一层来跑,但不依赖 UI,直接用 Python requests 或 pytest 调接口,重点抓服务端返回结构和字段校验。
模拟器启动命令现在各家 CI 上都有现成例子,我常用的是这样的:
emulator -avd test_avd -no-window -no-audio -gpu swiftshader_indirect -memory 2048 -cores 2 -no-boot-anim -no-snapshot & adb wait-for-device这里有几个细节容易踩坑:-no-window能省大量渲染资源,但如果你调试脚本需要看画面,先别加;-no-snapshot可以避免上次运行留下的脏状态;内存和核心数不要给太低,否则模拟器里一个动画都能卡到超时。iOS 模拟器用xcrun simctl boot "iPhone 15"启动,再open -a Simulator调出界面,不过 CI 上一般不需要真开窗口。
2.2 第二层:真机跑核心用户路径
真机这一层我习惯叫“真机巡航组”。我的配置是两台 Android 真机加一台 iPhone,跑核心用户路径:注册登录、搜索、加购、下单、取消订单、消息推送。这组用例必须在真机上跑,因为支付、推送、真实键盘、指纹识别,模拟器根本替代不了。
其中一台 Android 真机故意保持高负载状态,塞满后台 App、开启省电模式,用来验证低内存下的页面回收和白屏问题。这种场景模拟器很难模拟出来。真机巡航组一天只跑一次,建议串行或者最多两台并行。并发开多了,手机容易过热,USB 连接会断,反而制造大量环境类失败。
真机执行时,我还要求收集 App 的启动耗时和页面首帧时间。这些数据在模拟器上没有参考价值,但在真机上能看出某次版本更新是否明显变慢。把性能数据和功能断言放在一起,比单纯“用例通过”有意义得多。
2.3 第三层:兼容性抽查和特殊机型
第三层是兼容性抽查,不进入日常流水线,放在每周固定时间或发布前跑。我会从设备池里随机抽几台机器,跑一个“兼容性清单”,覆盖页面显示、截图对比、核心功能快捷操作。重点盯老系统版本、大屏和折叠屏。Android 8 上的 WebView 渲染,iOS 13 上的权限弹窗样式,经常在这种抽查里暴露问题。
这一层我经常用 Airtest 作为补充手段。因为老系统或者定制 ROM 上,原生控件树经常取不全,用图像识别虽然慢,但能发现“我的页面右上角图标变形”“按钮被遮挡”这类显示异常。兼容性层的失败率偏高,不要让它阻塞主流水线,结果以报告形式发送给开发,标记为“待确认”,而不是直接判定为回归失败。
提示:兼容性层用例失败时,一定要把截图和系统版本信息一起附上,否则开发看到一条失败记录根本无从下手。
2.4 分层比例怎么定:从成本倒推
设备分层不是拍脑袋,要拿时间和成本倒推。我按一个常见的 120 条用例集来算:模拟器单条用例平均 30 秒,真机单条平均 70 秒。用 8 个模拟器并发跑 80 条模拟器用例,大约需要 300 秒;用 4 台真机跑剩余的 40 条核心用例,大约需要 700 秒。整体控制在 20 分钟以内,这是比较合理的目标。
| 层级 | 设备 | 典型用例 | 频率 | 预估耗时 |
|---|---|---|---|---|
| 冒烟回归 | 模拟器 xN | 新增功能冒烟、接口回归 | 每次提交 | 5-8 分钟 |
| 真机巡航 | 2 台 Android + 1 台 iPhone | 核心用户路径 | 每日集成 | 12-15 分钟 |
| 兼容抽查 | 设备池随机抽样 | 页面显示、旧系统、折叠屏 | 每周/发布前 | 20-30 分钟 |
所以模拟器和真机的用例比例,我建议是 70% 到 80% 跑模拟器,20% 到 30% 跑真机。设备多、预算足,可以加大真机比例;只有一两台真机,那就只跑最核心的支付和推送链路,别贪多。
3. 把真机和模拟器装进同一个调度池
3.1 统一设备抽象:一台手机就是一个 node
要让真机和模拟器协同工作,第一步是把它们统一成“节点”概念。不管背后是什么设备,对外暴露的都只是一个可调度资源,包含平台类型、系统版本、设备名称、当前状态和最大会话数。
我团队一开始用的方案是 Selenium Grid 4 来注册 Appium 节点。每个模拟器或真机通过 nodeconfig.json 注册到同一个 Grid,业务脚本只传 capabilities,由 Grid 决定分配哪台设备。这样出来的好处非常明显:脚本里没有任何真实设备 id,以后扩容和换设备都不用改代码。
用 Selenium Grid 4 时,Appium 服务要先注册节点,nodeconfig.json 里大致要描述:
{ "capabilities": [ { "platformName": "android", "appium:udid": "emulator-5554", "appium:automationName": "UIAutomator2", "maxInstances": 1 } ], "configuration": { "hubUrl": "http://localhost:4444", "nodeStatusCheckTimeout": 5000 } }然后启动 Appium 并指定该配置。业务脚本里从环境变量读取DEVICE_UDID,实际执行时由调度层注入。这样真机、模拟器、云真机都只是一个 node,对用例来说是透明的。
3.2 模拟器的启动与连接命令细节
Android 第三方模拟器,像夜神、MuMu、雷电,本质上都是改过的 qemu,它们通过固定的 adb 端口暴露给宿主机。夜神默认是 62001,MuMu 常见是 7555,雷电是 5555 或 5557。每个版本可能不一样,连接前先看模拟器设置里的端口号。
连接命令很简单:
adb connect 127.0.0.1:62001 adb devices如果返回 offline,先关掉模拟器,执行adb kill-server && adb start-server,然后再开模拟器。这个坑我踩过很多次——模拟器开太多之后,adb 服务状态直接混乱,你连谁都是 offline。我自己现在规定:每次并发任务启动前,统一重启 adb 服务。
iOS 模拟器不能走 adb,要用xcrun simctl list devices查看可用设备,然后xcrun simctl boot "iPhone 15"启动。Appium 连接 iOS 模拟器时,capabilities 里只需要指定platformName和deviceName,不用填 udid,它会自动选一个可用模拟器。
3.3 真机连接的两个老大难:驱动和授权
Android 真机连上之后,adb devices如果显示unauthorized,说明手机上那个“允许 USB 调试吗?”弹窗还没确认。这个弹窗只能人工点一次,点错过只能拔线重插。所以有条件的话,建议直接配一台专用测试机,一次性把授权做好,不要频繁换手机。
国产 ROM 还要多做几步:小米要在开发者选项里打开“USB 安装”和“USB 调试(安全设置)”,否则自动化安装 App 或执行某些 shell 命令会被拒绝。vivo 和 OPPO 则在第一次连接时需要输入锁屏密码验证身份。
iOS 真机更麻烦,连接 WDA(WebDriverAgent)需要开发者证书和签名。没有付费开发者账号时,个人 Apple ID 可以做 7 天签名,但 7 天过期后所有设备都要重签一次,长期运维成本非常高。遇到 “unable to start WebDriverAgent” 这类报错,绝大多数时候不是配置问题,就是证书没信任何或者设备和 Xcode 版本不匹配。
4. 同一套脚本如何跨端稳定运行
4.1 选择器设计的“最大公约数”
模拟器和真机上,即使同为 Android,不同 ROM 也经常对资源 ID 做改动,元素树结构不固定。所以选择器要遵守“最大公约数”原则:优先用 resource-id、accessibility id、content-desc,其次用稳定的文本,最后才用 xpath,而且 xpath 必须写相对路径,不要带绝对索引。
我在框架里封装了一个选择器路由,按平台取不同的定位方式:
LOCATORS = { "login_button": { "android": (MobileBy.ID, "com.example:id/btn_login"), "ios": (MobileBy.ACCESSIBILITY_ID, "Login Button"), } } def locate(driver, key): platform = driver.capabilities["platformName"].lower() by, value = LOCATORS[key][platform] return driver.find_element(by, value)为什么我把这个限制得这么死?因为真机上踩过太多坑:同一个按钮,在模拟器上固定坐标是 x=560、y=720,换到不同分辨率的真机就偏了;文本定位更危险,页面上到处是“登录”两个字,取出来的可能是广告位里的登录按钮。resource-id 相对最稳定,虽然国产 ROM 偶尔混淆,但至少它是语义化的。真机与模拟器的差异,主要就体现在这里。
4.2 等待策略不能只靠 sleep
固定sleep(5)是“模拟器能过、真机随机崩”的头号原因。真机冷启动 App 可能要 2 秒,模拟器只要 0.5 秒;弱网下页面要 8 秒才加载完,你 sleep 5 秒就超时。所以等待策略一定要用显式等待,等元素从无到有,甚至等页面状态稳定。
我封装了一个最基础的等待方法:
def wait_visible(driver, locator, timeout=None): if timeout is None: timeout = 20 if driver.capabilities["platformName"].lower() == "ios" else 15 WebDriverWait(driver, timeout).until( EC.visibility_of_element_located(locator) )真机的超时时间我习惯比模拟器长 5 秒。只等一个元素还不够,最好还要等页面上的 loading 动画消失或者空状态隐藏。模拟器上渲染快,页面瞬间就稳定了,真机上动画可能还在跑,所以“等元素可见”和“等元素可点”有时候是两个状态,需要分别判断。
4.3 非预期弹窗兜底:从框架层解决
“自动化测试非预期弹窗导致失败”这个问题,真机自动化绕不开。弹窗类型五花八门:系统权限弹窗、版本更新弹窗、运营广告弹窗、青少年模式、隐私协议、风险提示。模拟器上大多可以通过配置关掉,但真机上厂商会随机弹,只能在框架层面解决。
我分三层处理:
第一层是 capabilities 配置。Android 设置autoGrantPermissions=true,让系统权限弹窗自动通过;iOS 用appium:autoAcceptAlerts=true一键接受系统弹窗。
第二层是安装后的权限预授权。Android 上执行:
adb shell pm grant com.example.app android.permission.CAMERA adb shell pm grant com.example.app android.permission.ACCESS_FINE_LOCATION这至少把大部分系统授权弹窗按死在启动前。
第三层是运行时弹窗守卫。我在每个关键操作前调用一个dismiss_unexpected_popup(driver),检查一份已知弹窗关闭按钮的白名单,发现就点掉。弹窗处理代码要跟业务用例分开,单独维护一份平台弹窗配置,不要让业务脚本里到处是“如果弹窗出现就关闭”的判断。
提示:弹窗白名单务必宁缺毋滥。你把“跳过”按钮当成弹窗关闭按钮处理,结果把真实业务跳过了,用例跑得再绿也是假绿。
4.4 小程序真机调试常见连接重置问题
如果被测对象是微信小程序或 uni-app,真机调试时经常会碰到failed net::err_connection_reset这个错。我遇到的情况里,绝大多数不是被测代码本身的问题,而是调试链路出了问题:手机和电脑不在同一个局域网、电脑防火墙拦掉了调试端口、手机锁屏导致调试通道断开。
处理步骤我整理成一套固定流程:
- 确认手机和电脑连接的是同一个 Wi-Fi;
- 关闭电脑防火墙,或者把开发者工具的调试端口加入白名单;
- 手机保持亮屏,在开发者工具里重新点击“预览”或“自动预览”;
- 如果用 HBuilder 跑 uni-app 到雷电模拟器,先执行
adb devices确认能看到模拟器,再在 HBuilder 里选“运行到浏览器/模拟器”。
还有一个容易被忽略的点:开发工具如果开了多进程调试,端口会被占用,重启一下开发者工具往往就好了。这个错误在小程序真机调试中太常见,很多新人以为是代码问题,其实先走一遍网络链路排查,能省几个小时。
5. 执行稳定性:并发、重试、结果聚合缺一不可
5.1 并发执行时模拟器和真机的资源隔离
并发跑多个模拟器的时候,宿主机 CPU 和内存瞬间会被吃满。我给每个模拟器固定分配 2 核、2GB 内存,同一台机器最多跑 4 到 6 个模拟器。带界面的模拟器尽量不用,CI 环境统一用-no-window,只有本地调试才开界面。
真机并发更考验硬件维护。USB Hub 如果供电不足,跑着跑着手机会掉线,然后整台设备被调度层剔除。我现在用带独立供电的 USB Hub,每台手机一根单独的数据线,Android 和 iOS 设备分别接不同的物理通道,避免带宽互相抢。
iOS 真机并发建议最多 2 台。WDA 每次安装和启动都很耗资源,同时连 4 台 iPhone,大概率有一台会报 timeout。真机重连的成本比模拟器高得多,不稳定的设备宁可串行。
5.2 超时、崩溃和重试策略
一个稳定方案一定要区分“环境失败”和“用例失败”。我在用例执行前加了一步健康检查:设备是否在线、App 是否安装、安装版本是否匹配。如果健康检查没过,直接标记environment_error,不计入用例失败,避免因为设备断了而误报功能回归。
执行中的超时也必须有硬性指标。我目前用的参数是:启动 App 60 秒超时,元素等待 15 秒超时,整条用例 3 分钟超时。如果用例在执行早期就失败,比如启动后 10 秒内,通常不是业务问题,而是设备或者 App 安装状态的问题,我允许系统自动重启 App 后重试一次。
关键原则是:重试只能重试一次,而且要保留首次失败的原因和截图。很多框架为了追求通过率,失败后无限重试,最后报告一片绿,但这种绿没有任何信任度。重试是给环境抖动一个机会,不是给真实缺陷遮羞布。
5.3 测试结果要能回答“谁坏了”
协同方案最后一步,是让结果能回答“谁坏了”。Appium 日志非常冗长,开发不可能一条条翻。我们需要在失败时自动收集关键信息:设备 ID、系统版本、App 版本、开始结束时间、失败步骤截图、页面源码或者崩溃日志。
我习惯在报告里做五个状态分类:
| 状态 | 含义 | 处理人 |
|---|---|---|
| passed | 正常通过 | 无需处理 |
| failed_assert | 断言失败,功能或数据不对 | 开发、测试 |
| failed_crash | App 崩溃 | 开发,附带崩溃日志 |
| env_error | 设备断连、权限、网络问题 | 测试负责环境恢复 |
| retry_passed | 首次失败,重试后通过 | 人工确认是否环境抖动 |
Android 崩溃时用adb logcat -d抓取近期日志,iOS 设备用xcrun simctl io booted screenshot x.png抓截图。把这些东西自动附加到报告里,开发收到后能快速过滤,不用再来找测试问“这是不是环境问题”。
6. 这套方案落地半年后,我掏心窝的几个建议
6.1 先跑通 30 条核心用例,别贪多
很多团队一开始就列 300 条用例,三个月后设备池和维护成本直接把人压垮。我实际走的路线是:第一周只挑 30 条核心链路,分别在模拟器和一台真机上跑通;第二周再把用例扩展到 50 条,稳定后接入 CI。不要一上来就把所有用例塞进真机,否则三天两头排查环境故障,大家很快就对自动化失去信心。
6.2 给每条用例标注运行环境
因为同一条用例可能需要同时在模拟器和真机上执行,但 CI 里的筛选条件不一样。我用 pytest 的 marker 来标记用例:
@pytest.mark.smoke @pytest.mark.real_device def test_order_pay(): ...执行时按标签筛选:
pytest -m "smoke and not real_device" pytest -m "real_device and android_only"这样就不会出现“用例数量很多但不知道哪条该跑哪台机器”的混乱。模拟器层跑 smoke,真机层跑 real_device,偶尔有平台独占用例再用标记隔离。
6.3 关注设备池健康,比关注脚本更重要
最后这点,是我踩坑最多的地方。协同方案里设备池一定要有健康检查和自动恢复机制。模拟器跑久了会变慢,我安排每周日凌晨自动重启所有模拟器;真机长期接 USB 会发热、断连,我定时清理缓存、检查电量,还做了电量低于 20% 就自动通知的脚本。
调度层还有一个心跳任务,每分钟检查一次设备在线状态,连续两次心跳失败就把设备从池子里剔除,避免整个任务卡在一台死设备上。没有这套机制,再好的用例集也会因为一台“诈尸”设备导致整轮任务挂掉。真机和模拟器的协同,说到底拼的不是脚本写得多漂亮,而是设备运营和稳定性治理这些脏活有没有做到位。