4. 测试异步代码:XCTestExpectation的使用、等待超时处理、异步网络请求测试实战

异步代码的测试,说实话,是很多iOS开发者的一个坎。

我记得刚带团队那会儿,有个同事跑过来跟我说:「我写的网络请求方法,单测怎么都过不了,明明接口返回是对的。」我过去一看,好家伙,测试方法执行完了,异步回调还没回来呢。这就是典型的「测试跑得比代码快」的问题。

今天我们就来聊聊怎么用 XCTestExpectation 驯服异步代码。

4.1 为什么需要 XCTestExpectation?

你想想看,单元测试默认是同步执行的。测试方法一调用,从上往下跑,跑完就结束。但异步代码呢?它把结果放在回调里,等网络回来了、等动画结束了、等队列调度完了才执行。

说白了,测试框架和异步回调之间,缺一个「信使」。这个信使就是 XCTestExpectation。

核心思想: 测试方法等待一个「预期」被 fulfilled,超时了就报错。

4.2 基本用法:三步走

我个人习惯把 XCTestExpectation 的使用总结成三步:

  1. 创建预期 —— 告诉测试框架「我要等一个东西」
  2. 等待预期 —— 设置超时时间,阻塞当前线程
  3. fulfill 预期 —— 在异步回调里标记「我完成了」

来看一个最简单的例子:

func testAsyncOperation() {
    // 第一步:创建预期
    let expectation = XCTestExpectation(description: "异步操作完成")

    // 模拟一个异步任务
    DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
        // 第三步:标记完成
        expectation.fulfill()
    }

    // 第二步:等待,超时设为 2 秒
    wait(for: [expectation], timeout: 2.0)
}

嗯,这里要注意:fulfill() 必须在 wait 之前被调用,否则测试会超时报错。我在项目中遇到过好几次这种低级错误,排查了半天才发现是 fulfill 写在了 wait 后面。

4.3 等待超时处理

超时处理是异步测试里最容易踩坑的地方。设短了,网络慢一点就误报;设长了,整个测试套件跑起来慢得让人抓狂。

我的建议是:

  • 单元测试:超时设 1-2 秒,因为不应该依赖真实网络
  • 集成测试:超时设 5-10 秒,给真实网络留点余量
  • UI 测试:超时可以设到 30 秒,因为涉及界面渲染和动画
测试类型 推荐超时 说明
单元测试(Mock 网络) 1-2 秒 不依赖真实网络,纯逻辑验证
集成测试(真实网络) 5-10 秒 考虑网络延迟和服务器响应
UI 测试 15-30 秒 涉及界面加载和动画完成

注意: 超时时间不是越长越好。设得太长,CI 流水线会变得极其缓慢。我曾经见过一个项目,所有异步测试都设了 30 秒超时,结果跑一次全量测试要 40 分钟。后来统一优化到 5 秒,时间直接砍半。

4.4 多个预期的等待

有时候一个测试方法里要等好几个异步操作。比如同时发起两个网络请求,都返回了才算完成。

XCTestExpectation 支持同时等待多个预期:

func testMultipleAsyncOperations() {
    let exp1 = XCTestExpectation(description: "请求A完成")
    let exp2 = XCTestExpectation(description: "请求B完成")

    fetchDataA { result in
        XCTAssertNotNil(result)
        exp1.fulfill()
    }

    fetchDataB { result in
        XCTAssertNotNil(result)
        exp2.fulfill()
    }

    // 等待两个预期都 fulfill,或者任何一个超时
    wait(for: [exp1, exp2], timeout: 5.0)
}

这里有个小细节:wait(for:timeout:) 默认是等待所有预期都 fulfill。如果你想「只要有一个完成就算通过」,可以用 wait(for:timeout:enforceOrder:) 配合 XCTestExpectationisInverted 属性。不过说实话,这种场景在实际项目中用得不多。

