同一套 iOS 设置测试,第一次通过,第二次却读到上次留下的主题值;换到云端 Mac 后,结果又变了。这通常不是机器性能问题,而是测试把进程内默认值、已保存设置和启动参数混在了一起。在 VpsKeep 上运行这类任务时,先隔离偏好设置,再检查业务行为,不要靠清空整个模拟器碰运气。
先把读取契约写清楚
UserDefaults 不是只有一张键值表。读取时会搜索多个域:启动参数可以覆盖应用域,注册域提供后备值。因此,object(forKey:) 读到某个值,不等于这个值已经保存。
先为每个设置明确三件事:键名、合法类型、缺失时的结果。例如主题键只接受 system、light、dark;不存在、类型不对或字符串未知时都返回 system。这样,历史版本留下的脏值也有确定行为。
register(defaults:) 只注册当前进程的后备值,不会把它们写成用户设置。删除持久化值后重新读到默认值,属于正常回退,而不是删除失败。
不要用
dictionaryRepresentation()证明某个键已经保存。它汇总可见设置,可能包含注册域中的值;检查保存状态应读取指定的持久化域。
注入独立 suite,禁止碰真实设置
生产代码可以默认使用 .standard,测试则必须注入专用实例。不要让设置封装在方法内部重新获取 .standard,否则依赖注入只是表面隔离。
下面的代码可放进 iOS 单元测试目标。每个测试使用随机 suite 名,只删除自己创建的持久化域。示例把读取封装也放在测试文件里;接入工程时,将封装移到应用目标,并由测试导入。
import Foundation
import XCTest
struct ThemePreferences {
let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["theme": "system"])
}
var theme: String {
guard let value = defaults.object(forKey: "theme") as? String,
["system", "light", "dark"].contains(value) else {
return "system"
}
return value
}
}
final class ThemePreferencesTests: XCTestCase {
private func withSuite(
_ body: (UserDefaults, String) throws -> Void
) throws {
let name = "preferences-tests." + UUID().uuidString
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defaults.removePersistentDomain(forName: name)
defer {
defaults.removePersistentDomain(forName: name)
}
try body(defaults, name)
}
func testOverrideAndRemoval() throws {
try withSuite { defaults, name in
let preferences = ThemePreferences(defaults: defaults)
XCTAssertEqual(preferences.theme, "system")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
defaults.set("dark", forKey: "theme")
XCTAssertEqual(preferences.theme, "dark")
XCTAssertEqual(
defaults.persistentDomain(forName: name)?["theme"]
as? String,
"dark"
)
defaults.removeObject(forKey: "theme")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
XCTAssertEqual(preferences.theme, "system")
}
}
func testInvalidValuesFallBack() throws {
try withSuite { defaults, _ in
let preferences = ThemePreferences(defaults: defaults)
defaults.set(["dark"], forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
defaults.set("unknown", forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
}
}
}
随机名称避免并行测试互相覆盖。退出清理处理正常执行路径;进程被强制终止时,defer 不保证执行。残留随机域不会被下一轮复用,但长驻测试设备仍应定期盘点,仅清理确认属于测试的域。
补齐覆盖与删除的边界
最小检查集是:无保存值时回退、合法写入后覆盖、删除后回退、错误类型回退、未知枚举值回退。还应增加“重建业务封装后仍读取保存值”,防止封装每次初始化都用 set 重置用户选择。
若业务同时使用共享 suite 和应用域,不要在单元测试里随手调用 addSuite(named:)。它会改变搜索路径,使测试结果依赖其他域。先把共享设置作为另一个可注入依赖,分别验收,再做应用集成测试。
布尔设置也要区分“未设置”和“用户选择关闭”。仅调用 bool(forKey:) 会把缺失值读成 false,无法据此判断用户是否操作过;需要显式检查键是否存在,或另设版本化的状态字段。
用两个进程检查真正的持久化
同一进程中新建 UserDefaults 对象,只能证明读取行为,不能证明重启后的持久化。集成测试应分成写入和读取两个阶段,并使用同一个固定测试 suite。
第一阶段写入主题,通过页面或测试接口确认读回,再让应用正常结束。第二阶段启动应用,不重复写入,直接检查主题是否恢复。最后执行独立清理阶段,删除这个测试域。
不要把强制杀进程后立即读取当成普通保存验收;那是在检查异常退出边界。也不要依靠固定等待几秒或 synchronize() 宣称落盘完成。
启动参数属于另一条测试线。若应用用参数注入主题,要单独验证覆盖,并在后续启动时移除参数;否则它会遮住已经保存的值,让持久化测试产生假成功。
把失败结果收成可追查记录
云端 Mac 的流水线记录测试名、suite 名、应用版本、键是否存在、实际类型和预期回退即可。不要直接导出整个设置字典,其中可能包含用户标识或内部环境信息。
连续运行两轮,再开启并行执行,检查结果是否一致。若只有第二轮失败,先找固定 suite 和未清理域;若并行才失败,检查共享单例;若重启才失败,检查初始化是否覆盖用户值。
最终验收不是“某次读到了正确字符串”,而是默认值、用户覆盖、删除回退与进程重启各有独立证据。把这些边界固定下来,设置测试才能在新机器和长驻节点上得到同样的结论。
常见问题
register(defaults:) 会把默认值写进持久化文件吗?
不会。它把值放入当前进程的注册域,作为查找时的后备值。新进程仍需执行注册,不能把注册默认值当成已经保存的用户设置。
新建一个 UserDefaults 对象能证明数据已经落盘吗?
不能。同一进程里的对象可能共享偏好设置状态。持久化验收应让写入进程正常退出,再启动另一个进程读取;不要用 synchronize() 代替这项测试。