當一套 iOS 專案包含數千個單元測試與 UI 測試時,直接執行一次 xcodebuild test,通常會同時負責編譯、啟動模擬器、安裝 App,以及執行所有測試案例。若失敗發生在最後幾分鐘,重新執行時卻又得從編譯開始。雲端 Mac 更適合把這條流程拆成兩個階段:先產生一次可供測試的建置,再讓多個邊界明確的測試分流重用它。
先將編譯與執行分開
Xcode 提供的 build-for-testing 會建置 App、測試宿主與測試套件,並產生描述執行環境的 .xctestrun 檔案。後續使用 test-without-building,即可避免每個分流重複解析相依套件與編譯原始碼。
先固定工作區、Scheme、目標裝置與 DerivedData 路徑:
set -euo pipefail
DERIVED_DATA="$PWD/.ci/DerivedData"
RESULTS="$PWD/.ci/Results"
DESTINATION="platform=iOS Simulator,name=iPhone 16,OS=latest"
rm -rf "$DERIVED_DATA" "$RESULTS"
mkdir -p "$RESULTS"
xcodebuild build-for-testing \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$DESTINATION" \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO
find "$DERIVED_DATA/Build/Products" -name "*.xctestrun" -print
CODE_SIGNING_ALLOWED=NO 僅適用於不依賴實機簽署的模擬器測試。若測試 Target 包含必須簽署的元件,應移除此參數,並讓專案本身的簽署設定生效。建置成功後不要移動 DerivedData;.xctestrun 可能會參照其中的絕對路徑。
測試分流解決的是測試排程問題,無法修正測試之間的共享狀態。如果某個測試案例必須等另一個案例先執行,即使在單機依序執行時看似穩定,拆分後仍會暴露真正的缺陷。
使用測試清單定義穩定分流
不要以「每組約一百個方法」的方式手動切分。方法名稱經常變動,維護成本很高。更穩妥的做法是依測試 Target、測試類別或業務領域劃分,並將清單納入版本庫。
| 分流 | 建議範圍 | 適合納入的測試 |
|---|---|---|
| unit-core | 純邏輯層 | 資料轉換、驗證、狀態機 |
| unit-storage | 持久化層 | 資料庫、快取、遷移 |
| ui-account | 帳戶流程 | 登入、設定、權限頁面 |
| ui-checkout | 交易流程 | 商品選擇、確認與異常路徑 |
清單可以保留為一般文字檔:
AppTests/ParserTests
AppTests/SessionReducerTests
AppTests/ValidationTests
執行時,將每一行轉換成一個 -only-testing: 參數。測試識別碼通常採用 Target/Class 或 Target/Class/testMethod。先執行 xcodebuild -list 核對 Scheme,再以一次小範圍命令驗證識別碼,避免分流因拼字錯誤而實際執行零個測試。
單獨執行一個分流
XCTESTRUN="$(find "$DERIVED_DATA/Build/Products" -name '*.xctestrun' -print -quit)"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "$DESTINATION" \
-parallel-testing-enabled NO \
-only-testing:AppTests/ParserTests \
-only-testing:AppTests/SessionReducerTests \
-resultBundlePath "$RESULTS/unit-core.xcresult"
每個分流都必須使用不同的 resultBundlePath。重用同一路徑會覆寫證據,也可能讓並行程序爭用同一個目錄。
為每個並行任務配置獨立模擬器
如果兩個 xcodebuild 程序同時使用同一台模擬器,App 安裝、啟動、權限狀態與剪貼簿資料都可能互相干擾。應為每個並行槽位準備專用裝置,並透過 UDID 指定目標。
先查看可用的執行階段:
xcrun simctl list runtimes
xcrun simctl list devicetypes
取得目前環境中的執行階段識別碼後,建立專用裝置:
xcrun simctl create \
ci-shard-1 \
com.apple.CoreSimulator.SimDeviceType.iPhone-16 \
"$RUNTIME_ID"
將傳回的 UDID 儲存至受控的執行設定中。每輪執行前先關機並清除狀態:
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
接著將 destination 改為 platform=iOS Simulator,id=$SIMULATOR_UDID。對於需要預先設定語言、權限或測試資料的任務,應在 erase 後統一注入,不要依賴上一次執行留下的狀態。
依資源壓力決定並行數量
明確啟動兩個分流後,如果又讓每個分流啟用 Xcode 內建並行功能,就會形成巢狀並行。模擬器數量、測試程序與編譯輔助程序會一起增加,最後甚至可能比依序執行更慢。第一版應設定 -parallel-testing-enabled NO,每次只增加一個分流槽位。
監控時至少應記錄三類訊號:
memory_pressure是否持續進入高壓狀態;vm_stat中的記憶體壓縮與換頁是否快速增加;- 每個分流的測試案例數、執行時間與失敗位置是否穩定。
分流不應只追求數量相等。一個包含大量 App 重新啟動操作的 UI 測試類別,可能比數百個純函式測試更慢。可以根據最近數次流水線的持續時間調整清單,但不要在執行階段隨機分配,否則將難以重現失敗並比較趨勢。
讓失敗可獨立重跑並保留證據
每個分流都應保存結束代碼、主控台記錄與獨立的 .xcresult。流水線的彙總階段可以失敗,但不應因為第一個分流失敗就刪除其他分流的證據。重新執行失敗組別時,繼續使用原始 .xctestrun,同時改用另一個結果路徑:
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-parallel-testing-enabled NO \
-only-testing:AppUITests/CheckoutTests \
-resultBundlePath "$RESULTS/ui-checkout-retry-1.xcresult"
如果原始碼、編譯參數、Xcode 版本或模擬器執行階段發生變化,就應重新執行 build-for-testing,不能把舊產物當成新提交的證據。最終保留測試清單、建置記錄、分流結果與環境版本,才能判斷失敗源自程式碼、測試狀態,還是執行環境。
這套結構的關鍵不在於複製多份測試命令,而是明確界定三項所有權:建置產物只產生一次、模擬器由單一分流獨占、結果套件永不互相覆寫。做到這三點後,擴縮分流與定向重跑才不會犧牲可重現性。
常見問題
顯式測試分流與 Xcode 內建平行測試有何差異?
顯式分流會固定每個工作項目的測試範圍與結果檔,較容易重跑及追蹤;內建平行測試則由 Xcode 在單次執行內自動分配。
每次執行前都需要刪除模擬器嗎?
不一定。長期保留專用模擬器並在執行前 erase,通常比反覆建立更容易管理;若測試依賴特殊初始狀態,則應重建並記錄執行環境。
需要一台由單一訂單獨享的實體 Mac mini?
核對 M4 與 M4 Pro 兩種配置、四種租用週期及 5 個可選節點,再依工作流程選擇裝置。