4.5 异步网络请求测试实战

好,理论说完了,我们来点实战的。这是我最想分享的部分。

假设我们有一个网络请求类 APIClient,它有一个方法 fetchUserProfile

class APIClient {
    func fetchUserProfile(userId: String, completion: @escaping (Result<UserProfile, Error>) -> Void) {
        let url = URL(string: "https://api.example.com/users/\(userId)")!
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            guard let data = data else {
                completion(.failure(NSError(domain: "No data", code: -1)))
                return
            }
            do {
                let profile = try JSONDecoder().decode(UserProfile.self, from: data)
                completion(.success(profile))
            } catch {
                completion(.failure(error))
            }
        }.resume()
    }
}

要测试这个方法,最直接的方式是发起真实网络请求:

func testFetchUserProfileSuccess() {
    let expectation = XCTestExpectation(description: "获取用户资料成功")
    let client = APIClient()

    client.fetchUserProfile(userId: "12345") { result in
        switch result {
        case .success(let profile):
            XCTAssertEqual(profile.id, "12345")
            XCTAssertNotNil(profile.name)
        case .failure(let error):
            XCTFail("请求失败: \(error.localizedDescription)")
        }
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 10.0)
}

我的建议: 真实网络请求的测试,尽量只在集成测试阶段跑。单元测试阶段,我强烈建议用 Mock 来模拟网络层。为什么呢?因为真实网络不稳定,CI 环境可能没网,而且你也不想每次跑单测都去请求别人的服务器吧?

4.6 用 Mock 替代真实网络请求

我个人习惯用 URLProtocol 来做网络层的 Mock。这样不需要改业务代码,只需要在测试里注册一个自定义的 URLProtocol:

class MockURLProtocol: URLProtocol {
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

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

    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            XCTFail("未设置 requestHandler")
            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 testFetchUserProfileWithMock() {
    let expectation = XCTestExpectation(description: "Mock 请求成功")

    // 配置 Mock 数据
    let mockData = """
    {
        "id": "12345",
        "name": "张三",
        "email": "zhangsan@example.com"
    }
    """.data(using: .utf8)!

    MockURLProtocol.requestHandler = { request in
        let response = HTTPURLResponse(
            url: request.url!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )!
        return (response, mockData)
    }

    // 注册 Mock URLProtocol
    let config = URLSessionConfiguration.default
    config.protocolClasses = [MockURLProtocol.self]
    let session = URLSession(configuration: config)

    let client = APIClient(session: session) // 假设 APIClient 可以注入 session
    client.fetchUserProfile(userId: "12345") { result in
        switch result {
        case .success(let profile):
            XCTAssertEqual(profile.name, "张三")
        case .failure:
            XCTFail("Mock 请求不应该失败")
        }
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 2.0)
}

关键点: 用 Mock 测试异步网络请求,速度极快(毫秒级),而且结果可预测。这才是单元测试该有的样子。

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 忘记调用 fulfill —— 测试会一直等到超时,然后报错。我曾经排查了一个小时,发现是回调里有个提前 return 忘了 fulfill。
  • 在主线程 wait —— 如果你在 wait 的同时,异步回调也在主线程执行,会造成死锁。解决方案:确保异步回调不在主线程,或者用 DispatchQueue.main.async 包裹 fulfill。
  • 超时设得太短 —— 特别是在 CI 环境里,机器性能差,网络慢,2 秒超时经常误报。我建议 CI 环境单独配置一个更宽松的超时时间。
  • 多个预期只 fulfill 了一个 —— 如果你用 wait(for: [exp1, exp2], timeout:),但只 fulfill 了 exp1,测试会一直等到超时。记得检查每个回调路径是否都调用了 fulfill。

嗯,异步测试说白了就是「等」的艺术。等对了,测试稳如泰山;等错了,测试跑得比代码还快,结果全是假通过。希望今天的内容能帮你少踩几个坑。