某個 UI 測試從上週開始穩定失敗,但近期已合併數十個提交。逐一切換提交、開啟 Xcode 再執行測試,不但耗時,也很容易忽略環境差異。更有效率的做法,是把「這個提交是否引入目標故障」寫成可重複執行的命令,再交給 git bisect 進行二分搜尋。若有 64 個候選提交,理論上只需要約 6 輪判定。真正的難點不在二分演算法,而在於如何確保每一輪的結果都可信。
先把回歸定義成機器可判斷的結果
開始前至少要準備兩個邊界:一個已確認正常的提交,以及一個已確認異常的提交。不要只用「頁面有問題」這類模糊描述,而應把回歸收斂成單一可觀察項目,例如某個 XCTest 斷言失敗、指定 Scheme 無法封存,或固定輸入產生錯誤輸出。
判定條件應同時固定以下變數:
- Scheme、Configuration 與測試目標
- Xcode 選取路徑與相依套件鎖定檔
- 模擬器型號、系統版本、語言及時區
- 測試資料、網路相依項目與執行帳號
- 判斷正常與異常時採用的唯一證據
git bisect會如實放大判定器的誤差。若把一次偶發失敗誤判為異常提交,即使後續搜尋順利完成,結論仍可能完全錯誤。
先在正常與異常提交上,分別手動執行兩次相同命令。若結果無法穩定重現,應先改善測試隔離,而不是立刻開始二分搜尋。
為每個候選提交隔離執行環境
在 G-Mini 的雲端 Mac 上執行時,建議使用專用工作副本,不要在開發者正在編輯的目錄中操作。二分過程會頻繁切換提交,未追蹤檔案、自動產生的設定與共用快取都可能污染判定結果。
| 隔離項目 | 建議做法 | 原因 |
|---|---|---|
| Git 工作區 | 使用獨立 clone 或 worktree | 避免覆寫日常修改 |
| DerivedData | 依提交雜湊分目錄 | 防止舊產物跨提交重複使用 |
| 模擬器 | 固定 UDID | 避免 destination 自動選取發生漂移 |
| 結果套件 | 每輪分別儲存 | 方便複核第一個異常提交 |
| 相依套件 | 保留鎖定檔並停用隱式更新 | 避免版本解析結果改變 |
開始前執行 git status --porcelain,結果必須為空。模擬器應預先建立並啟動,再把 UDID 寫入環境變數。不要讓判定指令碼在執行時依名稱臨時選取「任何可用裝置」;同名裝置與系統升級都可能改變實際使用的目標。
編寫三態 Xcode 判定指令碼
git bisect run 不只接受成功與失敗兩種結果。結束碼 0 代表正常,1 到 127 代表異常,而 125 則表示目前提交無法判斷,應予以跳過。對歷史較久的專案而言,第三種狀態尤其重要:舊提交可能無法搭配目前的相依套件完成編譯,但不代表它一定包含正在調查的回歸。
以下指令碼會先確認專案能否建置,再只執行目標測試。基礎建置失敗會被標記為無法判斷;只有測試明確失敗時,才會視為異常。
#!/bin/zsh
set -u
: "${DESTINATION_ID:?Set DESTINATION_ID first}"
sha="$(git rev-parse --short HEAD)"
root="${TMPDIR:-/tmp}/xcode-bisect"
derived="$root/derived-$sha"
result="$root/result-$sha.xcresult"
log="$root/test-$sha.log"
mkdir -p "$root"
rm -rf "$result"
xcodebuild \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-destination "platform=iOS Simulator,id=$DESTINATION_ID" \
-derivedDataPath "$derived" \
build >"$root/build-$sha.log" 2>&1
if [[ $? -ne 0 ]]; then
exit 125
fi
xcodebuild \
-project App.xcodeproj \
-scheme App \
-destination "platform=iOS Simulator,id=$DESTINATION_ID" \
-derivedDataPath "$derived" \
-resultBundlePath "$result" \
-only-testing:AppTests/CheckoutReducerTests/testExpiredCart \
test >"$log" 2>&1
status=$?
if [[ $status -eq 0 ]]; then
exit 0
fi
if grep -q "TEST FAILED" "$log"; then
exit 1
fi
exit 125
依故障類型調整分類方式
如果調查的是編譯回歸,建置失敗就應回傳 1,而不是 125。如果調查的是行為回歸,則相依套件下載失敗、模擬器服務異常、磁碟寫入失敗等基礎環境問題都必須跳過。不要把所有 xcodebuild 的非零結束碼一律解讀成目標回歸。
啟動二分搜尋並複核第一個異常提交
準備好指令碼後,先記錄目前分支與工作區狀態,再執行:
chmod +x ./scripts/bisect-xcode.sh
export DESTINATION_ID="固定的模拟器UDID"
git bisect start
git bisect bad BAD_COMMIT
git bisect good GOOD_COMMIT
git bisect run ./scripts/bisect-xcode.sh
搜尋結束後,Git 會指出第一個異常提交。此時不要立刻關閉問題,應分別在該提交及其父提交上手動執行指令碼,並檢查已儲存的建置記錄與 .xcresult。同時也要閱讀提交差異,確認變更內容確實能解釋觀察到的行為,而不是剛好觸發另一個失敗。
完成後執行 git bisect reset 回到原本的分支。如果二分期間產生大量 DerivedData,可在複核完成後統一清理。調查階段不要太早刪除結果套件,否則會失去判定過程的證據。
處理偶發測試與歷史斷層
偶發測試採用多數判定
如果單次執行不穩定,可讓判定器連續執行三次,只有同一個目標失敗至少出現兩次時才回傳 1。如果三次結果彼此矛盾,則回傳 125。這會增加執行時間,但仍比在錯誤提交上繼續調查更划算。每次重複執行前仍須重設測試資料,不能讓上一輪的狀態影響下一輪。
跳過過多提交時縮小邊界
大量 125 可能使 Git 無法找出唯一提交。常見原因包括專案格式、相依套件管理方式或測試名稱曾在歷史版本中變更。此時應將搜尋範圍移到遷移完成之後,或為舊目錄結構加入相容分支,但不要讓指令碼自動修改受測原始碼。
最終應保留正常提交、異常提交、第一個異常提交、判定指令碼版本、模擬器 UDID、Xcode 版本及結果套件路徑。如此一來,另一位工程師才能重新執行並驗證定位結果,也能在修正提交完成後,直接使用相同設定進行回歸驗證。
常見問題
git bisect 判定腳本應回傳哪些結束碼?
正常提交回傳 0,確認出現目標回歸時回傳 1,無法可靠判定時回傳 125。依賴下載或模擬器故障不應直接被標成異常提交。
偶發失敗的 Xcode 測試能直接用 git bisect 嗎?
應先固定模擬器、語系、時區與測試資料,並讓每個提交重複執行多次。只有失敗次數達到既定門檻時才判為異常,結果不明時回傳 125。
為下一個建置佇列準備雲端 Mac
比較兩種 M4 配置與五個節點,依實際工作週期選擇日租、週租、月租或季租。