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

如何将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)
    }
}

最佳实践

  1. 优先选择Async/Await:避免XCTestExpectation的嵌套回调,测试代码更易读和维护。
  2. 封装监听逻辑:把ObservationTracking的模板代码封装到ViewModel扩展中,测试代码只关注业务验证。
  3. 直接验证初始状态:不需要通过监听回调验证初始值,直接访问ViewModel属性即可,减少不必要的异步逻辑。
  4. 及时取消任务/观察:测试结束时取消监听用的Task或ObservationToken,避免内存泄漏和影响后续测试。
  5. 分离操作与验证:尽量不在监听回调中修改ViewModel状态(比如原测试里的viewModel.addVoice(secondVoice)),改为在测试主线程中执行,让测试流程更清晰可控。

内容的提问来源于stack exchange,提问作者Felix Lunzenfichter

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 04:13:11