品質關卡寫好之後,下一個實務問題是它們應該在什麼時候執行。所有測試都跑在每次 push 上,聽起來很嚴謹——直到開發者開始在漫長的 pipeline 中苦等,並開始尋找繞過它的方法。而如果把所有東西都推到發佈前才檢查,問題會很晚才出現,屆時修復的成本更高,而圍繞這次變更的脈絡可能早已消失。目標並不是把每個測試都跑得越頻繁越好,而是把每一項檢查放在它的結果最有用的位置。
同樣的原則也適用於 flaky 測試。一個紅、綠、紅反覆變色的測試,不只是製造一個惱人的失敗——它損害的是對整個 pipeline 的信任。假綠燈在測試其實沒問題時說:「測試沒問題」;而 flaky 測試在什麼都沒壞時說:「有東西壞了」。兩個方向都在教團隊不信任訊號。實務上的解法,是讓 CI/CD pipeline 對什麼時候跑什麼有所取捨,同時替不穩定的測試留下一條受控制的路徑:從偵測、到隔離、最後做出決定。
基本規則很簡單:一項檢查越快、越便宜、越可重複,它就應該跑得越頻繁。 靜態分析與單元測試通常外部依賴很少、很快可以完成,所以應該放在 pipeline 前段。在每次 push 上執行它們,開發者就能在變更記憶還新鮮時得到回饋。契約(contract)與整合檢查可以放後面一點,因為它們通常牽涉更多元件。更廣泛的 AI 評估可以再往後移,因為它們花費更長的時間,也可能消耗額外的資源。
舉例來說,如果開發者改了一個 API 回應欄位,卻要等 40 分鐘才讓評估套件發現一個幾秒鐘就能抓到的基本契約失敗,這幾乎沒有價值。一個有用的 pipeline 因此可能從靜態檢查與單元測試,走到契約與整合檢查,再到更廣泛的評估與營運檢查。重點不在於層數的多寡,而在於每一層的失敗都對應到一個明確的下一步動作。單元測試失敗,應該把開發者指回那個變更;契約測試失敗,應該指向介面本身;評估失敗,應該擋下或標記發佈決策,而不是變成又一則沒有人知道該怎麼解讀的通知。
這種分層也讓 CI/CD 更容易落地。以 CircleCI 為例,workflow 可以定義哪些作業跑在 push、pull request、排程執行,或是後面的部署階段。pipeline 於是成為一連串逐漸昂貴的證據,而不是一個巨大的測試水桶。這件事之所以重要,是因為快速回饋會改變開發者的行為:如果一個便宜的檢查能及早抓到問題,團隊就比較沒有動機只因為「pipeline 太久」而繞過它。
面對 flaky 測試的第一步是偵測,而不是刪除。把同一個案例、貼著同一個 commit 反覆執行,並記錄它的結果是否一致。如果它這次過、下次失敗,而程式碼與環境都沒有相關變更,這種不一致就是值得留下的證據。我不希望結論建立在記憶上——「這個測試本來就很不穩」——因為那會把工程問題變成鄉野傳說。pipeline 應該留下痕跡:案例、commit、執行次序、結果,以及不一致的模式。
一旦確認某個測試不穩定,第二步是隔離(quarantine)。測試可以繼續執行並回報結果,但暫時被移出阻擋路徑,讓一個不可靠的訊號不再擋住別人的工作。關鍵詞是暫時。隔離應該有負責人、有原因、有到期日。如果一個測試可以永遠處於隔離狀態,那 quarantine 就只是「停用這個測試」的客氣說法。
第三步才是決定:修好它、刪掉它,或把它改寫成穩定的版本。 依賴時序、共享狀態、不穩定外部服務或非確定性 AI 輸出的測試,可能需要重新設計,而不是反覆重試。當風險已經在其他地方被涵蓋、或測試已不再代表有效行為時,刪掉它是合理的;當底層的產品行為很重要、而測試正是要保護它時,修好它更好。目標不是不惜任何代價讓 pipeline 變綠,而是讓綠燈重新值得信任。
第一個難題是非確定性。傳統軟體測試往往假設相同輸入會產出完全相同的輸出,但 AI 系統面對同一個 prompt 與同一份上下文,可能產出不同的措辭——有時甚至是意思上不同的回應。這意味著 response == "Your order will arrive tomorrow" 這種斷言可能太脆弱。AI 測試因此可能需要規則或範圍:答案必須指出正確的送達日期、不得宣告未經授權的操作、必須包含必要的政策條件,並且維持在可接受的品質範圍內。
第二個難題是成本。每一次評估執行都可能消耗計費的模型用量,所以把整套評估集放在每次 push 上,可能讓 pipeline 變得不必要地昂貴。但這不代表成本檢查應該被默默跳過。pipeline 應該有明確的預算或上限,而超出上限應該依關卡的規則,產生看得見的失敗或阻擋條件。
第三個難題是時間。完整的黃金集(golden set)或對抗性(adversarial)評估,動輒要跑上數十分鐘,尤其是在牽涉多次模型呼叫時。答案是讓較小的抽樣樣本與完整評估分開,而不是假裝兩者做的是同一件事。
第一個 fragment 展示的是各階段該放在哪裡。下面的範例則展示:前面談的 flaky 處理順序,如何轉成 pipeline 設定——偵測從不阻擋、隔離只回報不設關、到期日強迫做出決定。再次強調,詞彙僅為示意:
pipeline:
- stage: flake-detect # 觸發條件:每次 push,並固定貼著同一個 commit
run: [record-case-hashes] # 每次執行記下案例 -> 輸出雜湊;commit 沒變而雜湊變了,就是一個警訊
budget: 3 min
on_fail: none # 偵測永遠不阻擋;它只負責餵隔離清單
- stage: quarantined # 觸發條件:每次 push;內容為一份明列的清單,每筆都帶有日期
run: [quarantine-suite]
budget: 10 min
on_fail: report_only # 結果照樣收集、照樣可見,但不拿它們關任何人
expire: 14 days # 超過日期的條目,自動讓這個階段失敗
- stage: unit # 觸發條件:每次 push
run: [unit-tests]
retry: 1 # 最多重跑一次,且只限基礎設施類的失敗,斷言失敗絕不重跑
on_retry_fail: block # 重跑一次之後,紅就是紅
- stage: quarantine-audit # 觸發條件:每晚
run: [list-expired-entries]
budget: 2 min
on_fail: block_release # 不該有任何筆還留在清單上超過日期:發佈前先修好、刪掉或改寫
確切的設定會依團隊的 CircleCI 配置而不同,但重要的是原則:快的檢查保護每一次變更;昂貴的 AI 評估以較慢的節奏提供更廣的證據。 一小份 AI 樣本可以及早抓住明顯的迴歸,而完整的黃金集與對抗性套件可以按排程或在發佈邊界上執行。這就在回饋速度、評估涵蓋範圍與模型成本之間,取得了一個務實的折衷。
| 階段 | 執行內容 | 時間預算 | 觸發條件 | 失敗時的動作 |
|---|---|---|---|---|
| 靜態 | Lint、型別檢查 | 2 分鐘內 | 每次 push | 阻擋 |
| 單元 | 單元測試、突變抽樣 | 6 分鐘內 | 每次 push | 阻擋 |
| 契約與整合 | 契約測試、整合煙霧測試 | 15 分鐘內 | 每個 pull request | 阻擋 |
| 評估 | 黃金集抽樣、對抗性案例 | 40 分鐘內 | 每晚或標籤 | 阻擋發佈、通知負責人 |
| 成本與延遲 | 成本與延遲分佈檢查 | 10 分鐘內 | 每晚 | 通知負責人 |
| 隔離 | 疑似 flaky 的案例,僅收集結果 | 不計入預算 | 每次 push | 不阻擋;到期時審查 |
這張表把分層策略變得具體:為每個驗證階段指定時間預算、觸發條件,以及失敗時的動作。靜態檢查與單元測試跑在每次 push 上,失敗就阻擋——這很合理,因為它們相對快、相對便宜。契約與整合檢查跑在每個 pull request 上,而較廣的評估層每晚執行或在明確觸發時執行,並且可以擋下發佈。成本與延遲檢查同樣每晚執行,但只通知負責人而不是自動阻擋,說明了並非每一項重要的量測都必須是硬關卡。最後,疑似 flaky 的案例被放進隔離:它們繼續收集結果,但在到期審查之前,不計入阻擋用的預算。
這張表也說明了為什麼觸發條件與失敗動作必須寫在同一份規格裡。「跑 AI 評估」這句話是不完整的,除非團隊知道它什麼時候跑、失敗時會發生什麼。同樣地,「隔離 flaky 測試」也是不完整的,除非測試持續回報、而且有一個明確的時間點,讓人必須決定它的未來。這樣做出來的 pipeline,每一項檢查都有它的目的,而不是一堆剛好被執行到的作業。
這些時間預算是演練目標,不是實測結果;真實數字會隨環境、工作負載、依賴與模型版本而變動。分層也犧牲了一些即時性:只在夜裡跑的評估,代表問題可能到隔天才浮現。隔離也類似:它在降低阻擋噪音的同時,暫時削弱了那個測試提供的保護。因此目標不是把每項檢查推得越早、越頻繁越好,而是把每一項放在它的訊號值得付出成本與延遲的位置。快的檢查保護開發者的下一步,慢的檢查保護發佈本身,而 flaky 測試永遠不該被容許悄悄重新定義「綠燈」的意義。