如何在Swift Testing中正确测试Combine Publishers?
嘿,这个问题我之前也踩过坑!你原来的Swift Testing代码失败,核心原因是用错了confirmation这个API——它并不是用来处理Combine Publisher这类异步流的最佳方式,而且你的写法里其实没有正确等待Publisher发射值就提前走到了取消订阅的逻辑,导致确认根本没被触发。
原来的XCTest用XCTestExpectation+wait(for:)来等待Publisher发射值,但Swift Testing的异步模型是基于async/await的,Combine在iOS 15+、macOS 12+已经支持和AsyncSequence互操作,所以我们应该利用这个特性来简化测试代码,而不是硬套confirmation的写法。
下面给你两种正确的实现方式,优先推荐第一种:
方法一:转成AsyncSequence(推荐)
这是最简洁也最符合Swift Testing设计理念的方式,直接把Combine Publisher转换成AsyncSequence,用await等待符合条件的值:
@Test func imageRetrieved() async { // 跳过初始值,等待第一个非nil的image发射 let receivedImage = await viewModel.$image .dropFirst() .first(where: { $0 != nil }) #expect(receivedImage != nil) }
这种写法和你原来的XCTest逻辑完全一致:跳过$image的初始值,等待第一个非nil的图像,然后断言它不为空。AsyncSequence的first(where:)会自动等待,直到找到符合条件的值或者Publisher完成,测试会自然等待这个异步操作完成。
方法二:用CheckedContinuation手动等待(兼容旧系统或复杂场景)
如果你需要兼容iOS 15以下的系统,或者有更复杂的订阅逻辑要处理,可以用withCheckedContinuation来手动等待Publisher发射值,和原来的XCTest Expectation逻辑更接近:
@Test func imageRetrieved() async { let receivedImage = await withCheckedContinuation { continuation in let cancellable = viewModel.$image .dropFirst() .sink( receiveCompletion: { _ in // 如果Publisher完成但没发射目标值,我们返回nil让断言失败 continuation.resume(returning: nil) }, receiveValue: { image in // 拿到值后恢复continuation,同时取消订阅 continuation.resume(returning: image) cancellable.cancel() } ) } #expect(receivedImage != nil) }
这里我们用continuation来“挂起”测试,直到Publisher发射值或者完成,然后恢复测试并进行断言。
为什么原来的confirmation写法会失败?
confirmation这个API主要是用来测试回调式的异步代码(比如传统的completion handler),它的设计是等待你手动调用确认函数,但它并不会处理Combine Publisher的异步调度逻辑:
- 当你在
confirmation闭包里设置好订阅后,闭包立即执行完毕,confirmation开始等待确认被调用。 - 但Combine的
sink回调是在后续的RunLoop/EventLoop周期中执行的,测试代码可能已经走到了cancellable?.cancel()这一行,直接取消了订阅,导致值还没发射就被终止了,所以确认永远不会被触发,测试就失败了。
总结一下:在Swift Testing中测试Combine Publisher,优先用AsyncSequence的方式,它更简洁、符合Swift的异步编程模型,而且和Swift Testing的async测试方法完美契合。如果有特殊场景需要手动控制等待逻辑,再用CheckedContinuation的方式,尽量避免用confirmation来处理Combine Publisher的测试。
备注:内容来源于stack exchange,提问作者lazarevzubov

