iOS开发:加载视图控制器前Mock UserDefaults的问题
我懂你在iOS测试里Mock UserDefaults时遇到的糟心事——直接继承UserDefaults确实容易踩坑,毕竟它是单例设计,还会和真实的持久化数据纠缠不清。先聊聊你当前实现里的潜在问题,再给你两个更靠谱的解决方案。
问题分析
你当前的MockUserDefaults直接继承了UserDefaults,这里有两个核心问题:
- 调用
super.init(suiteName:)还是会关联到真实的系统存储域,哪怕你调用了removePersistentDomain,也可能存在数据残留或者测试并发时的干扰 - 你定义的
FakeData字典还没真正接管UserDefaults的读写逻辑,测试中如果调用了未重写的方法(比如object(forKey:)),还是会访问真实的存储,达不到Mock的目的
解决方案1:用协议抽象(推荐)
这种方式遵循依赖倒置原则,把UserDefaults的行为抽象成协议,然后分别实现真实版本和Mock版本,彻底隔离测试与真实存储。
第一步:定义协议
先把你业务中用到的UserDefaults方法抽象出来:
protocol UserDefaultsProtocol { func object(forKey defaultName: String) -> Any? func set(_ value: Any?, forKey defaultName: String) func removeObject(forKey defaultName: String) // 按需添加其他常用方法,比如integer(forKey:)、bool(forKey:)等 }
第二步:让真实UserDefaults遵循协议
给系统的UserDefaults做个扩展,自动遵循协议:
extension UserDefaults: UserDefaultsProtocol {}
第三步:实现Mock类
这个Mock类完全基于内存字典,和真实存储毫无关联:
class MockUserDefaults: UserDefaultsProtocol { private var storage: [String: Any?] = [:] func object(forKey defaultName: String) -> Any? { storage[defaultName] ?? nil } func set(_ value: Any?, forKey defaultName: String) { storage[defaultName] = value } func removeObject(forKey defaultName: String) { storage.removeValue(forKey: defaultName) } // 示例:添加bool类型的方法 func bool(forKey defaultName: String) -> Bool { storage[defaultName] as? Bool ?? false } }
第四步:在业务代码中注入协议
修改你的业务类,通过初始化方法注入UserDefaultsProtocol,默认使用真实的UserDefaults.standard:
class UserProfileService { private let userDefaults: UserDefaultsProtocol init(userDefaults: UserDefaultsProtocol = UserDefaults.standard) { self.userDefaults = userDefaults } func saveNickname(_ nickname: String) { userDefaults.set(nickname, forKey: "user_nickname") } func getNickname() -> String? { userDefaults.object(forKey: "user_nickname") as? String } }
第五步:编写测试用例
测试时直接传入MockUserDefaults,完全隔离真实存储:
import XCTest @testable import YourApp class UserProfileServiceTests: XCTestCase { func testSaveAndGetNickname() { let mockDefaults = MockUserDefaults() let service = UserProfileService(userDefaults: mockDefaults) // 执行保存操作 service.saveNickname("iOSDev") // 验证Mock中的数据 XCTAssertEqual(mockDefaults.object(forKey: "user_nickname") as? String, "iOSDev") XCTAssertEqual(service.getNickname(), "iOSDev") } }
解决方案2:重写UserDefaults方法(不推荐但可用)
如果你坚持要继承UserDefaults,必须重写所有业务中用到的读写方法,避免访问真实存储:
class MockUserDefaults: UserDefaults { typealias FakeData = [String: Any?] var data: FakeData = [:] // 用唯一UUID作为suiteName,避免和其他测试/真实App冲突 convenience init() { self.init(suiteName: UUID().uuidString)! } override init?(suiteName suitename: String?) { data = [:] super.init(suiteName: suitename) // 清空这个suite的持久化数据,防止残留 self.removePersistentDomain(forName: suitename!) } // 重写set方法,只操作自己的data字典 override func set(_ value: Any?, forKey defaultName: String) { data[defaultName] = value } // 重写object(forKey:)方法,返回自己data中的数据 override func object(forKey defaultName: String) -> Any? { data[defaultName] ?? nil } // 按需重写其他方法,比如removeObject override func removeObject(forKey defaultName: String) { data.removeValue(forKey: defaultName) } }
注意:这种方式必须确保所有用到的UserDefaults方法都被重写,否则会调用父类的真实实现,导致测试污染。
总结
优先推荐协议抽象的方式,它更灵活,测试隔离更彻底,也符合现代iOS开发的最佳实践。继承UserDefaults的方式虽然可行,但容易因为遗漏重写方法而引入隐藏问题。
内容的提问来源于stack exchange,提问作者Kilom94l
相关产品推荐
相关产品推荐

