4. 测试异步代码:XCTestExpectation的使用、等待超时处理、异步网络请求测试实战
异步代码的测试,说实话,是很多iOS开发者的一个坎。
我记得刚带团队那会儿,有个同事跑过来跟我说:「我写的网络请求方法,单测怎么都过不了,明明接口返回是对的。」我过去一看,好家伙,测试方法执行完了,异步回调还没回来呢。这就是典型的「测试跑得比代码快」的问题。
今天我们就来聊聊怎么用 XCTestExpectation 驯服异步代码。
4.1 为什么需要 XCTestExpectation?
你想想看,单元测试默认是同步执行的。测试方法一调用,从上往下跑,跑完就结束。但异步代码呢?它把结果放在回调里,等网络回来了、等动画结束了、等队列调度完了才执行。
说白了,测试框架和异步回调之间,缺一个「信使」。这个信使就是 XCTestExpectation。
核心思想: 测试方法等待一个「预期」被 fulfilled,超时了就报错。
4.2 基本用法:三步走
我个人习惯把 XCTestExpectation 的使用总结成三步:
- 创建预期 —— 告诉测试框架「我要等一个东西」
- 等待预期 —— 设置超时时间,阻塞当前线程
- 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:) 配合 XCTestExpectation 的 isInverted 属性。不过说实话,这种场景在实际项目中用得不多。
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。
嗯,异步测试说白了就是「等」的艺术。等对了,测试稳如泰山;等错了,测试跑得比代码还快,结果全是假通过。希望今天的内容能帮你少踩几个坑。