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

依赖Publisher的ViewModel单元测试:receiveCompletion未触发问题

问题分析与解决

你实现了一个Service类,通过loadModels方法返回Publisher加载数据:

class Service {
    let fileURL: URL // 在程序其他地方设置

    func loadModels() -> AnyPublisher<[MyModelClass], Error> {
        URLSession.shared.dataTaskPublisher(for: fileURL)
            .map( { $0.data } )
            .decode(type: [MyModelClass].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

ViewModel调用该方法,通过@Published属性models接收数据:

class ViewModel: ObservableObject {
    @Published var models: [MyModelClass]?
    
    var cancellables = Set<AnyCancellable>()
    let service: Service
    
    init(service: Service) {
        self.service = service
        loadCityData() // 注:此处疑似笔误,应为loadModels()
    }
    
    func loadModels()  {
        service.loadModels()
            .sink { _ in
            } receiveValue: { [weak self] models in
                self?.models = models
            }
            .store(in: &cancellables)
    }
}

单元测试时,你监听ViewModel的$models,发现receiveCompletion从未触发,但receiveValue会多次调用:

let expectation = expectation(description: "loadModels")

viewModel.$models
    .receive(on: RunLoop.main)
    .sink(receiveCompletion: { _ in
        expectation.fulfill()
    }, receiveValue: { _ in
    })
    .store(in: &cancellables) // 类级别的属性

viewModel.loadModels()

wait(for: [expectation], timeout: 10)

而直接监听Service返回的Publisher时,receiveCompletion能正常触发,核心原因如下:

核心差异:两种Publisher的生命周期不同

  • Service返回的是有限Publisher:dataTaskPublisher是一次性任务,请求完成(成功/失败)后会立即发送completion事件并终止,所以监听它的sink能收到completion回调。
  • @Published生成的是无限Publisher:$models是长期存在的Publisher,只要ViewModel对象存活,它就会在models值变化时持续发送新值,永远不会主动发送completion事件——这就是测试中receiveCompletion永远不触发的根本原因。

测试的正确写法

如果要通过监听$models验证加载完成,可以通过过滤值+取首次有效结果来触发预期:

let expectation = expectation(description: "loadModels")

viewModel.$models
    .receive(on: RunLoop.main)
    .compactMap { $0 } // 过滤nil值,只保留有效数据
    .first() // 只取第一个非空结果
    .sink(receiveValue: { _ in
        expectation.fulfill()
    })
    .store(in: &cancellables)

viewModel.loadModels()

wait(for: [expectation], timeout: 10)

也可以在ViewModel中新增状态属性(比如@Published var isLoading = false),在loadModels的sink回调中更新状态,测试时监听该状态的变化。

内容的提问来源于stack exchange,提问作者Ramy Al Zuhouri

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.03 11:10:19