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

SwiftUI中依赖网络请求的ObservableObject类单元测试方案咨询

如何在保持属性私有性的前提下测试SwiftUI的ObservableObject类?

问题背景

使用SwiftUI开发时,通过iTunes API获取应用版本并判断是否需要更新,实现了AppUpdateViewModel类,将currentAppVersion和usersAppVersion设为私有以保证封装性,但单元测试时无法直接修改这些私有属性,导致测试受阻。

原ViewModel实现:

final class AppUpdateViewModel: ObservableObject {
    
    @Published
    private var currentAppVersion: String?
    private var usersAppVersion: String = Bundle.appVersionNumber()
    
    private func fetchCurrentAppVersion() async -> Void {
        do {
            let res = try await NetworkingManger.shared.request(.getAppMetaDataFromApple, type: AppUpdateModel.self)
            self.currentAppVersion = res.results[0].version
        } catch {
            print("app updates ERR: ", error.localizedDescription)
        }
    }
    
    public func appNeedsUpdate() -> Bool {
        guard let currentAppVersionParts = currentAppVersion?.split(separator: ".") else { return false }
        let curAppVersMajor = currentAppVersionParts[0]
        let curAppVersMinor = currentAppVersionParts[1]
        let curAppVersPatch = currentAppVersionParts[2]
        
        let userAppVersionParts = usersAppVersion.split(separator: ".")
        let userAppVersMajor = userAppVersionParts[0]
        let userAppVersMinor = userAppVersionParts[1]
        let userAppVersPatch = userAppVersionParts[2]
        
        return ( curAppVersMajor > userAppVersMajor ||
                 (curAppVersMajor == userAppVersMajor && curAppVersMinor > userAppVersMinor) ||
                 (curAppVersMajor == userAppVersMajor && curAppVersMinor == userAppVersMinor && curAppVersPatch > userAppVersPatch) )
    }
}

测试时遇到的问题:无法直接修改私有属性currentAppVersion和usersAppVersion。


解决方案

1. 提取版本比较逻辑到独立工具类(优先推荐)

版本比较是纯业务逻辑,与ViewModel的状态管理无关,将其抽离为独立工具类后,可直接测试该类的逻辑,无需依赖ViewModel的私有属性。

步骤:

  • 创建版本比较工具类:
struct VersionComparator {
    static func needsUpdate(currentVersion: String, userVersion: String) -> Bool {
        // 先校验版本格式合法性
        guard let currentParts = currentVersion.split(separator: ".").map(String.init), currentParts.count == 3,
              let userParts = userVersion.split(separator: ".").map(String.init), userParts.count == 3 else {
            return false
        }
        
        let curMajor = currentParts[0], curMinor = currentParts[1], curPatch = currentParts[2]
        let userMajor = userParts[0], userMinor = userParts[1], userPatch = userParts[2]
        
        return curMajor > userMajor ||
               (curMajor == userMajor && curMinor > userMinor) ||
               (curMajor == userMajor && curMinor == userMinor && curPatch > userPatch)
    }
}
  • 修改ViewModel,调用工具类方法:
final class AppUpdateViewModel: ObservableObject {
    
    @Published
    private var currentAppVersion: String?
    private var usersAppVersion: String = Bundle.appVersionNumber()
    
    private func fetchCurrentAppVersion() async {
        do {
            let res = try await NetworkingManger.shared.request(.getAppMetaDataFromApple, type: AppUpdateModel.self)
            self.currentAppVersion = res.results[0].version
        } catch {
            print("app updates ERR: ", error.localizedDescription)
        }
    }
    
    public func appNeedsUpdate() -> Bool {
        guard let currentVersion = currentAppVersion else { return false }
        return VersionComparator.needsUpdate(currentVersion: currentVersion, userVersion: usersAppVersion)
    }
}
  • 测试工具类:
final class VersionComparatorTests: XCTestCase {
    func testPatchUpdateNeeded() {
        let result = VersionComparator.needsUpdate(currentVersion: "1.0.5", userVersion: "1.0.4")
        XCTAssertTrue(result)
    }
    
