URLSession单元测试未触发MockURLProtocol的requestHandler问题
iOS单元测试URLProtocol Mock失效、请求计数异常问题修复
问题涉及的现有实现
- 自定义
MockURLProtocol继承URLProtocol实现网络Mock:- 静态属性
contactedURLs记录所有触发请求的URL - 静态属性
callCount统计请求调用次数 - 静态方法
clear()用于重置所有内部状态 - 静态闭包
requestHandler支持自定义返回预设HTTPURLResponse与测试Data - 重写
canInit(with:)、canonicalRequest(for:)、startLoading()、stopLoading()实现核心Mock逻辑
- 静态属性
- 封装
MovieRequest结构体处理电影列表拉取:- 持有
urlSession属性,初始化方法支持传入自定义URLSession实例,默认参数为URLSession.shared - 内部
getMovies(completion:)方法通过持有的session调用dataTask(with:completionHandler:)发起请求,依次处理网络错误、HTTP状态码校验、JSON解码逻辑,最终通过completion闭包返回Result<[Movie], MovieError>结果
- 持有
- 测试类
MovieTaskTests的setUp()配置:#if DEBUG分支下创建URLSessionConfiguration.default实例,将protocolClasses设置为[MockURLProtocol.self],用该配置初始化自定义URLSession#else分支直接使用URLSession.shared- 初始化被测ViewController实例
sut与MovieRequest实例movieRequest,将配置好的URLSession注入movieRequest
异常现象
- 运行测试用例
test_FetchShouldBeCalledOnceWithThisURL()时,movieRequest始终使用URLSession.shared发起真实网络请求,未触发MockURLProtocol.requestHandler返回预设测试数据,拿到真实接口返回的全量JSON - 删除
#else分支强制使用自定义配置的URLSession后,请求返回空数组,MockURLProtocol.callCount统计值为3,不符合预期的1次调用,仅MockURLProtocol.contactedURLs的URL校验逻辑可正常通过
根因说明
- 条件编译分支失效:XCTest测试Target默认不会继承主Target的
DEBUG预处理宏,若未手动给测试Target配置DEBUG=1的预处理宏,#if DEBUG分支不会执行,默认走#else分支的URLSession.shared。
注意:URLSession.shared是全局单例,系统不支持修改其配置的protocolClasses属性,任何针对shared session的protocol注册操作都不会生效,必须通过URLSession(configuration:)初始化的自定义session才能使用自定义URLProtocol。 - 请求计数为3、返回空数组的核心原因是
MockURLProtocol的startLoading()实现缺失关键回调:自定义URLProtocol在返回响应数据后,必须调用urlProtocolDidFinishLoading(_:)告知URLSession请求已完成,否则系统会判定请求失败,默认触发2次重试,加上初始请求总次数刚好为3;如果未按顺序回调响应头、响应体方法,上层逻辑拿不到正确数据就会解析出空数组。 - 缺少请求去重标记:如果未在
canonicalRequest(for:)中给已拦截的请求打标记,同一个请求可能被URLProtocol系统逻辑多次触发canInit判断,导致重复计数。
修复步骤
- 移除测试配置中的
#if DEBUG/#else条件分支,测试Target内固定使用注册了MockURLProtocol的自定义配置初始化URLSession,不要依赖DEBUG宏判断测试环境。如果需要保留条件判断,手动给测试Target添加预处理宏:打开测试Target的Build Settings,搜索Preprocessor Macros,在Debug配置项中添加DEBUG=1。 - 补全
startLoading()的完整回调逻辑,参考实现:
override func startLoading() { guard let handler = MockURLProtocol.requestHandler else { fatalError("Forgot to set mock request handler in test") } // 统计计数、记录URL Self.callCount += 1 if let url = request.url { Self.contactedURLs.append(url) } 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 class func canInit(with request: URLRequest) -> Bool { // 仅拦截业务相关的接口请求,过滤系统探测、ATS校验等无关请求 guard let host = request.url?.host, host == "你的业务接口域名" else { return false } // 已经标记处理过的请求不再重复拦截 return URLProtocol.property(forKey: "MockURLProtocolHandledKey", in: request) == nil } override class func canonicalRequest(for request: URLRequest) -> URLRequest { guard let mutableRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else { return request } // 给当前请求打已处理标记 URLProtocol.setProperty(true, forKey: "MockURLProtocolHandledKey", in: mutableRequest) return mutableRequest.copy() as! URLRequest }
- 补充测试生命周期清理逻辑:在
tearDown()方法中调用MockURLProtocol.clear()重置静态状态,同时调用自定义session的invalidateAndCancel()方法释放资源,避免不同测试用例之间的状态污染。
内容的提问来源于stack exchange,提问作者Nikola
相关产品推荐
相关产品推荐

