无需XCTestExpectation或固定等待时长,如何对基于Combine的异步代码进行单元测试?
嘿,这个问题我太有共鸣了——用XCTestExpectation写测试不仅代码繁琐,还经常因为超时时间设置的问题导致测试不稳定,完全不符合我们想要的简洁可靠的测试体验。刚好Combine有几种完美适配你需求的测试方式,不用等待,不用期望,和async/await的测试思路一致,我结合你给的fetchData例子给你拆解一下:
方法一:用Combine的values属性配合async/await(iOS 15+)
从iOS 15、macOS 12开始,Combine给所有Publisher添加了values属性,它会返回一个AsyncSequence,这意味着你可以直接用async/await语法来订阅并获取发布者的输出,和测试普通的异步函数完全一样。
测试成功场景的代码示例:
import XCTest import Combine final class DataFetchTests: XCTestCase { // 用来存订阅,避免提前释放 private var cancellables = Set<AnyCancellable>() func testFetchData_Success() async throws { // 第一步:Mock你的异步依赖(比如这里的someAsyncCall) // 假设我们模拟一个成功返回的响应,包含预期的Data let expectedData = "test content".data(using: .utf8)! // 这里需要你通过依赖注入替换fetchData里的someAsyncCall,比如: // someAsyncCall = Just(Response(data: expectedData)).eraseToAnyPublisher() // 获取要测试的发布者 let publisher = fetchData() // 用async/await直接获取第一个输出值 let receivedData = try await publisher.values.first() // 断言结果匹配 XCTAssertEqual(receivedData, expectedData) }
测试失败场景的代码示例:
func testFetchData_Failure() async { // 模拟失败的情况,比如让someAsyncCall返回一个错误 let expectedError = NSError(domain: "TestDomain", code: 404, userInfo: nil) // 依赖注入替换:someAsyncCall = Fail(error: expectedError).eraseToAnyPublisher() let publisher = fetchData() // 捕获错误并断言 do { _ = try await publisher.values.first() XCTFail("测试失败:预期抛出错误但实际没有") } catch { XCTAssertEqual(error as NSError, expectedError) } } }
这种方式的优势在于代码极其简洁,完全贴合你熟悉的async/await测试逻辑,没有多余的等待或期望代码,测试稳定性也更高。
方法二:用TestScheduler同步执行异步流(iOS 13+)
如果你的项目需要支持iOS 13及以上,或者需要测试包含时间相关操作符(比如debounce、retry、delay)的Combine代码,TestScheduler是绝佳选择——它可以让你完全控制时间进度,把所有异步事件同步执行,根本不需要等待。
代码示例:
import XCTest import Combine final class DataFetchTests: XCTestCase { private var cancellables = Set<AnyCancellable>() func testFetchData_WithTestScheduler() { // 创建测试调度器,初始时间设为0 let testScheduler = TestScheduler(initialClock: 0) // 模拟异步依赖的事件序列:比如在时间点100的时候发送成功数据 let expectedData = "test content".data(using: .utf8)! let mockResponse = Response(data: expectedData) let mockPublisher = testScheduler.createColdPublisher([ .input(100, mockResponse), .completion(.finished) ]) // 依赖注入替换fetchData里的someAsyncCall为mockPublisher // 准备存储测试结果 var receivedValues: [Data] = [] var receivedError: Error? // 订阅要测试的发布者,并指定用测试调度器执行 fetchData() .subscribe(on: testScheduler) .receive(on: testScheduler) .sink( receiveCompletion: { completion in if case .failure(let error) = completion { receivedError = error } }, receiveValue: { data in receivedValues.append(data) } ) .store(in: &cancellables) // 启动调度器,同步执行所有事件 testScheduler.run() // 断言结果 XCTAssertEqual(receivedValues.count, 1) XCTAssertEqual(receivedValues.first, expectedData) XCTAssertNil(receivedError) } }
这个方法的核心是用TestScheduler模拟时间,所有原本异步的事件都会在testScheduler.run()调用时同步完成,完全不需要等待,而且能精准控制每个事件的触发时机,特别适合测试复杂的Combine数据流逻辑。
关键注意事项
不管用哪种方法,依赖注入都是必不可少的——你需要把fetchData里的实际异步依赖(比如网络请求someAsyncCall)设计成可替换的,这样才能在测试中用mock的发布者替代真实的网络调用,保证测试的可靠性和速度。
备注:内容来源于stack exchange,提问作者Levan Karanadze