    func testNoUpdateNeeded() {
        let result = VersionComparator.needsUpdate(currentVersion: "1.0.3", userVersion: "1.0.4")
        XCTAssertFalse(result)
    }
    
    func testMinorUpdateNeeded() {
        let result = VersionComparator.needsUpdate(currentVersion: "1.2.0", userVersion: "1.1.5")
        XCTAssertTrue(result)
    }
}

2. 依赖注入(测试ViewModel完整流程)

如果需要测试ViewModel的完整流程(包括网络请求后判断更新),可以通过依赖注入替换真实的网络管理器和版本来源,避免直接修改私有属性。

步骤:

  • 定义网络请求协议,让NetworkingManger遵循该协议:
protocol NetworkingProtocol {
    func request<T: Decodable>(_ endpoint: Endpoint, type: T.Type) async throws -> T
}

extension NetworkingManger: NetworkingProtocol {}
  • 修改ViewModel,添加支持依赖注入的初始化方法:
final class AppUpdateViewModel: ObservableObject {
    
    @Published
    private var currentAppVersion: String?
    private let usersAppVersion: String
    private let networkingManager: NetworkingProtocol
    
    // 初始化方法:默认使用真实依赖,测试时传入Mock实例
    init(networkingManager: NetworkingProtocol = NetworkingManger.shared, userVersion: String = Bundle.appVersionNumber()) {
        self.networkingManager = networkingManager
        self.usersAppVersion = userVersion
    }
    
    private func fetchCurrentAppVersion() async {
        do {
            let res = try await networkingManager.request(.getAppMetaDataFromApple, type: AppUpdateModel.self)
            self.currentAppVersion = res.results[0].version
        } catch {
            print("app updates ERR: ", error.localizedDescription)
        }
    }
    
    public func appNeedsUpdate() -> Bool {
        guard let currentVersion = currentAppVersion else { return false }
        return VersionComparator.needsUpdate(currentVersion: currentVersion, userVersion: usersAppVersion)
    }
}
  • 创建Mock网络管理器,返回测试用的版本数据:
class MockNetworkManager: NetworkingProtocol {
    func request<T: Decodable>(_ endpoint: Endpoint, type: T.Type) async throws -> T {
        // 返回预设的版本数据
        let mockUpdateModel = AppUpdateModel(results: [AppUpdateResult(version: "1.0.5")])
        return mockUpdateModel as! T
    }
}
  • 测试ViewModel完整流程:
final class AppUpdateViewModelTests: XCTestCase {
    func testAppNeedsUpdateAfterFetch() async {
        // 注入Mock网络管理器和测试用户版本
        let vm = AppUpdateViewModel(networkingManager: MockNetworkManager(), userVersion: "1.0.4")
        // 调用版本获取方法
        await vm.fetchCurrentAppVersion()
        // 验证更新判断结果
        XCTAssertTrue(vm.appNeedsUpdate())
    }
}

3. 使用@testable导入(不推荐,仅临时场景)

通过@testable导入目标模块,可以访问模块内的私有成员,但这会破坏封装性,仅适合临时测试或特殊场景。

测试代码示例:

@testable import YourAppModule

final class AppVersionTests: XCTestCase {
    var appUpdateVM: AppUpdateViewModel!
    
    override func setUp() {
        super.setUp()
        appUpdateVM = AppUpdateViewModel()
    }
    
    func testPatchUpdate() {
        // 直接修改私有属性
        appUpdateVM.currentAppVersion = "1.0.5"
        appUpdateVM.usersAppVersion = "1.0.4"
        
        XCTAssertTrue(appUpdateVM.appNeedsUpdate())
    }
}

总结

  • 优先选择提取纯逻辑到工具类,既保证封装性,又能单独测试核心业务逻辑;
  • 若需测试ViewModel完整流程,使用依赖注入替换真实依赖,模拟网络请求和版本来源;
  • @testable导入仅作为临时方案,长期来看会降低代码的可维护性。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.24 05:24:55