XCTest下如何测试使用Combine @Published的ViewModel异步方法?
解决方案
核心思路是通过监听ViewModel的@Published属性变化+XCTestExpectation实现无延迟的精准异步测试,不需要硬编码等待时长,也不需要修改现有核心业务逻辑,以下是两种可落地的实现方案:
方案1:零业务代码修改,直接用属性监听实现测试
首先需要先实现可控的MockDataService,成功/失败场景的返回完全由测试控制,不需要依赖真实网络逻辑:
// MockDataService 实现示例 class MockDataService: DataServiceProtocol { var shouldReturnError = false let mockData = Data() func getSomeData() -> AnyPublisher<Data, Error> { if shouldReturnError { return Fail(error: NSError(domain: "test", code: -1)).eraseToAnyPublisher() } else { return Just(mockData).setFailureType(to: Error.self).eraseToAnyPublisher() } } }
测试代码通过XCTestExpectation监听isLoading的状态变化,当isLoading变为false时就说明异步逻辑已经执行完成:
import Combine import XCTest @testable import 你的应用模块名 func testGetSomeDataDidLoad() { // 初始化Mock服务,配置为返回成功 let mockDataService = MockDataService() mockDataService.shouldReturnError = false let viewModel = ViewModel(dataService: mockDataService) var cancellables = Set<AnyCancellable>() // 创建期望:等待isLoading变为false let loadingExpectation = XCTestExpectation(description: "等待数据加载完成") viewModel.$isLoading // 过滤初始值和中间的true状态,只捕获变为false的触发时机 .filter { $0 == false } .sink { _ in loadingExpectation.fulfill() } .store(in: &cancellables) // 调用待测试的目标方法 viewModel.getSomeData() // 等待期望满足,超时时间仅用作异常兜底,Mock同步返回的情况下不会真的走到超时 wait(for: [loadingExpectation], timeout: 1.0) // 执行断言 XCTAssertFalse(viewModel.isLoading) XCTAssertFalse(viewModel.didError) }
如果需要测试错误场景,只要把mockDataService的shouldReturnError设为true,新增监听didError变为true的期望即可。
方案2:优化ViewModel架构,彻底消除异步等待
你现有代码里硬编码了DispatchQueue.main.async,导致测试必须等待主队列调度,如果把调度器作为依赖注入,测试时可以直接使用同步执行的调度器,连期望等待都可以省略:
重构后的ViewModel代码:
class ViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var didError: Bool = false var dataService: DataServiceProtocol private let scheduler: DispatchQueue // 注入调度器 private var cancellables = Set<AnyCancellable>() // 修复原代码未存储Cancellable的bug,否则订阅会提前释放 // 默认使用主队列,不影响原有业务逻辑 init(dataService: DataServiceProtocol, scheduler: DispatchQueue = .main) { self.dataService = dataService self.scheduler = scheduler } func getSomeData() { isLoading = true dataService.getSomeData() .sink { [weak self] completion in guard let self = self else { return } switch completion { case .failure(_): self.scheduler.async { self.didError = true } case .finished: print("finished") } self.scheduler.async { self.isLoading = false } } receiveValue: { data in print("Ok here is the data", data) } .store(in: &cancellables) } }
对应测试代码(不需要任何等待):
func testGetSomeDataDidLoad() { let mockDataService = MockDataService() // 测试时传入当前队列作为调度器,所有async任务会同步执行 let viewModel = ViewModel(dataService: mockDataService, scheduler: .current) viewModel.getSomeData() // 直接断言即可 XCTAssertFalse(viewModel.isLoading) XCTAssertFalse(viewModel.didError) }
注意:你原有ViewModel代码里没有把sink返回的Cancellable存储起来,订阅会在方法执行完后立即释放,导致回调永远不会触发,这个问题需要优先修复。
内容的提问来源于stack exchange,提问作者John Brennan
相关产品推荐
相关产品推荐

