比后端先把页面做完:用 URLProtocol 做一个 API Mock 工具

在 URLSession 底层拦截请求,让页面、网络层、解码和错误处理都走真实逻辑,并能切换加载、401、500、超时和断网状态,以及 mock 仍然回答不了的问题。

文章目录
  1. Mock 放在哪一层
  2. 不放过错误的 route
  3. 不只支持正常数据
  4. App 如何切换数据源
  5. 单元测试
  6. 回顾

我之前在一个老 UIKit 项目里做新模块,包含概览页、列表页和详情页。当时不想等后端接口就绪,打算先把页面和各种状态全调通。如果直接把假数据写死在 view model 里,后面换成真接口还得重写那部分代码。

我想要的效果是让页面、view model、网络请求、JSON 解码和错误处理全走真实逻辑,唯一被替换掉的只有数据来源。

最后做出来的东西是一个本地 Swift package。

Mock 放在哪一层

做 mock 常见的位置有这么几个:

  • 直接把数据写死在 view model 里。 速度最快,但网络层、解码和错误映射完全没有跑过。
  • 在 service 协议后面换一个假实现。 这样好一些,但还是绕过了网络层,数据解码可能会出的问题依然发现不了。
  • 在 URLSession 底层拦截请求。 上层所有链路都是真实的,包括项目里的 Alamofire、解码逻辑和状态码处理,只是请求不会真的离开设备。

我选了第三种方案。Foundation 提供的 URLProtocol 就是做这个的。下面是我简化后的核心结构:

final class MockURLProtocol: URLProtocol {
    private let queue = DispatchQueue(label: "MockURLProtocol")
    private let lock = NSLock()
    private var isStopped = false

    override class func canInit(with request: URLRequest) -> Bool {
        let scheme = request.url?.scheme?.lowercased()
        return scheme == "http" || scheme == "https"
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        do {
            let result = try MockKit.result(for: request)
            queue.asyncAfter(deadline: .now() + .milliseconds(result.delayMilliseconds)) { [weak self] in
                self?.deliver(result)
            }
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {
        lock.lock()
        isStopped = true
        lock.unlock()
    }

    private func deliver(_ result: MockResult) {
        lock.lock()
        let stopped = isStopped
        lock.unlock()
        guard !stopped else { return }

        switch result.outcome {
        case .response(let mock):
            guard let url = request.url,
                  let response = HTTPURLResponse(url: url, statusCode: mock.statusCode,
                                                 httpVersion: "HTTP/1.1", headerFields: mock.headers) else {
                client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
                return
            }
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: mock.body)
            client?.urlProtocolDidFinishLoading(self)
        case .failure(let error):
            client?.urlProtocol(self, didFailWithError: error)
        }
    }
}

延迟和停止检查这两个细节很重要。如果不加延迟,页面的 loading 状态根本看不到。如果不做停止检查,已经被取消的请求可能还会继续回调响应。

接入点只有一个,网络层管理类单独建第二个 session,并在 configuration 里把这个 protocol 注册到最前面。只有显式开启 mock 的模块才会用这个 session,其他模块依然走真实网络。

请求链路:view model 调用 provider 和网络层。正常 session 访问真实服务器;mock session 被 MockURLProtocol 拦截,并按 variant 分发到本地 JSON 或错误。两种情况下解码和错误处理逻辑完全一致。

不放过错误的 route

每个接口对应一条 route,包含请求方法、可能带参数的路径,以及具体的 query 参数。

struct MockRequestPattern {
    let method: String
    let path: String // e.g. "/api/v1/items/{itemId}"
    let query: [String: String]
}

这里需要注意的是 query。一条 route 必须在 query 参数完全一致时才算命中,参数重复或者没有传值都算匹配失败。如果 mock 随便什么 query 都能命中,就会把客户端拼错参数的问题掩盖过去,等到接真实接口才暴露。

路径里的参数可以替换到数据文件名里,这样不同的 ID 就能返回不同的 JSON 文件。这些数据文件放在 package 的资源目录里,通过 Bundle.module 读取。空的路径、绝对路径以及带 .. 的相对路径都会被拒绝,如果找不到对应的文件,会直接抛错,而不是当成空数据成功返回。

最开始这些 route 是写在 JSON 配置文件里的,第二天我把它们全改成了 Swift 代码。这样 route 的定义由编译器检查,每个 route 在初始化时也必须指定一个默认 variant,否则就会触发 precondition 崩溃。后面这个检查是在运行时进行的,另外自定义的 variant 名字依然是字符串,所以 Swift 在这里帮我拦住了一部分问题,但不是全部。

不只支持正常数据

加载中、未登录、服务端报错、超时以及断网这些状态平时很难稳定复现,所以一条 route 可以定义多个 variant:

struct MockVariant: Hashable, ExpressibleByStringLiteral {
    let rawValue: String
    init(stringLiteral value: String) { rawValue = value }

