Hybrid App 移动端自动化怎么落地:Appium 3 下的 Android、iOS 与 Minium 分层实践

52 阅读9分钟

1. 为什么移动端自动化总是比想象中难

如果是纯 Web,问题通常集中在选择器和等待;如果是纯原生,问题更多落在设备、权限和系统弹层;但到了 uni-app 这类 Hybrid App,麻烦往往是叠加的。

你既要处理 NATIVE_APP 里的输入法、系统弹窗和页面切换,又要切进 WEBVIEW 定位 DOM,还要兼顾 Android、iOS,甚至再往后还要考虑微信小程序这一条线。很多团队在这一步最容易掉进一个误区:以为移动端自动化的核心是“把脚本写出来”。但真实情况往往正好相反,脚本只是最表层,真正决定稳定性的,是驱动初始化、上下文切换、结果取证和目录边界。

我在整理这一套自动化方案时,最深的感受是:移动端失败很多时候不是业务失败,而是自动化链路先失稳了。比如 WebView 上下文还没准备好,脚本已经开始找 DOM;比如 Android 软键盘已经切页了,自动化还停留在旧的输入假设里;再比如 iOS 的 bundleId、WebView context 和预期不一致,驱动能起来,但页面迟迟连不上。

所以,这篇文章不想把重点放在“某个命令怎么敲”,而是想讲清楚一件更重要的事:如果项目同时面对 Android App、iOS App 和微信小程序,自动化到底应该怎么分层,哪些能力该复用,哪些能力不该强行共用。

2. 我最后选择的不是“一套脚本跑全部”,而是“三层复用、驱动分治”

一开始最容易产生的想法,是做一套尽可能通用的脚本,让 Android、iOS 甚至小程序都能共用。但真的开始落地后就会发现,这种“全都想复用”的思路,往往会把脚本层搞得越来越脆。

我最后选择的是三层拆分:

  1. 用例层:只关心“我要验证什么”,例如登录成功、登录失败、会话缓存、多租户切换。
  2. 页面层:只关心“这个页面有哪些业务动作”,例如输入用户名、输入密码、点击登录、等待设备列表出现。
  3. 驱动层:只关心“当前平台怎么做这件事”,Android 走 Appium 3 + UiAutomator2,iOS 走 Appium 3 + XCUITest,微信小程序单独走 Minium

这个拆法最重要的价值,不是形式上看起来干净,而是它能把变化真正隔离开。

Android 和 iOS 可以共用同一组测试意图,但不强行共用底层定位和驱动细节;微信小程序可以继续复用业务用例和结果规范,但不复用 Appium 的上下文切换、原生输入和能力配置。这样做比“一套脚本适配所有平台”更现实,也更容易长期维护。

3. 为什么我不建议先写脚本,而是先把驱动配置收口

很多团队前期会优先写页面脚本,写到一定规模后再补公共层。这个顺序短期看似快,长期通常会让脚本质量越来越差。

我这次反过来做,先把驱动初始化和运行设置收敛到公共层,再往上写页面对象和测试用例。原因很简单:移动端自动化里,很多问题不是页面逻辑问题,而是驱动能力没有统一管理。

Android 侧当前收口到 docs/testing/appium/common/android.js,核心能力大致如下:

const APP_CAPABILITIES = {
  platformName: 'Android',
  'appium:automationName': 'UiAutomator2',
  'appium:deviceName': process.env.PATROL_DEVICE_NAME || 'emulator-5554',
  'appium:noReset': true,
  'appium:newCommandTimeout': 300,
  'appium:appPackage': 'uni.app.patrolcar',
  'appium:appActivity': 'io.dcloud.PandoraEntry',
  'appium:appWaitActivity': 'io.dcloud.PandoraEntry,io.dcloud.PandoraEntryActivity',
  'appium:appWaitDuration': 20000
};

同时把 settings 也统一收进公共层:

const DRIVER_SETTINGS = {
  enableMultiWindows: true,
  allowInvisibleElements: true,
  ignoreUnimportantViews: false
};

这一层看起来像“配置”,但其实它直接决定后面的稳定性。比如输入法、ActionSheet、系统弹层经常出现在独立窗口里,如果不开多窗口,很多原生节点根本拿不到;如果 appWaitActivity 不统一维护,启动阶段就会出现看似随机的失败。

iOS 侧同样单独收敛到 docs/testing/appium/common/ios.js。它和 Android 的区别,不是把 platformName 改一下这么简单,而是整个连接模型都不一样。

const APP_CAPABILITIES = {
  platformName: 'iOS',
  'appium:automationName': 'XCUITest',
  'appium:deviceName': process.env.PATROL_IOS_DEVICE_NAME || process.env.PATROL_DEVICE_NAME || 'iPhone 15',
  'appium:platformVersion': process.env.PATROL_IOS_PLATFORM_VERSION || '',
  'appium:noReset': true,
  'appium:newCommandTimeout': 300,
  'appium:bundleId': process.env.PATROL_IOS_BUNDLE_ID || 'uni.UNI74E403C',
  'appium:autoWebview': false,
  'appium:includeSafariInWebviews': true,
  'appium:webviewConnectRetries': Number(process.env.PATROL_IOS_WEBVIEW_RETRIES || '10'),
  'appium:webviewConnectTimeout': Number(process.env.PATROL_IOS_WEBVIEW_TIMEOUT || '20000')
};

对应的 settings 也单独维护:

const DRIVER_SETTINGS = {
  nativeWebTap: true,
  shouldUseCompactResponses: false,
  elementResponseAttributes: 'type,label,name,value,enabled,visible'
};

这里最容易被低估的一点是:iOS 不是“把 Android 脚本搬过去再修一修”就能跑。它对 bundleId、WebView 连接、原生手势和可调试环境都有额外要求,而且真正执行 XCUITest 还必须回到 macOS + Xcode

