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-project、three-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 共用固定的資料庫檔案。測試完成後,只刪除目前執行識別碼所對應的目錄,避免某個工作清除另一個工作的失敗現場。
失敗證據至少應保留啟動參數白名單、夾具名稱、測試方法、模擬器識別碼與結果套件路徑。不要在日誌中輸出完整的環境變數字典,因為其中可能混入流水線注入的敏感值。建議只記錄明確允許的鍵,並依值的類型進行遮蔽處理。
可將疑難排解順序固定為:
- 確認應用程式是否在啟動前確實終止。
- 檢查參數是否整體指派,且沒有重複的鍵。
- 確認狀態清理是否早於資料庫與根介面初始化。
- 比較失敗案例與前一個案例使用的資料目錄。
- 使用同一個結果套件檢查當機、斷言與螢幕截圖時間軸。
- 不帶任何測試參數執行一次冷啟動,確認正常入口未受影響。
上線前的最小驗收清單
提交合併前,應同時符合以下條件:集中定義參數名稱;帶值設定具有白名單;測試資料位於獨立容器;清理操作可重複執行;每次啟動都會整體覆寫參數;並行 worker 不共用目錄;失敗結果套件不會被重試覆蓋;Release 編譯條件不含測試標記;不帶參數的冷啟動能進入正常流程。
這套約束的價值,不只是多寫一個啟動工廠,而是讓「應用程式以什麼狀態啟動」從測試腳本中的隱含約定,轉變為應用程式與流水線都能驗證的介面。當狀態可以描述、拒絕與清除後,雲端 Mac 上變更順序、並行執行與長期重複執行的結果,才真正具備可比性。
常見問題
為什麼不能在每個 UI 測試中任意加入啟動參數?
分散字串容易產生拼字差異、順序依賴與過期旗標殘留。應由單一型別定義名稱、值格式及預設行為,讓應用程式與測試目標共同使用。
如何確認測試入口沒有進入 Release 建置?
把入口實作限制在 DEBUG 條件編譯內,於 CI 檢查 Release 的編譯條件與建置設定,再執行一次不帶測試參數的冷啟動驗收。
將經驗驗證的工作流程部署至獨享實體節點
依工作選擇 M4 或 M4 Pro、目標節點與計費週期,下單前確認設定與附加選項。