
接手过一个已经被手动点崩过的电商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 不是因为它最先进而是因为它在这个场景下最符合我的需求。对比维度XCUITestAppiumKIFEarlGrey技术原理基于系统 Accessibility外部进程驱动WebDriver 协议底层调用各种驱动在 App 进程内直接注入测试代码在 App 进程内调用 UI 层级做同步断言语言支持Swift / Objective-C多语言Objective-C / SwiftObjective-C / SwiftCI 友好度强xcodebuild 原生支持需要额外配置 Appium Server依赖 host app 注入稍重依赖 host app 注入稍重稳定性中等合理等待后较稳依赖元素查找策略跨端优势大稳定性好但侵入性强同步很强侵入性强适合场景iOS 原生 App 自测跨平台 / 跨应用 / 黑盒测试不介意改造 App 的团队需要精细化同步断言的团队如果团队同时要测 Android 和 iOSAppium 有它独特的价值一套 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.submitSwiftUI 里更直接Button(登录) { ... } .accessibilityIdentifier(login.submit)有了稳定的 identifier测试里就可以写app.buttons[login.submit]完全不用关心界面上显示的是“登录”还是“马上登录”。大多数 UI 测试不稳定根源都是拿展示文案/系统控件文案当定位锚点产品一个文案修改就引发整片红。把 identifier 作为团队约定放进 code review 范畴这是一种投入小、回报极高的可测性建设。除了 identifierXCUITest 还支持 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 出现并可点击再进入二级页写法可以用 XCTNSPredicateExpectationlet 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(uitestexample.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 platformiOS Simulator,nameiPhone 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 可能失效、哪些流程新增了分支。然后在提测阶段顺手跑一遍受影响的测试计划。这套机制运行小半年后测试资产的成本会稳定下来收益则慢慢变成一种可信赖的“夜间守护”。如果你正打算开始尝试我的个人建议是从一条核心用户路径入手而不是一开始就追求全量覆盖。跑通一条路径、稳定住一条路径比写出二十个三天两头就挂的用例有用得多。