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 生命週期或導覽結構時,流水線都能使用相同輸入,驗證使用者是否仍可從中斷處繼續工作。

常見問題

狀態還原測試可以直接使用開發者日常操作的模擬器嗎?

不建議。自動化工作應使用獨立模擬器或裝置集,並在每個案例開始前清除 App 資料,避免既有狀態造成錯誤通過。

為什麼不能只檢查 App 是否成功重新開啟?

成功開啟只能證明沒有立即崩潰,還要驗證草稿內容、導覽層級、選取項目與還原提示,並確認敏感暫存資料沒有被不當保留。

獨享實體節點

在雲端 M4 Mac 上執行下一項工作

選擇 Runner M4 或 Runner M4 Plus,依專案需求決定節點、租期與儲存空間附加選項。每筆訂單皆對應一台獨享實體機,並非虛擬機。

立即租用雲端 Mac