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
相关产品推荐
相关产品推荐

