开篇
iOS 的测试长期处在一个尴尬位置:写起来不难,维护起来很贵。UI 测试脆、快照测试跨设备漂移、CI 上跑一次要二十分钟。结果就是团队逐渐把测试删掉,回到「手点一遍再发版」的老路。
问题的根子通常不在测试框架,而在架构可测性与流水线设计。本文从测试金字塔讲起,先把「写什么测试」讲清楚,再讲「怎么让测试跑得快、跑得稳」,最后落到 Fastlane、Xcode Cloud 与签名管理这些工程化环节。
一、测试金字塔与策略
iOS 的测试不该只有「单元测试」和「UI 测试」两种,而是一个有层次的组合。
| 层级 | 工具 | 数量占比 | 运行时间 | 稳定性 |
|---|---|---|---|---|
| 单元测试 | XCTest / Swift Testing | 约 70% | 毫秒级 | 极高 |
| 集成测试 | XCTest + 真实依赖替身 | 约 20% | 百毫秒级 | 高 |
| 快照测试 | swift-snapshot-testing | 约 5% | 秒级 | 中 |
| UI 测试 | XCUITest | 约 5% | 十秒级 | 低 |
原则很朴素:越往上越贵、越脆、越少。UI 测试只用来覆盖「关键路径能不能走通」,例如登录、下单、支付这三条不能挂的链路,其余细节交给单元测试。
一个常见的反模式是把业务逻辑塞进 UIViewController,导致想测逻辑必须先起一个界面。解法是把逻辑抽成独立的、无 UI 依赖的类型,这本身就是好架构。
二、XCTest 单元测试
XCTest 是 iOS 的元老框架,XCTestCase + XCTAssert* 的组合至今仍是主力。
import XCTest
@testable import ShopKit
final class CartTests: XCTestCase {
private var cart: Cart!
override func setUpWithError() throws {
cart = Cart(taxRate: 0.08)
}
override func tearDownWithError() throws {
cart = nil
}
func testAddItemIncreasesTotal() {
cart.add(Item(name: "Book", price: 100))
XCTAssertEqual(cart.total, 108, accuracy: 0.001)
}
func testRemoveItemBelowZeroThrows() {
XCTAssertThrowsError(try cart.remove(at: 0)) { error in
XCTAssertEqual(error as? CartError, .indexOutOfRange)
}
}
}
要点:
@testable import让测试可以访问internal成员,但不要为了测试把私有实现暴露成public;setUpWithError/tearDownWithError是带throws的现代写法,避免用!强解包;- 浮点比较必须用
accuracy:,否则0.1 + 0.2会让你怀疑人生。
三、async 测试与 actor 测试
Swift 5.5 之后,XCTest 原生支持 async 测试方法,无需再手写 XCTestExpectation。
final class ProfileServiceTests: XCTestCase {
func testFetchProfileReturnsName() async throws {
let service = ProfileService(client: StubHTTPClient())
let profile = try await service.fetch(id: "42")
XCTAssertEqual(profile.name, "Leeting")
}
// 测试 actor 的隔离与并发安全
func testCounterIsSerialized() async {
let counter = CounterActor()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000 {
group.addTask { await counter.increment() }
}
}
let value = await counter.value
XCTAssertEqual(value, 1_000)
}
}
几个必须知道的坑:
- 不要用
XCTestExpectation混搭async,两套等待机制会互相干扰; - 测试
@MainActor隔离的类型时,测试方法也要标@MainActor,否则编译不过; - 测试超时要靠
XCTestCase.executionTimeAllowance或 CI 层的超时,async测试不会自动超时; - 涉及
Task.sleep的测试应注入可控时钟(Swift 5.7+ 的Clock协议),而不是真的睡两秒。
四、依赖注入与测试替身
可测性的核心是依赖可替换。三种注入方式各有取舍:
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 构造器注入 | init(client: HTTPClient) | 显式、易测 | 初始化链变长 |
| 属性注入 | var client: HTTPClient | 灵活 | 可变状态、易忘设 |
| 环境注入 | SwiftUI Environment | 适配视图树 | 隐式、难追踪 |
推荐默认用构造器注入,它让依赖在类型签名上就可见。
测试替身分四种,术语常被混用,这里明确一下:
| 替身类型 | 行为 | 典型用途 |
|---|---|---|
| Stub | 返回预设值 | 提供固定数据 |
| Spy | 记录调用 | 验证「是否被调用」 |
| Mock | 预设期望并自校验 | 交互式断言 |
| Fake | 简化实现 | 内存版数据库 |
protocol HTTPClient {
func get(_ url: URL) async throws -> Data
}
// Stub:返回固定数据
final class StubHTTPClient: HTTPClient {
var result: Result<Data, Error> = .success(Data("{}".utf8))
func get(_ url: URL) async throws -> Data { try result.get() }
}
// Spy:记录调用参数,便于断言
final class SpyHTTPClient: HTTPClient {
private(set) var requestedURLs: [URL] = []
func get(_ url: URL) async throws -> Data {
requestedURLs.append(url)
return Data()
}
}
取舍:不要为了 mock 而 mock。如果一个类型没有外部依赖(纯计算),直接测它的真实实现即可,过度 mock 会让测试只验证了 mock 本身。
五、快照测试
快照测试把视图渲染成图片或文本,与基线对比。它对 UI 回归非常敏感,但也很容易因为系统字体、渲染引擎版本变化而误报。
import SnapshotTesting
import XCTest
final class ProfileCardSnapshotTests: XCTestCase {
func testProfileCardLayout() {
let view = ProfileCard(name: "Leeting", avatar: .init(systemName: "person"))
assertSnapshot(of: view, as: .image(layout: .fixed(width: 320, height: 120)))
}
}
必须遵守的纪律:
- 固定设备与系统版本,在 CI 上指定单一模拟器 runtime;
- 固定语言环境与动态字体设置,否则本地化或大字号会改变渲染;
- 基线必须入库,且修改基线要经过 review,否则「更新快照」会变成掩盖 bug 的快捷键。
六、Swift Testing:Xcode 16 的新框架
Xcode 16 引入了全新的 Swift Testing 框架,与 XCTest 并存。它的写法更贴近 Swift 的现代语法:
import Testing
@Suite("购物车")
struct CartTests {
@Test("加入商品后总价包含税费")
func addItem() {
let cart = Cart(taxRate: 0.08)
cart.add(Item(name: "Book", price: 100))
#expect(cart.total == 108)
}
// 参数化测试:一个函数跑多组数据
@Test(arguments: [(100.0, 108.0), (0.0, 0.0), (50.0, 54.0)])
func taxCalculation(price: Double, expected: Double) {
let cart = Cart(taxRate: 0.08)
cart.add(Item(name: "X", price: price))
#expect(abs(cart.total - expected) < 0.001)
}
}
它与 XCTest 的关键差异:
- 用
#expect/#require替代XCTAssert*,失败信息更精确; - 原生支持参数化测试与
@Suite分组; - 测试可并发执行(默认并行),共享状态需要用
@Suite(.serialized)显式串行; - UI 测试仍必须用 XCUITest,Swift Testing 不覆盖 UI 层。
迁移建议:新项目直接上 Swift Testing,老项目先并存,只把新写的测试用新框架,避免一次性迁移带来的风险。
七、XCUITest 与稳定性
XCUITest 通过 Accessibility 标识驱动真实界面。它脆弱的根本原因是依赖了会变的元素——索引、文案、层级。
final class LoginUITests: XCTestCase {
func testLoginFlow() {
let app = XCUIApplication()
app.launchArguments += ["-uiTesting", "1"] // 让 App 走测试专用配置
app.launch()
let email = app.textFields["login.email"]
XCTAssertTrue(email.waitForExistence(timeout: 5))
email.tap()
email.typeText("demo@example.com")
app.secureTextFields["login.password"].tap()
app.secureTextFields["login.password"].typeText("secret")
app.buttons["login.submit"].tap()
XCTAssertTrue(app.staticTexts["home.title"].waitForExistence(timeout: 10))
}
}
稳定性三板斧:
- 一切可交互元素都给 accessibilityIdentifier,测试只按标识查找,不按文案;
- 永远用
waitForExistence(timeout:),不用XCTAssertTrue(element.exists); - 隔离测试数据,用
launchArguments注入 mock 后端或独立测试账号,避免测试之间互相污染。
八、Fastlane:把发布流程脚本化
Fastlane 是 iOS 自动化的老牌工具,四个核心 action 覆盖了「测 → 构建 → 签名 → 分发」全链路。
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "跑单元测试并生成覆盖率报告"
lane :test do
scan(
scheme: "Shop",
device: "iPhone 15 Pro",
code_coverage: true,
output_types: "html,junit"
)
end
desc "同步签名证书与描述文件"
lane :certs do
match(type: "appstore", readonly: true)
end
desc "构建并上传到 TestFlight"
lane :beta do
certs
gym(scheme: "Shop", export_method: "app-store", include_bitcode: false)
pilot(skip_waiting_for_build_processing: true)
end
end
四个 action 的定位:
| Action | 作用 | 关键参数 |
|---|---|---|
| scan | 跑测试 | scheme、device、code_coverage |
| gym | 构建并导出 IPA | export_method、configuration |
| match | 证书与描述文件管理 | type、readonly |
| pilot | 上传 TestFlight | skip_waiting_for_build_processing |
match 的价值在于把证书和描述文件加密后存进私有 Git 仓库,团队共享同一份,彻底消灭「证书在谁电脑上」的经典问题。
九、Xcode Cloud 与 GitHub Actions
| 维度 | Xcode Cloud | GitHub Actions 自托管 runner |
|---|---|---|
| 维护成本 | 零,Apple 托管 | 需自建 Mac runner |
| 与 Xcode 集成 | 原生,配置可视化 | 靠命令行 |
| 灵活性 | 受限于 Apple 的流程 | 完全自由 |
| 成本 | 按计算时长计费 | 硬件 + 运维 |
| 签名 | 与 App Store Connect 打通 | 靠 fastlane match |
Xcode Cloud 适合「团队小、不想维护 CI 机器」的场景;GitHub Actions 适合「需要自定义流程、已有自托管 Mac 机群」的团队。自托管 runner 的关键是把 Mac 当一次性资源:每次构建前清理 DerivedData,避免缓存污染带来的诡异失败。
# .github/workflows/ios.yml
name: iOS CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: [self-hosted, macOS, arm64]
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: .build
key: spm-${{ hashFiles('Package.resolved') }}
- name: Test
run: bundle exec fastlane test
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: test-results
path: fastlane/test_output
十、TestFlight 与灰度
TestFlight 分内部测试(最多 100 名团队成员,无需审核)与外部测试(最多 10000 人,首个构建需审核)。工程上最有价值的用法是分层灰度:
- 内部测试:每次 CI 构建自动分发,团队自测;
- 外部小范围:挑 50
200 名真实用户,跑 37 天看崩溃率与关键指标; - 全量发布:用 App Store Connect 的分阶段发布(Phased Release)按 1%/2%/5%/10%/20%/50%/100% 七天铺开。
配套指标必须盯住:崩溃率(MXCrashDiagnostic)、卡顿率、启动时间、以及关键业务漏斗。灰度出问题时可以暂停分阶段发布,这是 App Store 上少数可用的「止损」手段。
十一、CI 中的签名与证书管理
签名是 CI 最容易翻车的环节,因为它依赖 Apple 的证书体系与密钥链状态。核心原则:
- 不要往仓库里塞
.p12和.mobileprovision,用match的加密仓库; - CI 上必须先建临时钥匙串,再导入证书,构建后删除;
match在 CI 上使用readonly: true,避免构建过程意外改动证书仓库;- App Store Connect API Key(
.p8)比 Apple ID + 应用专用密码更安全,且不会因为 2FA 失效。
# CI 上创建临时钥匙串的典型步骤
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# 后续交给 fastlane match 导入证书
bundle exec fastlane certs
十二、CI 缓存与构建加速
一个中等规模的 iOS 工程全量构建轻松超过 15 分钟,缓存是唯一的解药。
| 缓存对象 | 收益 | 注意事项 |
|---|---|---|
SwiftPM .build | 高 | key 用 Package.resolved 哈希 |
CocoaPods Pods/ | 高 | 需与 Podfile.lock 绑定 |
| DerivedData | 中高 | 必须严格匹配 Xcode 版本 |
| Xcode 编译缓存 | 中 | 用 xccache 之类工具 |
此外还有几条通用提速手段:
- 用
xcodebuild -parallelizeTargets并行构建 target; - UI 测试拆分到多台 runner 并行跑,缩短反馈时间;
- 关闭不必要的
dSYM生成(Debug 配置); - 用
-skipPackagePluginValidation跳过无关校验。
十三、常见 CI 坑清单
- 模拟器 runtime 未安装:CI 机器上
xcodebuild报找不到 destination,需要预先xcodebuild -downloadPlatform iOS。 - Xcode 版本漂移:CI 默认 Xcode 与本地不一致,必须显式
xcode-select -s。 - 钥匙串超时锁定:构建中途证书失效,用
set-keychain-settings -t 3600延长。 @testable import在 Release 配置失败:Release 不生成.swiftmodule供测试导入,测试必须跑在 Debug 配置。- UI 测试并发跑导致互相干扰:同一模拟器上并发会抢焦点,要么串行,要么每台机器独立模拟器。
- 缓存污染:DerivedData 跨 Xcode 版本复用会导致玄学编译错误,缓存 key 必须带 Xcode 版本。
match在 CI 上非 readonly:可能触发证书仓库意外提交,务必加readonly: true。- 上传 TestFlight 后立刻用:构建要经过处理(processing)才可用,CI 里应轮询状态而不是硬等固定时间。
- 忘记清理旧构建:App Store Connect 上过期构建会拖慢列表,定期用 API 清理。
- 本地能过 CI 不过:八成是环境变量、时区或 locale 差异,测试里禁止依赖当前时区。
取舍:CI 的目标不是「全绿」,而是快速给出可信信号。宁可少跑几个脆弱的 UI 测试,也不要让团队养成「红了就重跑」的习惯。
相关阅读
- iOS 性能调优与 Instruments 剖析
— 用
XCTApplicationLaunchMetric把性能基线接入 CI 门禁 - Swift 协议与泛型 — 协议抽象是可测性的前提,mock 与依赖注入都建立在其上
- App Store 上架流程与签名机制 — 签名、描述文件与 TestFlight 分发的完整背景
小结
本文把 iOS 测试与持续集成串成一条完整链路:
- 测试金字塔决定了投入分配——单元测试占大头,UI 测试只覆盖关键路径;
- XCTest 与 Swift Testing 并存,async 测试、actor 测试、参数化测试各有明确写法,迁移宜渐进不宜激进;
- 可测性的本质是依赖可替换,构造器注入加协议抽象是成本最低的方案,测试替身要按需选择,不要为 mock 而 mock;
- 快照测试必须锁死设备与系统版本,基线变更要过 review;
- XCUITest 的稳定性来自 accessibilityIdentifier、
waitForExistence与数据隔离; - Fastlane 的 scan/gym/match/pilot 覆盖了从测试到分发的全流程,
match解决了团队证书共享的顽疾; - Xcode Cloud 与 GitHub Actions 各有取舍,自托管 runner 的关键是把 Mac 当一次性资源;
- 签名管理的核心原则是密钥不入库、CI 建临时钥匙串、用 API Key 而非账号密码;
- 最后,CI 的价值在于快速给出可信信号,而不是追求形式上的全绿。
测试与 CI 是典型的「前期投入、长期回报」工程。它不会让功能更快上线,但会让每一次发版都不再是一场赌博。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。