雲端 Mac 上的 iOS 啟動參數與環境變數契約測試

雲端 Mac 上的 iOS 啟動參數與環境變數契約測試

UI 測試在開發者電腦上順利通過,移到雲端 Mac 連續執行數十次後,卻偶爾停在引導頁面。常見原因通常不是模擬器效能,而是啟動狀態沒有形成明確契約。某個測試案例傳入 -skipOnboarding,另一個改用環境變數,第三個則忘了清除持久化資料;單獨執行時看似都合理,一旦並行執行或變更順序,就會開始互相污染。解決方式是把參數名稱、值格式、狀態重設與 Release 排除條件視為一套可測試的介面。

先定義啟動契約,而不是散落字串

啟動參數適合布林開關,環境變數則適合帶值設定。兩者都應集中宣告,避免測試 target 與應用程式 target 各自使用不同拼法。下列解析器只會在 Debug 建置中生效,並且針對缺值與空值採用明確的預設行為。

#if DEBUG
enum UITestLaunchContract {
    static let arguments = ProcessInfo.processInfo.arguments
    static let environment = ProcessInfo.processInfo.environment

    static var enabled: Bool {
        arguments.contains("-ui-testing")
    }

    static var resetState: Bool {
        arguments.contains("-reset-state")
    }

    static var fixture: String? {
        guard enabled else { return nil }
        return environment["UITEST_FIXTURE"]?.trimmingCharacters(
            in: .whitespacesAndNewlines
        ).nonEmpty
    }
}

private extension String {
    var nonEmpty: String? {
        isEmpty ? nil : self
    }
}
#endif

契約至少要回答四個問題:開關名稱是什麼、值從哪裡讀取、如何處理無效值,以及非測試啟動時會發生什麼。不要讓「未傳入值」隱含等同於某個業務狀態,也不要把存取憑證、簽章材料或權杖放進環境變數;這些資料可能出現在程序資訊、測試報告或失敗日誌中。

測試入口的目標是建立可重現的狀態,而不是繞過業務驗證。能透過公開介面完成的斷言,仍應使用公開介面;只有初始化夾具與清除殘留資料適合納入啟動契約。

在應用程式啟動初期完成狀態隔離

狀態重設必須發生在讀取使用者偏好設定、建立資料庫容器與決定第一個畫面之前。若先建構介面再進行非同步清理,測試將同時觀察到舊狀態與新狀態,形成難以重現的競態條件。

只清除測試所擁有的資料

UI 測試應使用獨立的 suite,而不是執行沒有邊界的目錄刪除。資料庫也應放在測試專用容器中,並在建立持久化堆疊前移除。

#if DEBUG
func prepareUITestState() throws {
    guard UITestLaunchContract.enabled else { return }

    if UITestLaunchContract.resetState {
        let defaults = UserDefaults(suiteName: "com.example.app.uitests")
        defaults?.removePersistentDomain(
            forName: "com.example.app.uitests"
        )

        let storeURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("UITestStore.sqlite")
        if FileManager.default.fileExists(atPath: storeURL.path) {
            try FileManager.default.removeItem(at: storeURL)
        }
    }
}
#endif

清理函式應可重複執行:目標不存在時不應報錯,刪除失敗時則應立即終止測試啟動,而不是帶著只清除一部分的狀態繼續執行。夾具名稱也應透過白名單對應,例如 empty-projectthree-items,不要允許環境變數直接轉換成任意檔案路徑。

由 XCTest 統一組裝啟動參數

測試端只呼叫單一啟動工廠。它負責終止舊程序、寫入固定參數並檢查夾具,測試方法不再自行修改 launchArguments

final class AppLauncher {
    static func launch(fixture: String) -> XCUIApplication {
        let allowed = ["empty-project", "three-items"]
        precondition(allowed.contains(fixture))

        let app = XCUIApplication()
        if app.state != .notRunning {
            app.terminate()
        }

        app.launchArguments = [
            "-ui-testing",
            "-reset-state",
            "-disable-animations"
        ]
        app.launchEnvironment = [
            "UITEST_FIXTURE": fixture,
            "LC_ALL": "en_US_POSIX"
        ]
        app.launch()
        return app
    }
}

