接手过一个已经被手动点崩过的电商App,回归一次要花一整个下午,后来我下决心把核心链路用 XCUITest 从登录、加购、支付一路铺到订单列表。说实话,最开始真被它坑得够呛,但等把所有坑摸清之后,XCUITest 带来的收益非常直接:每天 CI 自动跑一遍核心场景,比任何“测试通过率承诺”都实在。
这篇内容不是官方文档翻译,是我在实际项目里把 XCUITest 从零搭到能稳定工作的完整经验,包括选型逻辑、元素定位、等待机制、网络模拟、CI 集成和大大小小的坑。做 iOS 且想认真做 UI 自动化的同学,无论你新手还是已经断断续续用过 XCUITest,应该都能在这里找到一些能直接抄走的东西。
1. 先把 XCUITest 放在大图里看:定位、价值与选型逻辑
1.1 它到底解决 UI 自动化的什么核心问题
先说结论:XCUITest 是 Xcode 自带的 UI 测试框架,从 iOS 9 开始基本就没缺席过。它和普通 XCTest 的差别在于测试进程与 App 进程相互独立——测试代码跑在一个单独 runner 进程里,通过系统层的 Accessibility 信息去感知 App 界面,然后像真人一样点击、滑动、输入,再断言界面状态是否符合预期。
这个设计有一个极其隐蔽但重要的好处:测试代码和业务代码不共享内存。这意味着你不能在测试类里读取 App 内部的变量、调用私有方法,只能通过“界面是什么样”来判断功能正不正确。第一次接触的人会觉得很不方便,但它反而逼着你从真实用户体验去设计用例,也避免了单元测试里那种“改个内部实现,测试跟着崩”的耦合问题。
在多数真实项目里,XCUITest 最典型的使用位置是冒烟回归。团队不指望它替代单元测试,而是让它在每次提交或每日构建时把用户核心路径自动走一遍。iOS 端 UI 变化频繁,哪怕只是把一个按钮挪了位置,都会立刻反映到 XCUITest 失败上,而这种失败往往手动回归很难快速发现。
1.2 几种 iOS UI 自动化框架到底怎么选
我把几个常见方案都试过,选择 XCUITest 不是因为它最先进,而是因为它在这个场景下最符合我的需求。
| 对比维度 | XCUITest | Appium | KIF | EarlGrey |
|---|---|---|---|---|
| 技术原理 | 基于系统 Accessibility,外部进程驱动 | WebDriver 协议,底层调用各种驱动 | 在 App 进程内直接注入测试代码 | 在 App 进程内调用 UI 层级做同步断言 |
| 语言支持 | Swift / Objective-C | 多语言 | Objective-C / Swift | Objective-C / Swift |
| CI 友好度 | 强,xcodebuild 原生支持 | 需要额外配置 Appium Server | 依赖 host app 注入,稍重 | 依赖 host app 注入,稍重 |
| 稳定性 | 中等,合理等待后较稳 | 依赖元素查找策略,跨端优势大 | 稳定性好但侵入性强 | 同步很强,侵入性强 |
| 适合场景 | iOS 原生 App 自测 | 跨平台 / 跨应用 / 黑盒测试 | 不介意改造 App 的团队 | 需要精细化同步断言的团队 |
如果团队同时要测 Android 和 iOS,Appium 有它独特的价值,一套 WebDriver 思想两边通用。但如果团队只维护 iOS 原生客户端,XCUITest 的成本优势非常明显——不用额外装服务,不用维护协议层,xcodebuild 跑完直接拿 xcresult。
EarlGrey 和 KIF 我分别在一些项目里见过,稳定性确实不错,因为它们跑在 App 进程内,可以直接同步到主线程空闲为止。但有个很现实的问题:它们要求被测 App 集成测试库,等于给发布包增加侵入风险,这对很多大团队来说是个敏感点。XCUITest 完全不碰目标 App 的代码包,隔离性好,我只是额外加一个 UI 测试 Target 而已。
2. 工程落地第一步:Target 创建、项目配置与启动姿势
2.1 新建端到端测试 Target 的两种方式
在工作区里选 File -> New -> Target,然后在 Test 分类下选择 UI Testing Bundle,这就是一个干净的 UI 测试 Target。新建之后 Xcode 会生成两个模板文件,一个是_UITests.swift,另一个通常是取名类似的_UITestsLaunchTests.swift,后者主要是验证 App 能否启动,多数情况下可以直接删除,避免 CI 上白白多跑一个没意义的用例。
还有一个容易被忽略的方式,如果你已经有了 Unit Test Target,也可以在弹出的 Test Target 勾选时直接新建 UI 测试 Target,并不会冲突。项目里同时存在 Unit Test 和 UI Test 很常见,分开放即可。
无论哪种方式,都需要确认 Target 里的 Host Application 选对了被测 App。如果选了 None,那就成了测试一个没有宿主 App 的 runner,基本跑不了 UI 场景。这一点在多人协作时经常出问题,特别是工程里同时挂着多个 App Target,容易选成别人的壳子,测试代码能编译但一运行就报“Failed to synthesize event”。
创建好 Target 之后,真正要维护的不是 Target 本身,而是它的 Test Plan 和 Scheme。如果一个工程要区分“冒烟回归”“深度回归”“线上包巡检”等不同粒度的测试集合,可以用 Test Plan 把用例分组,而不是一个 Target 里塞几百个用例让 CI 全部盲跑。
2.2 XCUIApplication 启动参数:测试与被测对象的通信闸门
XCUITest 虽然不能读 App 内存,但它可以在启动 App 时向进程传递参数和环境变量,这是 UI 测试最常见的“门”。
let app = XCUIApplication() app.launchArguments = ["-UITestMode", "1"] app.launchEnvironment["MOCK_NETWORK"] = "1" app.launch()launchArguments会被 App 接收后,业务代码里用 UserDefaults 去读取,比如UserDefaults.standard.bool(forKey: "UITestMode")。这里会把“当前处于 UI_TESTS”的判断搭好,从而在测试环境里屏蔽推送弹窗、启动广告页、新功能引导等容易干扰元素定位的模块。对 UI 测试来说,每多一个系统弹窗或浮层,就多一分失败概率。
launchEnvironment更偏进程环境变量,适合传给网络层,让请求都指向测试环境域名,甚至在本地起一个 stub 服务返回固定 JSON。有的团队把不同环境配置编译进多个 Scheme,再加参数切换,这也行,但本质上是一样的思路——让测试的启动环境与线上隔离,并且保证每次启动状态完全可控。
另外,启动参数会影响 App 的首次动画、键盘联想、引导页等状态。我最后的建议是,在 AppDelegate 或启动配置入口加一个类似prepareForUITest()的方法,它负责把动画时长缩短、关闭定位弹窗、清理登录态。保证每次 launch 出来的 App 都是一张“白纸”,这才是 UI 测试可复现的前提。
2.3 录制功能生成的代码为什么不能直接用来当测试
Xcode 自带红色录制按钮,点击后会在真机或模拟器上操作 App,自动生成诸如app.buttons["Buy"].tap()这样的代码。新手入门用它找感觉非常快,但我强烈不建议直接把它当作测试资产长期保留。
录制功能生成的定位有两个毛病:第一,它默认用当前可见的 label 或者占位文本去定位,一旦文案变化,用例立刻失效;第二,它会产生大量重复的层级查找语句,可读性和维护性都很差。做 UI 测试的核心资产不是被录下来的操作步骤,而是你对每个页面、每个稳定标识符的定义策略。
我的经验是,用录制功能最多做两件事:一是快速获取某个控件在 Accessibility 树里的类型,比如确认这是个button还是staticText;二是快速造一个临时的脚本去试探 selector 的匹配结果。真正要提交到工程里的用例,必须手动整理出清晰的结构:启动 App,等待目标页面,按业务步骤操作,等待下一个状态,断言关键元素。录制代码只当一个勘察工具,不要直接复制上生产。
3. 元素定位并不只是“找按钮”:理解查询机制是稳定的分水岭
3.1 XCUIElementQuery 到底怎么遍历 UI 层级
很多刚上手的人会把app.buttons["提交"]当字典用,其实这背后是一套查询系统。app.buttons返回的是一个XCUIElementQuery,它按类型筛选出当前界面所有 button 元素。写在方括号里的字符串,本质上是传给查询里匹配元素的 identifier、label 或 title 的匹配条件。
查询并不是在执行代码的那一刻抓取快照,而是在你后续访问元素属性或执行操作时才真正去 App 端取得当前状态。这个差异能解释很多玄学问题:某些元素一开始查询可能返回“存在”,但等到 tap 时界面已经跳转,于是操作落到旧节点上导致失败。
当查询只有一个匹配结果时,XCUITest 使用比较顺畅;一旦出现多个匹配,代码通常会在运行时抛异常。比如一个 cell 内部有多个 label 同时满足条件,直接app.staticTexts["标题"]并且期望唯一的代码就会踩坑。所以写定位时最好先自问一句:这个 query 在真实界面上到底会命中几个元素?如果命中多个,就要用更精确的层级或者 predicate 去收窄范围。
3.2 从“找个能点的”到“稳定地定位到唯一对象”
accessibilityIdentifier是 UI 测试最核心的定位属性,它不随用户可见文案变化,并且是可被代码设置且稳定的。UIKit 控件里这样设:
submitButton.accessibilityIdentifier = "login.submit"SwiftUI 里更直接:
Button("登录") { ... } .accessibilityIdentifier("login.submit")有了稳定的 identifier,测试里就可以写app.buttons["login.submit"],完全不用关心界面上显示的是“登录”还是“马上登录”。大多数 UI 测试不稳定,根源都是拿展示文案/系统控件文案当定位锚点,产品一个文案修改就引发整片红。把 identifier 作为团队约定,放进 code review 范畴,这是一种投入小、回报极高的可测性建设。
除了 identifier,XCUITest 还支持 NSPredicate 进行条件匹配,这能在 id 不清晰时做组合定位。比如我想找到所有 label 包含“价格”的静态文本,再从中选择第一个,可以这样写:
let priceLabelQuery = app.staticTexts.matching( NSPredicate(format: "label CONTAINS '¥'") ) let firstPrice = priceLabelQuery.firstMatch我还会大量借助descendants(matching:)去查一个通用元素,因为很多自定义控件在 Accessibility 树里并不是严格的 button 类型。比如一个可点击的UIView加了 accessibilityIdentifier,但查询app.buttons时它不会出现,只能用app.descendants(matching: .any)["cell.item"]去匹配。记住这种差异化查询能省去很多折腾。
3.3 把 Accessibility 可测性当成功能开发的一部分
真实项目里最大的元素定位障碍不是写法,是控件根本没有暴露出来。常见的情况是:一个自定义画出来的卡片,点击事件是包在 UITapGestureRecognizer 上的,Info 里没开isAccessibilityElement,也没设置 identifier。对 XCUITest 来说,这种控件可能是一团空白或者一堆无意义的层级。
最效率最高的经验是在业务开发提测前就约定:所有可点击的重要控件,至少有 accessibilityIdentifier;所有承载核心文案的控件,至少 label 是正确的;图片如果承担按钮职责,也把它设置成 accessibility element。这条规范不只为了测试,也直接改善 VoiceOver 用户体验,成熟团队通常把可测性并入无障碍规范一起评审。
如果接手一个存量 App,也不可能一夜之间把所有 identifier 补齐。实操时我给自己的原则是:凡是测试资产里用到的主要页面,只补那一页稳定定位需要的最小 identifier 集合;而那些在测试里临时用 label 也能唯一定位的页面,先不强改,等遇到不稳定再说。补可测性最忌讳一次性大改革,容易阻塞业务迭代,改成按页面灰度推进才现实。
4. XCUITest 的等待机制:稳定性其实是一种同步艺术
4.1 为什么无脑 sleep 是最差劲的做法
新手最容易犯的错误是界面还没出来就直接 tap,于是到处加sleep(2)。sleep 看着简单,实际是最不可靠的方案:真机偶发卡顿、模拟器冷启动、接口响应差异,都会让固定休眠时间失效。跑得慢的机器 2 秒不够,跑得快的机器白白多等 2 秒,最后套件整体时长越来越长。
UI 测试框架本身并不是完全没有等待能力。XCUITest 对大部分元素操作有自动等待机制,比如调用tap()时会自动等待元素可交互,默认超时大约与系统事件队列相关。但它的自动等待不是万能的,尤其当你需要等一个异步网络请求完成后的元素出现,框架并不知道你要等什么,所以我们需要显式地告诉它:等到什么条件满足再继续。
我个人总结的原则是:能不用线程阻塞就不用,能用waitForExistence解决的不用 expectation,只有跨页面、长时间异步场景才用 predicate expectation。这样写出来的用例既稳定,又不会把测试时长拖到不可接受。
4.2 waitForExistence 和 XCTNSPredicateExpectation 的选择
waitForExistence(timeout:)是最常用的等待方式,它让当前线程阻塞直到元素出现或者超时。
let successToast = app.staticTexts["操作成功"] XCTAssertTrue(successToast.waitForExistence(timeout: 10)) successToast.tap()上面代码在大多数场景已经足够。需要注意waitForExistence返回后元素确实是 exists,但它不一定马上 hittable(可点击)。比如一个带转场动画的按钮,在动画播放中虽然 exists 为 true,但点击事件被系统拒绝。这时候要考虑再加一层等待可点击状态。
处理复杂的异步等待,比如登录成功后要等“首页”的某个 cell 出现并可点击,再进入二级页,写法可以用 XCTNSPredicateExpectation:
let homeCell = app.cells["home.featured"] let predicate = NSPredicate(format: "hittable == true") let exp = expectation(for: predicate, evaluatedWith: homeCell) let waiterResult = XCTWaiter().wait(for: [exp], timeout: 15) XCTAssertEqual(waiterResult, .completed, "首页推荐位没有在15秒内变成可点击状态")通过 NSPredicate 的exists、hittable、label == ...组合,实际上可以写出非常精确的界面状态等待条件,比轮询 exists 再手动判断更优雅,也比固定 sleep 快得多。
4.3 动画和键盘是 UI 测试的两只隐形绊脚石
在实际项目中,UI 测试跑挂的最大根因并不是代码逻辑,而是转场动画和键盘。页面 push 动画还没结束,测试已经尝试点击下一页的内容,就会落到错误的坐标系上。系统键盘弹出也需要时间,尤其是模拟器第一次输入时可能要弹出键盘,若测试代码立刻继续动作,很容易出现“不能顺利输入”。
针对动画,可以在启动参数里传入一个开关,让 App 端把 UIView 动画时长压缩成 0。这样测试运行里的界面切换几乎完全是瞬时的,回归速度也明显提升。
UIView.setAnimationsEnabled(false) // 在 UITestMode 下执行针对键盘,如果测试必须用系统键盘输入英文或数字,可以先点击文本框,等待键盘弹起,再执行typeText。如果输入的是中文,typeText经常因为中文输入法候选条而中断,这是 XCUITest 一个长期存在的痛点。稳妥做法是绕过键盘:对于登录名、搜索词这类输入,不要在 UI 测试里用系统键盘硬敲中文,而是通过启动参数或者剪贴板传入需要的数据,测试里只做粘贴动作。这样既绕开键盘焦点又减少不稳定因素。
5. 实战代码:登录、列表加载、深链跳转这三个用例怎么落地
5.1 登录用例:接口尚未就绪时也能跑通
写登录用例之前先要部署好测试账号体系。UI 测试最好有一个专用账号池,注册、改密、封禁这些场景不要在生产账号上跑。如果没有现成账号池,也可以在 App 里给“UITest模式”提供专用账号,用 launchArguments 注入。
func testLoginFlowWithMockAccount() throws { let app = XCUIApplication() app.launchArguments = ["-UITestMode", "1"] app.launch() let accountField = app.textFields["signin.account"] XCTAssertTrue(accountField.waitForExistence(timeout: 5), "账号输入框没有出现") accountField.tap() accountField.typeText("uitest@example.com") let passwordField = app.secureTextFields["signin.password"] passwordField.tap() passwordField.typeText("123456") app.buttons["signin.submit"].tap() let homeTab = app.tabBars.buttons["首页"] XCTAssertTrue(homeTab.waitForExistence(timeout: 10), "登录后没有到达首页") }这里关键点有两个:一个是等待首页 Tab 而不是等待某个 toast,因为 toast 生命周期端,等完可能就已经消失了,而首页 Tab 代表稳定状态更可靠;另一个是 secureTextFields 类型不同于 textFields,查错类型会导致元素找不到。
5.2 列表加载和空态用例:配合 mock 数据做出可重复断言
列表场景我更喜欢测两个极端:一是正常加载出数据,二是空数据状态。正常数据要在 App 端通过启动参数 mock 固定一组稳定数据,而不是依赖测试环境 seed。否则后端改造一下字段名,UI 测试和接口测试一起挂,排查起来非常痛苦。
空态处理在工程里经常被遗漏。后端有时返回空数组,但客户端状态没处理好,用户看到的是白屏而不是引导图标。写这样的断言时,要同时验证“列表不存在”和“空态视图存在”两个条件。
func testOrderListEmptyState() throws { let app = XCUIApplication() app.launchArguments = ["-UITestMode", "1", "-MockOrderList", "empty"] app.launch() app.tabBars.buttons["订单"].tap() let emptyIcon = app.images["order.empty.icon"] XCTAssertTrue(emptyIcon.waitForExistence(timeout: 8), "没有展示空订单图标") let guideText = app.staticTexts["order.empty.tip"] XCTAssertTrue(guideText.exists, "空态引导文案缺失") }5.3 深链跳转用例:从外部链接进入 App 还能直接断言页面内容
UI 测试除了驱动界面,还能通过 openURL 方式测试深链逻辑。这个场景手动测试很繁琐,自动化反而简单:把 url 通过 launchEnvironment 传给 App,让 App 在 didFinishLaunching 后立刻去处理这条深链。
func testDeepLinkToProductDetail() throws { let app = XCUIApplication() app.launchArguments = ["-UITestMode", "1"] app.launchEnvironment["DEEP_LINK_URL"] = "yourapp://product/10001" app.launch() let productTitle = app.staticTexts["product.title"] XCTAssertTrue(productTitle.waitForExistence(timeout: 10), "深链后没进入商品详情页") }业务代码接收这个环境变量并模拟系统 deep link 打开。注意不要在测试里真正跳到系统浏览器再跳回 App,容易因为外部 App 权限问题导致整个测试失败。把深链的“触发”降级成 App 内去处理一个 URL 字符串,才是 UI 测试里最稳定的方式。
6. 跑在 CI 上才是开始:并行、截图、产物收集与长期维护
6.1 xcodebuild 跑 UI 测试并读取结果
本地 Xcode 界面跑通只是第一步,真正有持续价值的是把它搬进 CI。命令行下最核心的命令是:
xcodebuild test \ -workspace YourApp.xcworkspace \ -scheme YourAppScheme \ -destination 'platform=iOS Simulator,name=iPhone 15' \ -resultBundlePath ./xcresult/YourApp.xcresult跑完之后的xcresult是个二进制包,可以导出测试报告、崩溃日志和每一步截图。很多团队的 CI 只会展示“过没过”,实际上这一步浪费了大量调试信息。应该额外导出 attachment 里的截图,让失败用例在构建页面上可以直接看到证据图,这会大幅减少排查时间。
需要注意,destination里如果写死模拟器,一旦 CI 机器没有这个名字的模拟器就会失败。建议先通过xcrun simctl list devices available自动找出一个可用的模拟器再填入命令,或者在 CI 节点上预先严格固定模拟器版本,不要随意升级,否则 UI 自动化环境会像流沙一样不稳定。
6.2 并行执行与重试策略怎么定
多测试类默认是串行跑的,一个用例跑 8 秒,200 个用例就是接近半小时。XCUITest 支持分 destination 并行跑,也可以用 xcodebuild 的 parallel-testing-enabled 参数开启并行。但我不建议无脑把用例全量并行,因为很多用例都启动同一个 App,在模拟器里并行容易互相干扰,尤其是多个测试进程同时启动时对系统资源竞争严重。
更稳妥的做法是按业务模块拆测试 Target 或 Test Plan,让不同模块可以并行在多个模拟器上跑,同一个模块内部仍然串行。例如“订单计划”跑 iPhone SE,“个人中心计划”跑 iPhone 15,两个计划互不相干,整体时间能压缩将近一半。
关于重试,UI 测试天然有偶发失败,所以团队普遍会设置失败后重试一次或两次。但重试只应作为抵御环境波动的兜底,不能成为掩盖代码缺陷的遮羞布。一个用例重试三次才能过,说明它本质上不够稳定,需要的是去诊断等待条件或定位方式,而不是靠提高重试次数自我安慰。
6.3 长期维护测试资产的一点方法论
最后聊聊最容易被忽视的维护问题。UI 测试资产跟业务代码一样需要 code review、去重和定期重构。否则业务迭代三个月后,测试代码的坏味道会超过业务代码,没人愿意碰,最终沦为工程债的另一个分支。
我会在每个版本迭代里做一张“UI 测试变更清单”:这个版本改了哪些页面、哪些元素的 accessibilityIdentifier 可能失效、哪些流程新增了分支。然后在提测阶段顺手跑一遍受影响的测试计划。这套机制运行小半年后,测试资产的成本会稳定下来,收益则慢慢变成一种可信赖的“夜间守护”。如果你正打算开始尝试,我的个人建议是从一条核心用户路径入手,而不是一开始就追求全量覆盖。跑通一条路径、稳定住一条路径,比写出二十个三天两头就挂的用例有用得多。