XCTest:从简历镀金到真香自动化测试
上周五晚上十一点半,我瘫在工位上盯着 Xcode 里红得发紫的测试报告,脑子里只有一个念头:“这破玩意儿怎么又崩了?”
——别误会,不是我的代码崩了,是我的 UI 自动化测试脚本 又在模拟器里跑飞了。而明天就是 Sprint Review,产品经理已经把 Demo 视频剪好了(虽然根本没跑通),就等我这边“自动验证”环节撑场面。
我是上海某 211 软件工程研二学生,目前在一家做跨境支付的创业公司实习,日常主力开发机是 MacBook Pro M2(Windows?那只是我用来测 IE 兼容性的古董)。实验室项目用 Spring Boot 搞后端微服务,前端是 Vue + Electron,移动端 iOS 端则是我主攻方向。最近团队被老板“赋能”了一个新 KPI:提升自动化测试覆盖率至 70% 以上,否则“简历优化”警告。
于是,我被迫深入研究 XCTest —— Apple 官方钦定的测试框架。本以为只是写几个 XCTAssert 就完事,结果发现水比黄浦江还深。
为什么是 XCTest?而不是 Appium 或 Detox?
说实话,一开始我本能地想上 Appium。毕竟简历上写“熟悉跨平台自动化测试框架”听起来更高级,而且团队后端同事用 Spring Boot 写接口时,顺手用 TestContainers 做集成测试,生态很统一。但现实狠狠打了我的脸:
- 速度慢到怀疑人生:Appium 启动一次 iOS 测试要 30s+,而 XCTest 直接跑在模拟器/真机上,秒级启动。
- 稳定性堪忧:元素定位频繁失效,XPath 在 iOS 上根本靠不住,Accessibility ID 又得开发配合加。
- Apple 生态锁死:XCTest 能直接调用 Swift 代码、访问私有 API(调试时)、甚至和 SwiftUI 预览联动。
更重要的是——App Store 审核越来越看重测试完备性。去年有个版本因为手动测试漏掉一个边界 case,上线后 Crash 率飙升,被 Apple 退回两次。从此老板立下规矩:没有自动化测试覆盖的核心路径,不准合入主干。
行吧,既然逃不掉,那就干。
XCTest 的三层结构:别只写单元测试!
很多同学(包括半年前的我)以为 XCTest = 单元测试。错!XCTest 实际包含三个层级:
| 类型 | 用途 | 运行环境 | 典型场景 |
|---|---|---|---|
| Unit Test | 验证函数/类逻辑 | 模拟器或 Mac | 工具类、算法、ViewModel |
| UI Test | 模拟用户操作流程 | 模拟器或真机 | 登录、支付、导航流 |
| Snapshot Test(需第三方库) | 视觉回归测试 | 模拟器 | UI 布局、主题切换 |
我们团队主要攻坚的是 UI Test,因为这才是产品经理最关心的“用户能不能点进去”。
但 UI Test 有个致命痛点:异步等待。比如点击“支付”按钮后,网络请求需要 2 秒,你怎么知道页面跳转完成了?早期我用 sleep(3),结果 CI 上偶尔超时,本地又浪费时间。后来才学会用 XCTWaiter + expectation:
func testPaymentSuccess() {
let app = XCUIApplication()
app.launch()
// 点击支付按钮
app.buttons["payButton"].tap()
// 等待成功提示出现(最多 5 秒)
let successLabel = app.staticTexts["支付成功"]
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == true"),
object: successLabel
)
XCTWaiter().wait(for: [expectation], timeout: 5.0)
XCTAssertTrue(successLabel.exists)
}
这段代码现在成了我们团队的模板。别再用 sleep 了!面试官看到会皱眉的(别问我怎么知道的)。
性能优化:让 CI 不再是“等待的艺术”
说到 CI,我们用的是 GitHub Actions。但每次 push 后,iOS 测试 job 跑 8 分钟,其中 6 分钟在等模拟器启动。痛定思痛,我做了三件事:
- 复用模拟器设备:通过
xcrun simctl预创建一个干净的模拟器,CI 中直接复用,省去每次安装的开销。 - 并行化测试:把 UI Test 按业务模块拆成多个 target,GitHub Actions 矩阵并行跑。
- 跳过非必要测试:通过
#if DEBUG包裹某些耗时长的视觉测试,只在 nightly build 中运行。
效果立竿见影:平均测试时间从 8 分钟降到 2 分 30 秒。老板看了直呼“技术驱动降本增效”,当场给我加了 200 块团建费(感动哭)。
对了,Spring Boot 后端同事看我折腾,还开玩笑说:“你们 iOS 测试终于赶上我们 Java 的 JUnit 了?” 我回他:“等着,下次我用 Swift 写个 Mock Server,直接集成到 XCTestCase 里,不用你们 TestContainers 了!”(其实已经在做了)
真机 vs 模拟器:求职时别踩这个坑
很多同学在简历上写“精通 iOS 自动化测试”,结果面试一问“真机测试怎么做”,就卡壳了。
XCTest 默认只能在模拟器跑 UI Test。真机测试需要额外配置:
- 开发者账号加入设备 UDID
- Target 的 Bundle Identifier 必须匹配 Provisioning Profile
- 测试 Target 也要签名(很多人忘了这点!)
我们团队因为要测 Face ID 支付流程,必须上真机。于是我在 Jenkins 上配了一台 iPhone 14 Pro 当“奴隶机”(运维小哥吐槽说像矿机)。每次跑测试前,脚本自动 unlock 设备、清除数据、安装最新 build。
但!App Store 审核严禁自动化脚本操控真机提交审核包。所以我们 CI 里只跑模拟器测试,真机测试仅用于内网 Beta 版验证。
这点一定要分清,不然简历写“支持真机自动化测试”可能被质疑合规风险——我秋招面某大厂时就被 challenge 过这个问题。
和 SwiftUI 的甜蜜(且痛苦)协作
我们新功能用 SwiftUI 重写了支付页。本以为声明式 UI 会让测试更简单,结果发现 SwiftUI 的 accessibilityIdentifier 默认是空的!
这意味着 app.buttons["payButton"] 根本找不到元素。解决方案是在 View 里显式加 .accessibilityIdentifier("payButton"):
Button("确认支付") {
viewModel.pay()
}
.accessibilityIdentifier("payButton")
但开发同学嫌麻烦,说“影响代码整洁”。于是我写了篇内部 Wiki,标题就叫《为了你的简历能写“高覆盖率测试”,请加上这行代码》,果然奏效(笑)。
另外,SwiftUI 的动画也会干扰测试。比如淡入效果导致元素短暂不可交互。解决办法是:在测试环境下关闭动画:
// 在 AppDelegate 或 App struct 中
#if DEBUG
if ProcessInfo.processInfo.environment["RUNNING_UI_TESTS"] == "1" {
UIView.setAnimationsEnabled(false)
// SwiftUI 也需要特殊处理
}
#endif
然后在 UI Test 启动时注入环境变量:
override func setUp() {
continueAfterFailure = false
let app = XCUIApplication()
app.launchEnvironment = ["RUNNING_UI_TESTS": "1"]
app.launch()
}
最后:自动化测试不是银弹,但能让你睡好觉
折腾两个月后,我们的核心路径自动化覆盖率从 30% 提升到 78%。上周双 11 大促,凌晨三点收到告警说支付回调异常,我第一反应不是查日志,而是跑了一遍 UI Test——果然复现了问题,定位到是某个 header 没传。
那一刻,我觉得之前写的每一行 XCTestCase 都值了。
如果你也在准备求职,简历上写“使用 XCTest 构建 CI/CD 自动化测试体系,提升回归效率 300%” 绝对比 “熟悉 iOS 开发” 有杀伤力。尤其是投递对质量要求高的外企或金融公司。
当然,别指望靠它拿 offer。但至少,当面试官问“你怎么保证代码质量”时,你能笑着掏出一段跑通的 UI Test 视频——而不是只会说“我认真写单元测试”。
至于我?下周还要重构测试用例的数据驱动部分,顺便研究怎么用 Swift Package 把通用测试工具抽出来。毕竟,研二了,得为秋招攒点硬核项目。
(写完这篇,终于可以关电脑了。窗外凌晨一点的上海,连外卖小哥都下班了。)

评论 0