一款 iOS App 把离线草稿写入 SQLite:用户看到“保存成功”后,进程却在下一次写入时崩溃。重新启动,草稿究竟还在不在?在云端 Mac 的独立模拟器上,可以把这个问题做成可重复的实验:先提交一条基线记录,再于第二个事务尚未提交时主动制造崩溃,最后直接检查数据库。这里验证的是事务边界,不是构建速度,也不以“应用能重新打开”代替数据检查。
先定义合格结果
准备一张只有 id 和 phase 两列的测试表。记录 1|committed 必须在崩溃前完成提交;记录 2|pending 必须在开启的新事务中写入,但不能提交。恢复后,完整性检查应返回 ok,查询只能得到第一条记录。若第二条也存在,应先排查故障点是否放在了提交之后;若第一条消失,则检查它是否真的写入了同一个数据库文件。
WAL 不是“所有写入都不会丢”的承诺。测试只要求已成功提交的事务在这次进程崩溃后可读,未提交事务不成为已保存的数据;它不能替代断电、存储损坏或业务级同步测试。
实验使用一次性模拟器和测试包名。先固定 Xcode 版本与模拟器运行时,记录 xcodebuild -version 和模拟器标识;每轮使用同一组条件。不要在个人开发模拟器或真实用户数据库上执行故障注入。
在提交边界注入故障
把故障入口限制在调试构建中,并要求显式启动参数,防止普通运行误触发。下面的函数假定调用方已经打开 Documents/Recovery.sqlite,且每个 SQL 调用都会检查返回值;示例中用 precondition 让准备失败立即暴露,而不是继续产生误导性的查询结果。
#if DEBUG
import Darwin
import SQLite3
func runRecoveryProbe(_ db: OpaquePointer) {
func execute(_ sql: String) {
precondition(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK)
}
execute("PRAGMA journal_mode=WAL")
execute("CREATE TABLE IF NOT EXISTS probe (id INTEGER PRIMARY KEY, phase TEXT NOT NULL)")
execute("INSERT OR REPLACE INTO probe VALUES (1, 'committed')")
execute("BEGIN IMMEDIATE")
execute("INSERT OR REPLACE INTO probe VALUES (2, 'pending')")
abort()
}
#endif
只在启动参数包含专用测试标记时调用它;测试入口应先确认使用的是实验数据库,且此前没有未结束的事务。PRAGMA journal_mode=WAL 要在事务之外执行。abort() 会使本轮进程异常终止,因此代码后面刻意没有 COMMIT。如果实际项目使用数据库封装层,应通过该封装层建立相同的提交边界,不要绕开它后仍声称验收了生产写入路径。
避免旧记录污染
每轮先重置一次性模拟器中的测试应用数据,或安装到新建的专用模拟器;不要只执行 DELETE FROM probe,却留下其他业务表和文件。首次运行前确认数据库确实位于应用数据容器内。注入前后记录数据库路径、构建版本和模拟器标识,但不要把实际项目数据复制进实验容器。
崩溃后检查原始现场
启动应用后,先确认它因预设故障异常终止,再做查询。仅看到启动命令返回,并不足以证明故障已经发生:应用可能尚未运行到测试入口。可用模拟器中的进程状态和崩溃报告交叉确认;没有崩溃证据的这一轮应判为“未完成测试”,不能算通过。
确认进程已经退出后,在云端 Mac 的终端读取应用数据容器。将变量替换为本轮模拟器标识和测试包名:
UDID="your-simulator-udid"
BUNDLE_ID="com.example.RecoveryProbe"
DATA="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
sqlite3 "$DATA/Documents/Recovery.sqlite" \
'PRAGMA integrity_check; SELECT id, phase FROM probe ORDER BY id;'
预期输出依次是 ok 和 1|committed。检查前不要删除或单独搬走数据库旁的 -wal、-shm 文件;让 SQLite 按原样打开数据库。检查时也不要启动会再次写入的应用进程。若项目原本就有多个数据库,先核对文件名及容器路径,避免把“查错文件”当作恢复失败。
把失败分成可定位的几类
输出包含 2|pending 时,优先检查是否有自动提交、封装层提前结束事务,或故障标记进入了另一条代码路径。缺少 1|committed 时,确认第一条写入是否返回成功,并核对测试是否在同一连接、同一文件中执行。如果查询提示“没有该表”,先确认安装的是包含测试入口的构建,以及容器路径没有指向上一轮模拟器。
integrity_check 不是业务断言:即使返回 ok,也仍须逐条核对记录。反过来,进程崩溃报告只能证明进程异常结束,不能证明事务恢复正确。把这两类证据连同 Xcode 版本、模拟器标识和测试包的构建标识一起保存,复现失败时才有可比条件。
接入流水线时保留边界
让这项测试单独占用模拟器,在故障注入结束、确认进程退出后,再运行只读检查步骤。不要与普通 UI 测试共用同一应用数据容器:并行写入会改变观察结果。故障入口只存在于调试构建,发布构建不包含它;流水线应明确检查这一点。
最后再做一轮不注入崩溃的对照:在正常路径提交第二条记录,并确认它能够读出。这样既验证“未提交写入被回滚”,也验证测试本身没有把所有写入都挡掉。得到这两组结果,才能把一次偶然的重启现象变成可复核的持久化验收。
常见问题
为什么不能只调用应用的正常退出流程?
正常退出可能完成提交或执行清理逻辑。崩溃恢复测试必须在事务未提交时直接终止测试进程,并确认确实产生了异常终止。
检查数据库前可以删除 -wal 和 -shm 文件吗?
不可以。应保持数据库及其伴随文件原样,用 SQLite 打开数据库检查完整性和记录;提前删除可能破坏现场并改变恢复结果。
把下一次 Xcode 构建放到独享物理机上。
选择 Apple Silicon 机型、节点和租期;通过远程连接运行自己的构建流程。实际可用条件以订购时控制台返回为准。