如何将ObservableObject与@Published测试迁移至Swift新@Observable宏
迁移到@Observable宏后的ViewModel异步测试方案
核心变化说明
@Observable宏和原ObservableObject+@Published的核心区别在于:前者不会自动生成带$前缀的Combine Publisher,需要通过ObservationTracking API手动监听属性变化。我们可以选择**Swift Concurrency(async/await)**或继续用Combine来重写测试,前者更符合现代Swift的编程风格。
重写后的测试示例(推荐用Async/Await)
步骤1:给ViewModel封装监听逻辑
先给ViewModel扩展一个返回AsyncStream的属性,封装属性变化的监听逻辑,避免测试代码重复:
import Observation @Observable class MyViewModel { var voiceList: [Voice] = [] func addVoice(_ voice: Voice) { voiceList.append(voice) } } extension MyViewModel { // 监听voiceList变化的AsyncStream var voiceListChanges: AsyncStream<Void> { AsyncStream { continuation in // 启动观察,当voiceList变化时触发回调 let observationToken = withObservationTracking { _ = self.voiceList // 访问属性以触发追踪 } onChange: { continuation.yield() // 发送变化信号 $0() // 递归调用,持续监听后续变化 } // 当Stream终止时取消观察,避免内存泄漏 continuation.onTermination = { _ in observationToken.cancel() } } } }
步骤2:重写测试用例
用async/await替代XCTestExpectation,让测试代码更线性、易读:
import XCTest class MyViewModelTests: XCTestCase { func testVoiceListUpdates() async { let viewModel = MyViewModel() let firstVoice = Voice.fixture() let secondVoice = Voice.fixture() // 1. 直接验证初始状态 XCTAssertTrue(viewModel.voiceList.isEmpty) // 2. 等待第一个语音添加完成 let firstAddTask = Task { for await _ in viewModel.voiceListChanges { if viewModel.voiceList.count == 1 && viewModel.voiceList.first == firstVoice { return } } XCTFail("Failed to add first voice") } viewModel.addVoice(firstVoice) await firstAddTask.value // 3. 等待第二个语音添加完成 let secondAddTask = Task { for await _ in viewModel.voiceListChanges { if viewModel.voiceList.count == 2 && viewModel.voiceList.last == secondVoice { return } } XCTFail("Failed to add second voice") } viewModel.addVoice(secondVoice) await secondAddTask.value // 4. 最终状态验证 XCTAssertEqual(viewModel.voiceList.count, 2) XCTAssertEqual(viewModel.voiceList[0], firstVoice) XCTAssertEqual(viewModel.voiceList[1], secondVoice) } }
兼容Combine的方案(保留原测试风格)
如果习惯用Combine的sink和XCTestExpectation,可以把属性变化转换成Publisher:
import Combine extension MyViewModel { var voiceListPublisher: AnyPublisher<[Voice], Never> { let subject = CurrentValueSubject<[Voice], Never>(voiceList) let observationToken = withObservationTracking { subject.send(self.voiceList) } onChange: { subject.send(self.voiceList) $0() } return subject .handleEvents(receiveCancel: { observationToken.cancel() }) .eraseToAnyPublisher() } }
测试代码和原版本几乎一致,仅替换监听的Publisher:
class MyViewModelTests: XCTestCase { private var cancellables = Set<AnyCancellable>() func testVoiceListUpdatesWithCombine() { let initialExpectation = XCTestExpectation(description: "Voice list should be empty initially") let firstExpectation = XCTestExpectation(description: "First voice should be added") let secondExpectation = XCTestExpectation(description: "Second voice should be added") let viewModel = MyViewModel() let firstVoice = Voice.fixture() let secondVoice = Voice.fixture() viewModel.voiceListPublisher .sink { newValue in switch newValue.count { case 0: initialExpectation.fulfill() case 1: if newValue.first == firstVoice { firstExpectation.fulfill() viewModel.addVoice(secondVoice) } case 2: if newValue.last == secondVoice { secondExpectation.fulfill() } default: break } } .store(in: &cancellables) viewModel.addVoice(firstVoice) wait(for: [initialExpectation, firstExpectation, secondExpectation], timeout: 1) } }
最佳实践
- 优先选择Async/Await:避免
XCTestExpectation的嵌套回调,测试代码更易读和维护。 - 封装监听逻辑:把
ObservationTracking的模板代码封装到ViewModel扩展中,测试代码只关注业务验证。 - 直接验证初始状态:不需要通过监听回调验证初始值,直接访问ViewModel属性即可,减少不必要的异步逻辑。
- 及时取消任务/观察:测试结束时取消监听用的Task或ObservationToken,避免内存泄漏和影响后续测试。
- 分离操作与验证:尽量不在监听回调中修改ViewModel状态(比如原测试里的
viewModel.addVoice(secondVoice)),改为在测试主线程中执行,让测试流程更清晰可控。
内容的提问来源于stack exchange,提问作者Felix Lunzenfichter
相关产品推荐
相关产品推荐

