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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.02 16:24:02