云端 Mac 上的 iOS 偏好设置回归测试

云端 Mac 上的 iOS 偏好设置回归测试

同一套 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() 代替这项测试。

把步骤放到独享云 Mac 上运行

租用整台物理 Mac mini,非虚拟机。按天、周、月、季选择周期,先核对机型和节点,再确认订单;实际可用状态以控制台实时返回为准。