Runner 工程文章

云端 Mac 上建立 iOS 状态恢复回归测试

云端 Mac 上建立 iOS 状态恢复回归测试

用户在云端 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)
}

同一条测试中不要在第二次启动时继续传入重置参数,否则测试会亲手删除需要验证的状态。也不要只断言编辑器存在;应同时检查内容、导航标题、选中对象和恢复提示。

分开测试三种启动路径

至少保留三条独立用例:

  1. 从后台直接回到前台,验证短暂离开不会重建整个页面。
  2. 保存状态后终止进程,验证重新启动能够继续工作。
  3. 构造失效对象后重新启动,验证应用安全降级并清除无效路由。

每条用例只验证一种生命周期,失败定位会比一个包含十几步的长测试更直接。

在云端 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,成功任务则可按团队策略缩短保存时间。

排查顺序可以固定为:

  1. 确认第一次启动确实写入了状态。
  2. 检查进入后台时是否完成原子保存。
  3. 确认第二次启动没有再次执行清理逻辑。
  4. 检查持久化数据的版本迁移是否成功。
  5. 最后检查路由是否接受恢复后的对象。

如果草稿已恢复但页面回到首页,问题多半在导航重建;如果页面正确但字段为空,应检查持久化时机与存储键;如果只有并行任务失败,优先检查模拟器、结果目录和夹具是否真正隔离。

将这些场景纳入合并门禁后,状态恢复就不再依赖人工反复切换应用。每次修改持久化模型、Scene 生命周期或导航结构时,流水线都能用相同输入验证用户是否仍可从中断处继续工作。

常见问题

状态恢复测试是否应该直接复用开发者的日常模拟器?

不应该。应为自动化任务创建独立模拟器或设备集,并在每个场景前清理应用数据,避免历史状态让测试误通过。

为什么只验证应用重新打开还不够?

重新打开只能证明应用没有崩溃,还需要断言草稿内容、导航层级、选中对象和恢复提示是否符合预期,同时确认敏感临时数据没有被错误保留。

独享物理节点

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

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

立即租用云端 Mac