launchArguments 應整體指派,不要在共用的應用程式實例上連續執行 append。整體指派可確保重新執行時,不會殘留上一個測試案例留下的開關。若測試需要兩種不同狀態,應先終止程序,再重新呼叫工廠,而不是在執行期間修改環境變數;程序啟動後,ProcessInfo 不會依照測試腳本的新值重新初始化。

讓契約錯誤直接導致流水線失敗

單靠程式碼審查無法完全阻擋舊參數。可以加入一個小型單元測試,驗證參數名稱不重複、所有夾具都能解析,並讓 UI 冒煙測試涵蓋三條路徑:不帶參數時正常啟動、使用合法夾具時進入預期的第一個畫面,以及使用非法夾具時明確失敗。

在雲端 Mac 上執行之前,先固定 scheme、測試計畫與模擬器識別碼:

set -euo pipefail

: "${SIMULATOR_ID:?SIMULATOR_ID is required}"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan CI \
  -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
  -resultBundlePath artifacts/LaunchContract.xcresult

流水線不應以「再執行一次」掩蓋首次失敗。第一次失敗的結果套件必須原樣保留;重試只能作為診斷步驟,並且應使用新的輸出路徑。否則第二次成功會覆蓋最有價值的啟動現場。

檢查 Release 編譯條件

測試入口使用 #if DEBUG 後,還要確認 Release 沒有誤帶 DEBUG 或自訂測試條件:

set -euo pipefail

settings="$(
  xcodebuild \
    -workspace App.xcworkspace \
    -scheme App \
    -configuration Release \
    -showBuildSettings
)"

if printf '%s\n' "$settings" |
  grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
  echo "Unexpected test compilation condition in Release" >&2
  exit 1
fi

這項檢查針對最終解析完成的建置設定,比只查看專案檔案更可靠。若團隊使用多套 xcconfig,應對每個可發布的 configuration 分別執行。

處理並行執行與失敗現場

並行測試時,每個 worker 都必須擁有獨立的資料識別碼。可以由流水線產生不含敏感資訊的 UITEST_RUN_ID,再組合出暫存目錄與 suite 名稱;不要讓多個 worker 共用固定的資料庫檔案。測試完成後,只刪除目前執行識別碼所對應的目錄,避免某個工作清除另一個工作的失敗現場。

失敗證據至少應保留啟動參數白名單、夾具名稱、測試方法、模擬器識別碼與結果套件路徑。不要在日誌中輸出完整的環境變數字典,因為其中可能混入流水線注入的敏感值。建議只記錄明確允許的鍵,並依值的類型進行遮蔽處理。

可將疑難排解順序固定為:

  1. 確認應用程式是否在啟動前確實終止。
  2. 檢查參數是否整體指派,且沒有重複的鍵。
  3. 確認狀態清理是否早於資料庫與根介面初始化。
  4. 比較失敗案例與前一個案例使用的資料目錄。
  5. 使用同一個結果套件檢查當機、斷言與螢幕截圖時間軸。
  6. 不帶任何測試參數執行一次冷啟動,確認正常入口未受影響。

上線前的最小驗收清單

提交合併前,應同時符合以下條件:集中定義參數名稱;帶值設定具有白名單;測試資料位於獨立容器;清理操作可重複執行;每次啟動都會整體覆寫參數;並行 worker 不共用目錄;失敗結果套件不會被重試覆蓋;Release 編譯條件不含測試標記;不帶參數的冷啟動能進入正常流程。

這套約束的價值,不只是多寫一個啟動工廠,而是讓「應用程式以什麼狀態啟動」從測試腳本中的隱含約定,轉變為應用程式與流水線都能驗證的介面。當狀態可以描述、拒絕與清除後,雲端 Mac 上變更順序、並行執行與長期重複執行的結果,才真正具備可比性。

常見問題

為什麼不能在每個 UI 測試中任意加入啟動參數?

分散字串容易產生拼字差異、順序依賴與過期旗標殘留。應由單一型別定義名稱、值格式及預設行為,讓應用程式與測試目標共同使用。

如何確認測試入口沒有進入 Release 建置?

把入口實作限制在 DEBUG 條件編譯內,於 CI 檢查 Release 的編譯條件與建置設定,再執行一次不帶測試參數的冷啟動驗收。

雲端 Mac

將經驗驗證的工作流程部署至獨享實體節點

依工作選擇 M4 或 M4 Pro、目標節點與計費週期,下單前確認設定與附加選項。

選擇設定並下單