如何在不注入调度器的情况下测试含.receive(on:)的Combine代码?
Combine含
.receive(on:)代码的单元测试方案探讨 问题场景
在iOS项目中使用Combine编写单元测试时,含调度器的代码(如.receive(on: .main))会增加测试难度,示例代码如下:
func getData() { state = .loading someAsyncPublisher() .receive(on: .main) // 该行增加测试复杂度 .sink( receiveCompletion: { [weak self] completion in switch completion { case .failure(let error): self?.state = .failed(error) case .finished: break } }, receiveValue: { [weak self] data in self?.state = .loaded(data) } ) .store(in: &cancellables) }
现有两种测试思路均存在不足:
- 自定义延迟:虽能确保Combine管线执行,但可靠性差(本地通过的测试可能在CI环境失败),增加延迟会拖慢测试速度,属于临时方案。
- 注入调度器:通过注入
TestScheduler或ImmediateScheduler可实现可靠测试,但需要修改函数签名或引入额外抽象,增加代码复杂度。
可行解决方案与最佳实践
1. 轻量调度器注入(推荐)
无需修改对外函数签名,仅在被测类中添加一个内部调度器属性,默认使用.main,测试时通过@testable导入替换为测试调度器,兼顾可靠性与代码简洁性。
业务代码改造:
class YourViewModel { private var cancellables = Set<AnyCancellable>() var state: State = .idle // 内部调度器属性,仅测试场景替换 var receiveScheduler: DispatchQueue = .main func getData() { state = .loading someAsyncPublisher() .receive(on: receiveScheduler) // 使用属性替换硬编码的.main .sink( receiveCompletion: { [weak self] completion in switch completion { case .failure(let error): self?.state = .failed(error) case .finished: break } }, receiveValue: { [weak self] data in self?.state = .loaded(data) } ) .store(in: &cancellables) } // 示例异步Publisher,实际业务中可能是网络请求等 var someAsyncPublisher: AnyPublisher<Data, Error> { // 业务逻辑实现 } }
测试代码实现:
@testable import YourApp import XCTest import Combine class YourViewModelTests: XCTestCase { private var viewModel: YourViewModel! private var testScheduler: TestScheduler! private var cancellables = Set<AnyCancellable>() override func setUp() { super.setUp() viewModel = YourViewModel() testScheduler = TestScheduler(initialClock: 0) // 替换为测试调度器 viewModel.receiveScheduler = testScheduler.queue } func testGetData_Success() { // 模拟异步Publisher返回数据 let testData = Data() viewModel.someAsyncPublisher = Just(testData) .setFailureType(to: Error.self) .eraseToAnyPublisher() viewModel.getData() // 推进测试调度器,触发Combine管线执行 testScheduler.run() XCTAssertEqual(viewModel.state, .loaded(testData)) } func testGetData_Failure() { let testError = NSError(domain: "TestError", code: 0) viewModel.someAsyncPublisher = Fail(error: testError) .eraseToAnyPublisher() viewModel.getData() testScheduler.run() XCTAssertEqual(viewModel.state, .failed(testError)) } }
这种方式对业务代码的侵入极小,仅添加一个默认值为.main的属性,测试时通过@testable访问替换,既保证了测试的可靠性,又避免了过度抽象。
2. 无注入的替代方案(仅适用于简单场景)
如果暂时不想修改业务代码,可以使用XCTestExpectation配合主线程异步等待,替代固定延迟,提升测试稳定性:
func testGetData_Success() { let expectation = XCTestExpectation(description: "Data loaded state updated") let expectedData = Data() // 模拟异步Publisher返回数据 viewModel.someAsyncPublisher = Just(expectedData) .setFailureType(to: Error.self) .eraseToAnyPublisher() viewModel.getData() // 等待主线程处理Combine管线的回调 DispatchQueue.main.async { XCTAssertEqual(self.viewModel.state, .loaded(expectedData)) expectation.fulfill() } // 超时时间设短(如0.1秒),仅等待主线程一次循环 wait(for: [expectation], timeout: 0.1) }
这种方案无需修改业务代码,但仍存在一定不确定性(极端情况下主线程阻塞可能导致超时),仅适合临时测试或简单场景,长期来看不如调度器注入可靠。
总结
- 优先选择轻量调度器注入:这是Combine测试的标准最佳实践,对业务代码影响极小,测试结果稳定可靠。
- 避免使用固定延迟:无论本地还是CI环境,固定延迟的测试都存在不可靠性,应尽量替换为基于期望或调度器的方案。
- 无需过度抽象:不需要为调度器专门定义协议,直接使用
DispatchQueue作为属性即可,TestScheduler可以直接提供兼容的队列实例。
内容的提问来源于stack exchange,提问作者Levan Karanadze
相关产品推荐
相关产品推荐

