用户在云端 Mac 上编辑一份尚未提交的表单,切到其他应用处理资料,几分钟后进程被系统结束。再次打开应用时,最糟糕的结果不是崩溃,而是界面看似正常,草稿却被清空、导航退回首页,或者恢复了已经失效的对象。此类问题很难靠普通单元测试覆盖,更适合在独立模拟器中用 XCUITest 重放完整生命周期。
先定义哪些状态应该恢复
不要把“恢复上次界面”当成单一需求。开始写测试前,先把状态分为三类:
| 状态类型 | 示例 | 重新启动后的预期 |
|---|---|---|
| 业务状态 | 草稿文本、筛选条件、选中项目 | 按产品规则恢复 |
| 导航状态 | 当前页面、标签页、列表位置 | 恢复到仍然有效的层级 |
| 临时状态 | 加载动画、弹窗、一次性令牌 | 丢弃并重新计算 |
恢复逻辑还要区分正常后台切换与异常终止。前者可以保留更多界面细节,后者则应优先保证数据一致性。如果草稿依赖一个已经删除的对象,应用应回到安全页面并解释原因,而不是强行重建失效界面。
状态恢复测试的目标不是让每个像素回到原位,而是确保用户可继续工作,并且不会看到过期、越权或无法提交的数据。
建议为每个场景写出“准备状态、终止动作、恢复断言、禁止出现的状态”四项。这样测试失败时,团队能判断是持久化、路由还是数据校验出了问题。
给自动化准备确定性入口
UI 测试不应依赖上一次运行留下的数据。应用可以在仅供测试的启动参数下导入固定夹具,并把数据写入独立容器。夹具名称应表达业务场景,而不是使用容易变化的数据库主键。
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("-ui-testing"),
let index = arguments.firstIndex(of: "-fixture"),
arguments.indices.contains(index + 1) {
let fixture = arguments[index + 1]
try TestFixtureLoader.load(named: fixture)
}
生产构建必须忽略或移除这条测试入口。更稳妥的做法是使用编译条件,把加载器限制在内部测试配置中。测试开始前还要清除通知、缓存和共享偏好设置,避免“旧草稿刚好存在”造成误通过。
在 Runner 的独享物理节点上执行时,仍应给每条流水线分配独立的模拟器设备集。独享计算资源不等于测试数据天然隔离;两个并发任务共用同一个模拟器,照样会互相覆盖应用容器。
用 XCUITest 重放进程终止与重启
下面的测试先创建草稿,再把应用送入后台、终止进程并重新启动。所有关键控件都应设置稳定的 accessibility identifier,避免依赖本地化文案查找元素。
func testDraftRestoresAfterTermination() {
let app = XCUIApplication()
app.launchArguments = [
"-ui-testing",
"-fixture", "empty-project",
"-reset-state", "YES"
]
app.launch()
app.buttons["project.create"].tap()
let editor = app.textViews["draft.editor"]
editor.tap()
editor.typeText("release checklist")
app.buttons["draft.save"].tap()
XCUIDevice.shared.press(.home)
app.terminate()
app.launchArguments = [
"-ui-testing",
"-fixture", "empty-project"
]
app.launch()
XCTAssertTrue(app.navigationBars["draft.screen"].waitForExistence(timeout: 8))
XCTAssertEqual(app.textViews["draft.editor"].value as? String,
"release checklist")
XCTAssertTrue(app.staticTexts["restoration.completed"].exists)
}
同一条测试中不要在第二次启动时继续传入重置参数,否则测试会亲手删除需要验证的状态。也不要只断言编辑器存在;应同时检查内容、导航标题、选中对象和恢复提示。
分开测试三种启动路径
至少保留三条独立用例:
- 从后台直接回到前台,验证短暂离开不会重建整个页面。
- 保存状态后终止进程,验证重新启动能够继续工作。
- 构造失效对象后重新启动,验证应用安全降级并清除无效路由。
每条用例只验证一种生命周期,失败定位会比一个包含十几步的长测试更直接。
在云端 Mac 上隔离模拟器与运行目录
自动化任务应显式创建临时设备集,而不是使用登录用户默认目录中的模拟器。任务结束后删除设备集,可以同时清理应用容器和残留快照。
set -euo pipefail
DEVICE_SET="$RUNNER_TEMP/CoreSimulator"
RESULTS="$RUNNER_TEMP/StateRestoration.xcresult"
mkdir -p "$DEVICE_SET"
xcrun simctl --set "$DEVICE_SET" create \
"State-Restore-iPhone" \
"com.apple.CoreSimulator.SimDeviceType.iPhone-16" \
"com.apple.CoreSimulator.SimRuntime.iOS-18-0"
UDID="$(xcrun simctl --set "$DEVICE_SET" list devices \
available -j | /usr/bin/python3 scripts/first_device.py)"
xcrun simctl --set "$DEVICE_SET" boot "$UDID"
xcodebuild test \
-workspace Example.xcworkspace \
-scheme ExampleUITests \
-destination "platform=iOS Simulator,id=$UDID" \
-resultBundlePath "$RESULTS"
xcrun simctl --set "$DEVICE_SET" shutdown "$UDID" || true
rm -rf "$DEVICE_SET"
示例中的运行时与设备类型必须替换为节点上已经安装的版本,可先执行 xcrun simctl list runtimes 与 xcrun simctl list devicetypes 检查。不要让脚本静默选择“最新”运行时,否则 Xcode 更新后,截图、系统弹窗和恢复行为可能一起变化。
把失败证据变成可诊断结果
状态恢复失败通常发生在第二次启动后,因此需要保留恢复前后的证据。推荐在关键动作后附加截图,并把测试使用的夹具名、模拟器标识、应用版本和恢复阶段写入 XCTest attachment。失败时保留 .xcresult,成功任务则可按团队策略缩短保存时间。
排查顺序可以固定为:
- 确认第一次启动确实写入了状态。
- 检查进入后台时是否完成原子保存。
- 确认第二次启动没有再次执行清理逻辑。
- 检查持久化数据的版本迁移是否成功。
- 最后检查路由是否接受恢复后的对象。
如果草稿已恢复但页面回到首页,问题多半在导航重建;如果页面正确但字段为空,应检查持久化时机与存储键;如果只有并行任务失败,优先检查模拟器、结果目录和夹具是否真正隔离。
将这些场景纳入合并门禁后,状态恢复就不再依赖人工反复切换应用。每次修改持久化模型、Scene 生命周期或导航结构时,流水线都能用相同输入验证用户是否仍可从中断处继续工作。
常见问题
状态恢复测试是否应该直接复用开发者的日常模拟器?
不应该。应为自动化任务创建独立模拟器或设备集,并在每个场景前清理应用数据,避免历史状态让测试误通过。
为什么只验证应用重新打开还不够?
重新打开只能证明应用没有崩溃,还需要断言草稿内容、导航层级、选中对象和恢复提示是否符合预期,同时确认敏感临时数据没有被错误保留。
在云端 M4 Mac 上运行下一项工作
选择 Runner M4 或 Runner M4 Plus,按项目需要确定节点、租期和存储附加项。每笔订单对应独享物理机,非虚拟机。