一次看似普通的 Core Data 字段调整,在全新安装的模拟器里通常毫无异常,却可能让从旧版本直接升级的用户卡在启动页。真正需要进入 CI 的不是“空库能否创建”,而是旧库能否沿着受支持的路径迁移、关键记录是否保持一致,以及失败时能否留下足够证据。云端 Mac 持续在线,适合把这类耗时但确定性的检查做成独立门禁。
先定义迁移支持范围
先列出仍可能出现的数据库版本,不要默认用户一定逐版升级。若当前模型是 V5,而线上仍有 V3、V4 客户端,就至少测试 V3→V5 与 V4→V5。V5 空库测试只能证明当前模型可加载,不能覆盖旧字段重命名、关系约束变化或唯一索引冲突。
建议维护一张简单矩阵:
| 样本 | 升级目标 | 必查内容 |
|---|---|---|
| V3 基础库 | V5 | 账户、项目、历史任务数量 |
| V4 边界库 | V5 | 空关系、重复名称、已删除标记 |
| V4 大样本 | V5 | 迁移完成时间与峰值文件大小 |
| V5 空库 | V5 | 当前模型初始化 |
迁移门禁的通过条件应是“数据语义保持成立”,而不只是持久化容器没有抛错。
每个样本还要记录生成它的应用版本、模型标识、预期记录数和校验摘要。不要从开发者日常使用的数据库临时取样,否则样本会随操作变化,失败也难以复现。
冻结可重复的旧库样本
为每个历史版本准备一个专用构建,让它写入固定数据:正常记录、空值、长文本、已归档对象和接近约束边界的对象都应包含。写入完成后先正常关闭持久化容器,再复制数据库。
SQLite 使用 WAL 时,数据可能分散在三个文件中。最稳妥的做法是让应用完成保存并执行 checkpoint;无法保证时,就把文件组一起封存:
set -euo pipefail
STORE="$HOME/Library/Developer/CoreSimulator/Devices/$SIM_UDID/data/Containers/Data/Application/$APP_ID/Library/Application Support/App.sqlite"
OUT="MigrationFixtures/V4"
mkdir -p "$OUT"
cp "$STORE" "$OUT/App.sqlite"
for suffix in -wal -shm; do
if [ -f "${STORE}${suffix}" ]; then
cp "${STORE}${suffix}" "$OUT/App.sqlite${suffix}"
fi
done
find "$OUT" -type f -print0 | sort -z | xargs -0 shasum -a 256 > "$OUT/SHA256SUMS"
不要在测试中直接打开仓库里的原始样本。每个测试先复制到临时目录,迁移完成后删除,确保并行任务不会互相改写。样本若包含真实用户内容、令牌或连接信息,应重新制作匿名数据,而不是依赖后续脱敏。
把迁移封装成可测试入口
应用启动流程常把数据库加载、界面初始化和网络请求绑在一起,这会让迁移失败难以定位。应把持久化容器创建封装为可注入 URL 的组件,让 XCTest 能直接加载临时副本。
func makeContainer(storeURL: URL) throws -> NSPersistentContainer {
let container = NSPersistentContainer(name: "AppModel")
let description = NSPersistentStoreDescription(url: storeURL)
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in
loadError = error
}
if let loadError {
throw loadError
}
return container
}
轻量迁移适合新增可选字段、增加带默认值的属性和明确的重命名。涉及实体拆分、数据合并或值转换时,应提供显式映射或分阶段迁移。不要为了让测试通过而删除旧模型;历史模型是读取旧库元数据和推导迁移路径的必要输入。
校验业务不变量
迁移后至少检查四层:持久化存储成功加载、模型版本已更新、记录数量符合预期、关键关系与字段语义正确。例如任务总数不能变化,已归档项目仍应保持归档,孤立子对象数量应为零。
比对时避免只依赖自动生成的对象 ID。可以为测试数据写入稳定业务键,再按业务键检查字段。对排序或集合关系,应先规范化再比较,防止无意义的顺序差异造成偶发失败。
在云端 Mac 接入独立测试任务
迁移测试应与普通单元测试分开运行,因为它需要复制样本并频繁创建持久化存储。固定模拟器运行时和目标设备名称,先启动设备,再执行指定测试计划:
set -euo pipefail
xcrun simctl bootstatus "$SIM_UDID" -b
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan DatabaseMigration \
-destination "platform=iOS Simulator,id=$SIM_UDID" \
-resultBundlePath Artifacts/MigrationTests.xcresult \
CODE_SIGNING_ALLOWED=NO
测试任务开始前清理临时目录,结束后无论成功或失败都归档 xcresult、迁移日志、样本摘要和目标模型标识。不要上传迁移后的完整数据库作为默认制品;它可能包含测试秘密,也会迅速放大存储占用。通常只在失败时保存匿名样本副本,并设置明确的清理周期。
在 ZoomMini 上执行时,把任务绑定到固定的 Xcode 工具链,并在控制台确认当前可选配置。更换 Xcode 或模拟器运行时后,先单独运行迁移计划,再恢复到完整流水线,避免工具链变化与模型变化同时进入,增加归因难度。
常见失败与发布检查项
“本地通过、CI 失败”通常来自文件组复制不完整、样本被上一次测试修改、模型资源未进入测试包,或多个测试同时操作同一路径。先核对 SHA-256,再检查临时目录是否唯一,最后确认编译产物中包含所有受支持的模型版本。
发布前逐项确认:
- 每个受支持的起始版本都有固定样本和预期摘要;
- SQLite 主文件与 WAL 状态一致;
- 测试只迁移临时副本,不修改原始 fixture;
- 轻量迁移与显式映射的适用边界已经写清;
- 记录数、稳定业务键、关系和默认值均有断言;
- 失败任务会保留
xcresult与脱敏迁移日志; - 模型版本删除、字段重命名和约束变化必须触发迁移计划;
- 完整测试从最旧受支持版本直接升级到当前版本。
数据库迁移不是一次性的发布脚本,而是一份会随模型演进增长的兼容性契约。把历史样本、业务不变量和失败证据固定下来后,每次模型调整都能在合并前回答同一个问题:现有数据是否还能安全抵达新版本。
常见问题
数据库迁移测试至少要保留哪些历史版本?
至少保留当前线上版本与前一个仍可能升级的版本;发布跨度较长时,应覆盖所有仍受支持的直接升级起点,而不是只测试相邻版本。
为什么不能只复制 SQLite 主文件?
启用 WAL 后,尚未合并的数据可能位于同名的 -wal 文件中。只复制主文件会得到缺记录或状态不一致的样本,必须先安全 checkpoint,或完整保存 sqlite、sqlite-wal 与 sqlite-shm 文件组。
为构建、测试与实验选择独享物理节点
两档 M4 配置覆盖新加坡、东京、首尔与香港节点。实际可用状态以下单时返回的结果为准。