Runner Engineering Article

iOS State Restoration Regression Tests on a Cloud Mac

iOS State Restoration Regression Tests on a Cloud Mac

A user is editing an unsubmitted form on a cloud Mac, switches to another app to look up information, and the system terminates the process a few minutes later. When the app is opened again, a crash is not the worst possible outcome. More dangerous is an interface that appears normal even though the draft has been cleared, navigation has returned to the home screen, or an object that is no longer valid has been restored. Conventional unit tests rarely cover these failures well. XCUITest is better suited to replaying the complete lifecycle in an isolated simulator.

Define the restoration contract first

Do not treat “restore the previous screen” as a single requirement. Before writing tests, divide state into three categories:

State type Examples Expected after relaunch
Business state Draft text, filter criteria, selected item Restore according to product rules
Navigation state Current screen, tab, list position Restore to the deepest level that remains valid
Temporary state Loading animation, dialog, one-time token Discard and recompute

Restoration logic must also distinguish a normal transition to the background from an unexpected process termination. The former can preserve more interface details, while the latter should prioritize data consistency. If a draft depends on an object that has already been deleted, the app should return to a safe screen and explain why instead of forcing the invalid interface back into existence.

The goal of state restoration testing is not to return every pixel to its previous position. It is to ensure that users can continue working without seeing stale, unauthorized, or unsubmitable data.

For each scenario, document four items: the initial state, the termination action, the restoration assertions, and the states that must not appear. When a test fails, this makes it easier for the team to determine whether the problem is in persistence, routing, or data validation.

Provide deterministic entry points for automation

UI tests should not depend on data left behind by a previous run. Under launch arguments reserved exclusively for testing, the app can import a fixed fixture and write its data to an isolated container. Fixture names should describe business scenarios rather than use database primary keys that may change.

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)
}

Production builds must ignore or remove this test entry point. A safer approach is to use compilation conditions so the loader is available only in internal test configurations. Before each test, also clear notifications, caches, and shared preferences. Otherwise, an old draft that happens to exist can produce a false pass.

Even when tests run on Runner dedicated physical nodes, every pipeline should receive its own simulator device set. Dedicated compute resources do not automatically isolate test data. If two concurrent jobs share a simulator, they can still overwrite each other’s app containers.

Replay process termination and relaunch with XCUITest

The following test creates a draft, sends the app to the background, terminates the process, and launches the app again. Every important control should have a stable accessibility identifier so element lookup does not depend on localized text.

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)
}

Do not pass the reset argument again during the second launch in the same test. Doing so would make the test delete the state it is supposed to verify. Do not merely assert that the editor exists, either. Check its content, the navigation title, the selected object, and the restoration indicator as well.

Test three launch paths separately

Keep at least three independent test cases:

  1. Bring the app directly from the background to the foreground and verify that a brief interruption does not rebuild the entire screen.
  2. Terminate the process after saving state and verify that work can continue after relaunch.
  3. Create an invalid object, relaunch the app, and verify that it safely falls back and clears the invalid route.

Each test case should cover only one lifecycle. A failure will be easier to diagnose than in a long test containing more than a dozen steps.

Isolate the simulator and working directories on the cloud Mac

Automation jobs should explicitly create a temporary device set instead of using the simulator in the logged-in user’s default directory. Deleting the device set at the end of the job also removes app containers and leftover snapshots.

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"

Replace the runtime and device type in the example with versions installed on the node. Run xcrun simctl list runtimes and xcrun simctl list devicetypes first to inspect what is available. Do not let the script silently select the “latest” runtime. After an Xcode update, screenshots, system dialogs, and restoration behavior could all change at once.

Turn failure evidence into diagnosable results

State restoration failures usually occur after the second launch, so evidence from both before and after restoration must be retained. Capture screenshots after important actions, and add the fixture name, simulator identifier, app version, and restoration phase to XCTest attachments. Preserve the .xcresult for failed runs; retention for successful jobs can be shortened according to team policy.

Use a consistent troubleshooting order:

  1. Confirm that the first launch actually persisted the state.
  2. Check whether the atomic save completed when the app entered the background.
  3. Confirm that the second launch did not run the cleanup logic again.
  4. Check whether the persisted data migrated successfully between versions.
  5. Finally, verify that the router accepts the restored object.

If the draft is restored but the app returns to the home screen, the problem is probably in navigation reconstruction. If the correct screen appears but its fields are empty, inspect the persistence timing and storage keys. If failures occur only in parallel jobs, first verify that the simulators, result directories, and fixtures are genuinely isolated.

Once these scenarios become merge gates, state restoration no longer depends on someone repeatedly switching apps by hand. Whenever the persistence model, Scene lifecycle, or navigation structure changes, the pipeline can use the same inputs to verify that users can still resume work from the point where they were interrupted.

Frequently asked questions

Should state restoration tests reuse a developer's everyday simulator?

No. Use a dedicated simulator or device set and reset application data before each independent scenario. Otherwise, leftover state can make a broken restoration path appear to pass.

Why is checking that the app relaunches not enough?

A relaunch only proves that the app did not immediately crash. The test must also assert the restored draft, navigation depth, selected object, user-facing recovery message, and removal of sensitive temporary data.

Dedicated physical node

Run your next task on a cloud M4 Mac

Choose Runner M4 or Runner M4 Plus, then select the node, rental term, and storage add-ons for your project. Every order includes a dedicated physical machine, not a virtual machine.

Rent a cloud Mac now