4. Hybrid App 真正难的地方,不在定位器,而在上下文切换

如果项目是纯原生,问题大多是原生树怎么找;如果项目是纯 Web,问题大多是选择器怎么写;但 Hybrid App 最麻烦的地方在于,你必须稳定地来回切 NATIVE_APPWEBVIEW

当前项目里,这部分能力统一从 docs/testing/appium/common/factory.js 进入。它只做一件事:根据 PATROL_PLATFORM 把平台差异挡在公共层里。

  • PATROL_PLATFORM=android 时走 common/android.js
  • PATROL_PLATFORM=ios 时走 common/ios.js

Android 当前登录页依赖的上下文是:

  • NATIVE_APP
  • WEBVIEW_uni.app.patrolcar

iOS 当前登录页默认依赖的上下文是:

  • NATIVE_APP
  • WEBVIEW_uni.UNI74E403C

这里我更想强调的是“为什么它们看起来不一样”。Android 这一项来自实际包名 uni.app.patrolcar,它是已经跑过的值;iOS 这一项目前只是根据项目 appid 推出来的默认占位值,真实联调时仍然应该以 Appium 返回的 contexts 为准。

也就是说,脚本里真正应该追求的,不是把两个平台硬写成同一个字符串,而是把等待逻辑、切换逻辑和错误信息都统一收口。这样页面脚本才不会到处散着 switchContextpause,后面排障也不会变成“满工程找切换代码”。

5. 页面对象为什么还要拆 Android 和 iOS 两份定位器

这个问题我自己一开始也犹豫过。因为登录页当前是 uni-app 的 Hybrid WebView,很多 DOM 结构在 Android 和 iOS 上看起来确实很像,于是很容易产生一个想法:既然页面长得差不多,是不是没必要把定位器拆成两份。

但真正稳定的做法,还是提前把结构拆开。

当前项目里,登录页已经把定位器拆成:

  • locators.android.js
  • locators.ios.js

这两个文件现在内容相近,并不代表它们没有必要分开;它只说明当前登录页在两个平台上的 DOM 还没有分化到必须写两套完全不同选择器的程度。结构先拆开,后面一旦 iOS 联调发现 placeholder、层级、可见性判断、输入行为和 Android 不一致,就可以只改 locators.ios.js,而不去污染已经跑稳的 Android 逻辑。

这类“先把边界拆开,再等待真实差异出现”的做法,在移动端自动化里其实比“先合并,出问题再说”更省成本。

6. 结果归档如果不设计好,后面几乎一定会乱

自动化做久了会发现,一个脚本能不能跑完只是最基础的问题,真正决定团队能不能长期使用这套测试的,是结果有没有被稳定留存下来。

当前结果目录按页面收口在:

docs/testing/results/

以登录页为例,结构大致是:

docs/testing/results/login/
  SUMMARY.md
  <YYYYMMDD_HHMMSS>/
    <script-name>/
      result.md
      screenshots/
      action-trace.jsonl

这里最关键的规则只有一条:每次测试脚本完成后,都必须同步更新对应页面目录下的 SUMMARY.md

原因并不复杂。单个 result.md 更适合保存当次细节,但真正方便团队回看的,是页面级别的持续汇总。尤其是同一轮次里如果跑了多个脚本,结果必须分别保留,不能被最后一次批量写入覆盖。

这也是为什么我把汇总逻辑收到了 docs/testing/appium/common/test-reporter.js。相比把汇总责任散落在每个脚本里,公共层统一处理更不容易漏,也更适合后续扩展。

7. 微信小程序为什么不建议沿用这套 Appium 脚本

如果只看“业务场景”,很多人会觉得小程序和 App 的测试意图差不多,既然登录、列表、缓存这些行为都类似,那是不是把 Appium 脚本改一改就能接着用。

我的判断是:不要这么做。

微信小程序更适合单独走 Minium。原因不是因为场景差别大,而是底层控制模型根本不是一回事。Appium 控的是原生容器和 WebView,而 Minium 控的是小程序运行时。两者在元素定位、页面对象、接口能力和调试方式上都不是同一套体系。

但这不代表前面的工作都白做了。真正可以复用的部分其实很清楚:

  • 业务用例设计
  • 测试场景命名
  • 账号池和测试数据规则
  • 报告字段设计
  • 结果目录和汇总规范

不建议复用的部分也同样清楚:

  • Appium capabilities
  • UiAutomator2 / XCUITest 驱动逻辑
  • WEBVIEW 上下文切换
  • Android、iOS 的输入和原生弹层处理
  • locators.android.js / locators.ios.js

换句话说,小程序这条线最合理的接法,不是复用 Appium 的底层实现,而是复用“我要测什么”和“结果怎么落”。

8. 最后回看,这套方案真正解决的不是脚本数量,而是失控问题

如果把移动端自动化理解成“把每个页面都补一份脚本”,那它很快就会变成一个越写越重、越写越难改的包袱。但如果从一开始就把驱动、平台切换、页面边界和结果归档都设计进去,它反而会慢慢长成一套可以持续演进的基础设施。

对当前这个项目来说,比较合适的结论其实已经很明确:

  • Android 继续走 Appium 3 + UiAutomator2
  • iOS 单独走 Appium 3 + XCUITest
  • 微信小程序单独走 Minium
  • 用例层、结果层和命名规范尽量复用
  • 驱动层和平台定位不要强行合并

这套拆法不一定是最“省文件数”的方案,但它是目前最容易把复杂度关在边界里的方案。对移动端自动化来说,长期稳定往往比短期看起来统一更重要。

参考资料

f0a223397341087f0d23f693911f6bc7.png