Runner 工程文章

云端 Mac 上建立 iOS 数据备份边界回归测试

云端 Mac 上建立 iOS 数据备份边界回归测试

一款资讯类 App 在版本更新后,把数百兆可重新下载的离线资源放进了 Application Support。功能测试全部通过,用户数据也没有丢失,但备份体积突然增长。问题不在下载逻辑,而在团队从未把“哪些文件必须恢复、哪些文件可以重建”写成可执行规则。云端 Mac 很适合承担这类回归检查:环境长期一致,模拟器可重置,失败时还能保留容器清单供排查。

先定义数据的恢复语义

不要先按目录写测试,先按数据用途分类。同一个 JSON 文件可能是用户创作,也可能只是接口缓存;它们的备份要求完全不同。

数据类型 推荐位置 备份预期 示例
用户创建且无法重建 Documents 应保留 草稿、用户导入文件
应用持久状态 Application Support 按业务决定 数据库、编辑进度
可重新生成或下载 Library/Caches 不依赖备份 图片缓存、离线索引
单次任务中间文件 tmp 不保留 解压目录、分片文件

Application Support 最容易被误用。它适合应用长期管理的数据,但不代表其中所有内容都应进入备份。大型模型、地图包或媒体代理文件如果可以重新取得,应显式设置排除属性,或者改放 Caches。

判断标准不是“这个文件重要吗”,而是“设备恢复后,这个文件是否必须从备份中回来,且无法由可靠来源重建”。

把规则整理成一份仓库内的清单,例如记录逻辑名称、相对路径、恢复要求和负责人。测试只负责执行这份约定,不应把散落在代码里的偶然路径当成规范。

用 XCTest 固定排除属性

Foundation 提供了比读取底层扩展属性更稳定的接口。下面的辅助方法创建文件后设置 isExcludedFromBackup,再重新读取资源值确认设置已经落盘。

import XCTest

final class BackupBoundaryTests: XCTestCase {
    private func markExcluded(_ url: URL) throws {
        var values = URLResourceValues()
        values.isExcludedFromBackup = true
        var mutableURL = url
        try mutableURL.setResourceValues(values)
    }

    func testDownloadPackageIsExcludedFromBackup() throws {
        let root = FileManager.default.urls(
            for: .applicationSupportDirectory,
            in: .userDomainMask
        )[0]
        let package = root.appendingPathComponent(
            "Downloads/catalog.bundle",
            isDirectory: false
        )

        try FileManager.default.createDirectory(
            at: package.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )
        try Data("fixture".utf8).write(to: package)
        try markExcluded(package)

        let values = try package.resourceValues(
            forKeys: [.isExcludedFromBackupKey]
        )
        XCTAssertEqual(values.isExcludedFromBackup, true)
    }
}

测试重点不是证明 Foundation 能工作,而是让生产代码创建文件后也经过同一条设置路径。更稳妥的做法是把目录创建、原子写入和排除标记封装进存储组件,测试直接调用该组件。否则测试代码设置成功,生产下载器却遗漏设置,门禁仍然没有价值。

同时检查错误放置

再增加反向断言:用户草稿不能落入 Caches,临时导出物不能留在 Documents。测试夹具应覆盖创建、升级迁移和失败重试,因为路径漂移经常发生在迁移分支,而不是首次安装流程。

对于目录级排除,还要抽查新建子文件。不要假设父目录的当前属性能永久替代写入逻辑;组件每次创建目录后主动确认属性,更容易抵抗旧数据迁移和目录重建。

在模拟器中保留容器证据

单元测试适合验证规则,模拟器容器检查适合回答“失败时实际写了什么”。先让测试使用固定设备和独立 DerivedData,避免多个任务共享状态。

set -euo pipefail

DERIVED_DATA="$PWD/.ci/DerivedData-backup"
RESULT_BUNDLE="$PWD/.ci/BackupBoundary.xcresult"

rm -rf "$DERIVED_DATA" "$RESULT_BUNDLE"

xcodebuild test \
  -workspace Example.xcworkspace \
  -scheme Example \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$DERIVED_DATA" \
  -resultBundlePath "$RESULT_BUNDLE"

APP_DATA="$(
  xcrun simctl get_app_container booted com.example.app data
)"
find "$APP_DATA" -type f -print | LC_ALL=C sort \
  > "$PWD/.ci/app-container-files.txt"

get_app_container 只有在目标 App 已安装且对应模拟器处于启动状态时才会成功。CI 不应默默选择任意已启动设备;应在任务开始时创建或指定设备,等待启动完成,并在任务结束后关闭。Runner 上若并行执行多套测试,每个任务使用独立模拟器设备集,可避免容器串线。

文件清单只记录相对路径更安全,也更容易比较。不要把主目录、工作区绝对路径或测试凭据打进产物。若需要检查扩展属性,可以把 xattr 输出作为诊断附件,但门禁结论仍应以 Foundation 资源值和业务清单为准,避免依赖未承诺的底层表现。

把容量异常变成可解释失败

仅检查布尔属性还不够。一次路径重构可能让所有缓存都进入 Documents,但每个文件本身没有排除属性测试。可以为容器顶层建立容量预算,但预算应表达结构异常,而不是追求固定字节数。

例如,测试夹具运行后要求 Documents 只包含指定样本;tmp 在任务结束后为空;Caches 可以增长,但其中不得出现用户草稿扩展名。对大型测试资源使用明确白名单,并在失败信息中输出相对路径、文件大小、所属规则和创建阶段。

不要直接删除现场

失败后的第一反应不应是清空模拟器。先归档以下内容:

这些证据足以区分“属性没设置”“文件放错目录”和“清理流程未执行”。确认附件完成后再删除设备,避免偶发失败只剩一行断言。

设计成稳定的 CI 门禁

备份边界测试应在每次修改存储层、下载器、数据库迁移或导出流程时执行,也可以进入合并前的轻量测试集。不要把它绑在需要外部服务的端到端流程上;使用本地夹具和固定大小的数据,结果才可重复。

建议把失败分成三类:必须阻断的用户数据位置错误、必须阻断的排除属性缺失,以及只告警的容量趋势变化。容量阈值需要由仓库内配置控制,并在评审中解释调整原因,不能由脚本看到超限后自动放宽。

最后保留一次真机验收。模拟器能够稳定验证目录、属性和迁移代码,却不能覆盖实际恢复链路的全部行为。发布检查应选取一份最小数据集,确认不可重建数据能够恢复、可再生数据不会被当作恢复前提。这样,自动化门禁负责高频回归,真机流程负责验证最终边界,两者不会互相替代。

常见问题

哪些 iOS 文件通常不应进入备份?

可重新下载或重新生成的缓存、离线资源包、解压中间文件和临时导出物通常不应进入备份,应放入 Caches、tmp,或显式设置 isExcludedFromBackup。

为什么不能只检查文件所在目录?

目录只能表达默认语义,迁移代码、第三方组件或历史版本可能把文件放错位置。测试还应检查文件用途、排除属性和重装后的恢复预期。

模拟器检查能完全替代真机验证吗?

不能。模拟器适合持续检查目录与属性规则,发布前仍应在受控真机流程中验证实际备份、恢复和数据保护行为。

独享物理节点

在云端 M4 Mac 上运行下一项工作

选择 Runner M4 或 Runner M4 Plus,按项目需要确定节点、租期和存储附加项。每笔订单对应独享物理机,非虚拟机。

立即租用云端 Mac