使用URLProtocol子类测试async/await URLSession调用时崩溃问题
问题背景
之前用Rob Napier的“Mockery of protocols”方案做网络请求测试,现在尝试改用继承URLProtocol子类的方式存根网络请求,但遇到了兼容性问题:基于闭包回调的URLSession测试用例能正常通过,但async/await风格的调用会触发EXC_BREAKPOINT崩溃(错误码:code=1, subcode=0x1a587a0f0)。同时也想确认Transport同步存根方案在async/await环境下是否可行。
崩溃原因
本质是传统URLProtocol的实现逻辑没适配async/await的任务生命周期。闭包回调模式下,URLSession靠客户端闭包接收结果后自行结束任务;但async/await的URLSession API依赖URLProtocolClient的完整信号流转——如果URLProtocol子类没正确调用urlProtocolDidFinishLoading(_:)(成功场景)或urlProtocol(_:didFailWithError:)(失败场景),async/await的Task会一直处于挂起状态,最终触发底层断点崩溃。
修复方案:适配async/await的URLProtocol子类实现
核心修改点
确保startLoading()方法中,无论成功或失败,都完整调用URLProtocolClient的回调方法,给async/await任务明确的结束信号。
示例实现:
class MockURLProtocol: URLProtocol { static var mockMap: [URL: (data: Data?, response: URLResponse?, error: Error?)] = [:] override class func canInit(with request: URLRequest) -> Bool { return mockMap.keys.contains(request.url!) } override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } override func startLoading() { guard let url = request.url, let mock = MockURLProtocol.mockMap[url] else { client?.urlProtocol(self, didFailWithError: NSError(domain: "MockProtocol", code: -1)) return } // 模拟错误场景 if let error = mock.error { client?.urlProtocol(self, didFailWithError: error) return } // 发送响应头 if let response = mock.response { client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) } // 发送响应数据 if let data = mock.data { client?.urlProtocol(self, didLoad: data) } // 必须调用:标记任务完成,否则async/await任务会挂起崩溃 client?.urlProtocolDidFinishLoading(self) } override func stopLoading() {} }
配套测试配置
测试时必须使用注册了自定义URLProtocol的URLSession实例,不能用默认的URLSession.shared:
func testAsyncAwaitRequest() async throws { // 1. 配置自定义Session let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] let session = URLSession(configuration: config) // 2. 设置存根数据 let testURL = URL(string: "https://test.example.com")! MockURLProtocol.mockMap[testURL] = ( data: "async test".data(using: .utf8), response: HTTPURLResponse(url: testURL, statusCode: 200, httpVersion: nil, headerFields: nil), error: nil ) // 3. 执行async/await请求并断言 let (data, response) = try await session.data(from: testURL) XCTAssertEqual(String(data: data, encoding: .utf8), "async test") XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) // 清理存根 MockURLProtocol.mockMap.removeValue(forKey: testURL) }
Transport方案在async/await环境下的可行性
完全可行,甚至比URLProtocol方案更适配async/await。Transport方案通过抽象网络层协议,彻底解耦业务代码与URLSession,测试时直接替换为Mock实现,无需依赖系统的URLProtocol机制,也不需要XCTestExpectation。
示例实现
- 定义支持async/await的Transport协议:
protocol NetworkTransport { func data(from url: URL) async throws -> (Data, URLResponse) // 可扩展其他网络方法,如upload、download等 }
- 真实网络实现:
class URLSessionTransport: NetworkTransport { private let session: URLSession init(session: URLSession = .shared) { self.session = session } func data(from url: URL) async throws -> (Data, URLResponse) { try await session.data(from: url) } }
- Mock测试实现:
class MockTransport: NetworkTransport { var stubbedData: Data? var stubbedResponse: URLResponse? var stubbedError: Error? func data(from url: URL) async throws -> (Data, URLResponse) { if let error = stubbedError { throw error } guard let data = stubbedData, let response = stubbedResponse else { throw NSError(domain: "MockTransport", code: -1) } return (data, response) } }
- 测试用例:
func testTransportAsyncAwait() async throws { let mockTransport = MockTransport() let testURL = URL(string: "https://test.example.com")! // 设置存根 mockTransport.stubbedData = "transport test".data(using: .utf8) mockTransport.stubbedResponse = HTTPURLResponse(url: testURL, statusCode: 200, httpVersion: nil, headerFields: nil) // 执行请求并断言 let (data, response) = try await mockTransport.data(from: testURL) XCTAssertEqual(String(data: data, encoding: .utf8), "transport test") XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) }
这种方案的优势在于:测试代码更简洁,避免了URLProtocol的全局状态管理问题,同时完全适配async/await的结构化并发特性。
内容的提问来源于stack exchange,提问作者Chris

