You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在不注入调度器的情况下测试含.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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 02:49:52