    static let `default`: MockVariant = "default"
    static let loading: MockVariant = "loading"           // same data, 750 ms delay
    static let unauthorized: MockVariant = "unauthorized" // 401 + error body
    static let serverError: MockVariant = "serverError"   // 500 + error body
    static let timeout: MockVariant = "timeout"           // URLError(.timedOut)
    static let offline: MockVariant = "offline"           // URLError(.notConnectedToInternet)
}

一个 variant 要么返回状态码和响应体,要么像请求完全没连上服务器那样直接抛出 URLError。切换起来只要一行代码:

MockKit.activate(.serverError, for: .itemOverview)

并不是每条 route 都要配齐所有 variant。第一版里只有概览页的 route 做了全部 6 种,列表和详情页只做了 default 和 loading。覆盖 variant 的逻辑加了锁来保证线程安全,因为测试用例是并行跑的,不加锁就会相互干扰。

App 如何切换数据源

view model 完全感知不到 mock 的存在。provider 在初始化时确定数据源,并把它传给网络层,网络层再决定使用哪一个 session:

enum APISource: String { case real, mock }

struct ItemProvider {
    let source: APISource

    func fetchOverview() -> Observable<ItemOverview> {
        network.request(.itemOverview, session: source == .mock ? .mock : .live)
    }
}

开发阶段默认使用 mock 数据源,也支持在运行时手动覆盖。后来我又加了一条限制,release 构建必须强制走真实数据源,并且忽略任何保存过的覆盖配置,免得调试开关不小心带到正式环境里。这个保护其实一开始就该做上。

单元测试

这个 package 用 Swift Testing 写了一套自己的测试,覆盖了 route 匹配、路径参数、每个 variant 的状态码和错误、文件缺失以及非法路径。如果 mock 工具本身不可靠,上面写的业务代码自然也靠不住。

@Test func serverErrorVariantReturns500() throws {
    MockKit.activate(.serverError, for: .itemOverview)
    let request = URLRequest(url: URL(string: "https://example.com/api/v1/items/overview")!)
    let result = try MockKit.result(for: request)
    guard case .response(let mock) = result.outcome else {
        Issue.record("Expected a response")
        return
    }
    #expect(mock.statusCode == 500)
}

第一版一共 3 条 route,16 个测试,代码大概 660 行。随着这个业务模块功能增加,后面扩充到了 6 条 route,18 个测试,代码量在 740 行左右。

回顾

做这件事的好处很实在。我可以提早把页面、交互和各种异常状态做完并拿去演示,同时这些页面已经跑在真实的网络层和解码逻辑上了。但 mock 解决不了接口契约对不对齐的问题。mock 能有多准,完全取决于写进去的契约本身对不对,切到真实接口之后该测的依然要认真测。

还有几个可以改进的地方:

  • 切换 variant 目前还要改代码,如果能做个 debug 菜单会方便很多。
  • URLProtocol 只能拦截配置了它的 session。如果其他第三方库自己新建了 session,那些请求就抓不到了。
  • 目前只覆盖了这一个模块的接口。如果要推广到更多地方,还得重新考虑 route 的组织方式和维护那些数据文件的成本。