一款资讯类 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 可以增长,但其中不得出现用户草稿扩展名。对大型测试资源使用明确白名单,并在失败信息中输出相对路径、文件大小、所属规则和创建阶段。
不要直接删除现场
失败后的第一反应不应是清空模拟器。先归档以下内容:
- XCTest 结果包及失败用例名称;
- 应用容器相对路径清单;
- 各顶层目录的汇总大小;
- 测试使用的提交号、Scheme 和模拟器运行时;
- 触发文件创建的测试步骤。
这些证据足以区分“属性没设置”“文件放错目录”和“清理流程未执行”。确认附件完成后再删除设备,避免偶发失败只剩一行断言。
设计成稳定的 CI 门禁
备份边界测试应在每次修改存储层、下载器、数据库迁移或导出流程时执行,也可以进入合并前的轻量测试集。不要把它绑在需要外部服务的端到端流程上;使用本地夹具和固定大小的数据,结果才可重复。
建议把失败分成三类:必须阻断的用户数据位置错误、必须阻断的排除属性缺失,以及只告警的容量趋势变化。容量阈值需要由仓库内配置控制,并在评审中解释调整原因,不能由脚本看到超限后自动放宽。
最后保留一次真机验收。模拟器能够稳定验证目录、属性和迁移代码,却不能覆盖实际恢复链路的全部行为。发布检查应选取一份最小数据集,确认不可重建数据能够恢复、可再生数据不会被当作恢复前提。这样,自动化门禁负责高频回归,真机流程负责验证最终边界,两者不会互相替代。
常见问题
哪些 iOS 文件通常不应进入备份?
可重新下载或重新生成的缓存、离线资源包、解压中间文件和临时导出物通常不应进入备份,应放入 Caches、tmp,或显式设置 isExcludedFromBackup。
为什么不能只检查文件所在目录?
目录只能表达默认语义,迁移代码、第三方组件或历史版本可能把文件放错位置。测试还应检查文件用途、排除属性和重装后的恢复预期。
模拟器检查能完全替代真机验证吗?
不能。模拟器适合持续检查目录与属性规则,发布前仍应在受控真机流程中验证实际备份、恢复和数据保护行为。
在云端 M4 Mac 上运行下一项工作
选择 Runner M4 或 Runner M4 Plus,按项目需要确定节点、租期和存储附加项。每笔订单对应独享物理机,非虚拟机。