用 URLProtocol 建立可复现的 iOS API 离线回归测试

用 URLProtocol 建立可复现的 iOS API 离线回归测试

远程构建一旦依赖真实测试接口,就容易出现同一提交上午通过、下午失败的情况:测试数据被改写、令牌过期、网关限流,或者接口只是暂时变慢。更麻烦的是,失败现场通常只留下一个解码错误,无法确认客户端究竟收到了什么。解决办法不是增加重试,而是把网络层分成两类验证:大部分回归测试使用本地固定响应,少量契约测试再访问真实环境。

先划清离线测试的边界

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.jsonprojects-empty.jsonprojects-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 mini

按任务长度选择云端 Mac 租期

两档 M4 配置可按天、周、月或季租用,节点与实际可用信息以控制台实时返回为准。

选择配置并订购