远程构建一旦依赖真实测试接口,就容易出现同一提交上午通过、下午失败的情况:测试数据被改写、令牌过期、网关限流,或者接口只是暂时变慢。更麻烦的是,失败现场通常只留下一个解码错误,无法确认客户端究竟收到了什么。解决办法不是增加重试,而是把网络层分成两类验证:大部分回归测试使用本地固定响应,少量契约测试再访问真实环境。
先划清离线测试的边界
URLProtocol 位于 URLSession 的请求链路中,可以在请求真正发出前返回自定义响应。它适合验证四件事:请求方法与路径是否正确、请求头和请求体是否完整、模型能否解码、业务层能否把状态码映射为明确错误。
它不负责证明真实服务当前可访问,也不能发现网关配置变化。因此建议把测试分层:
| 层级 | 数据来源 | 主要检查 | 执行频率 |
|---|---|---|---|
| 单元回归 | 本地 JSON 固件 | 解码与错误映射 | 每次提交 |
| 网络层回归 | URLProtocol | 请求与响应闭环 | 每次提交 |
| 契约测试 | 受控测试接口 | 字段与鉴权契约 | 定时或发布前 |
离线回归的目标是消除无关波动,不是伪造一次永远成功的请求。404、429、500、空响应和损坏 JSON 都应成为固定测试用例。
注入独立的 URLSession
不要在测试中全局注册协议,也不要让业务代码直接访问 URLSession.shared。给 APIClient 注入一个会话,生产环境使用默认配置,测试环境使用 ephemeral 配置。这样不会继承磁盘缓存、Cookie 或旧连接状态。
final class StubURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "api.test.invalid"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
func makeTestSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubURLProtocol.self]
configuration.timeoutIntervalForRequest = 3
return URLSession(configuration: configuration)
}
canInit 必须限制主机或自定义请求头。若无条件返回 true,测试进程里的其他 URLSession 也可能被截获,错误会变得难以定位。
把响应固件当作代码维护
固件建议放在测试 Target 的 Fixtures/API/v1/ 下,按资源和场景命名,例如 projects-success.json、projects-empty.json 与 projects-malformed.json。不要直接保存包含动态令牌、邮箱或内部地址的完整抓包。
提交前可以统一排序键并验证语法:
find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
tmp="${file}.tmp"
jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done
成功用例仍要断言请求本身。只检查最终模型,可能掩盖客户端把 GET 写成 POST、漏传版本头或重复编码查询参数的问题。
StubURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/v1/projects")
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
let data = try Data(contentsOf: fixtureURL("projects-success"))
let response = HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
错误分支要逐类验收
至少覆盖传输失败、HTTP 失败和内容失败。传输失败可抛出 URLError(.timedOut);HTTP 失败应返回真实状态码和结构化错误体;内容失败则返回 200,但提供缺字段、类型错误或截断 JSON。三者在业务层不应都变成同一个“未知错误”。
超时测试不要真的等待几十秒。让 handler 直接抛出对应错误,验证界面模型是否进入可重试状态。对于 429,应同时检查客户端是否读取 Retry-After,但测试中不要真的休眠;把退避计算抽成纯函数,单独验证输入与结果。
每个测试结束后把 handler 设为 nil。若测试套件开启并行执行,单个静态 handler 会被其他用例覆盖。最稳妥的起点是让该套件串行运行;确需并行时,使用请求 ID 到响应闭包的映射,并用锁或 actor 保护注册表。
在云端 Mac 上稳定执行
在 DplyMini 的云端 Mac 上,先固定 Xcode 选择,再用同一个 workspace、scheme 和测试计划运行。独享物理机是非虚拟机,但测试仍应显式隔离 DerivedData,避免不同分支共享中间产物。
set -euo pipefail
sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan NetworkRegression \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$PWD/.derived-data" \
-resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"
首次执行前用 xcodebuild -showdestinations 核对当前运行环境已有的模拟器名称,不要把个人机器上的设备名称直接复制进脚本。失败时保留 xcresult、测试日志与对应固件版本;不要只截取控制台最后几行。
最终验收应满足:断开外部网络后离线套件仍能完成;连续运行结果一致;固件变化能在代码审查中看清;真实契约测试失败时,不会阻塞开发者判断客户端逻辑是否回归。做到这四点,网络测试才真正从“依赖接口运气”变成可维护的工程资产。
常见问题
URLProtocol 测试能完全替代真实接口联调吗?
不能。它适合稳定验证客户端请求构造、响应解码和错误映射;上线前仍应保留少量真实环境契约测试,用于发现鉴权、网关和服务端字段变化。
为什么测试必须使用单独创建的 URLSession?
单独注入 ephemeral 配置可以限定拦截范围,避免共享缓存、Cookie 和全局协议注册污染其他测试,也便于每个测试独立设置响应。
并行测试时如何避免静态 handler 互相覆盖?
最简单的做法是让该测试套件串行执行。需要并行时,应按请求标识保存响应,并用锁或 actor 保护注册表,而不是共享一个可变的全局 handler。
按任务长度选择云端 Mac 租期
两档 M4 配置可按天、周、月或季租用,节点与实际可用信息以控制台实时返回为准。