紅燈在量什麼 — 測試訊號的三層失真:斷言、量測、環境
觸發場景:Flutter 專案的測試修復戰役尾聲,同一週內連續三次發現「紅燈數字不可信」——不是測試錯、是訊號本身失真:兩個被判 flaky 的測試翻案、worktree 環境的 88 個失敗查出來是環境問題、效能斷言在兩台環境上結論相反 疑問來源:目標「全套件失敗降至 0」看起來是明確的驗收條件,為什麼一路追下去發現這個數字先要能被信任? 整理目的:把「測試訊號可信度」拆成三層(斷言設計 / 量測工具 / 執行環境)、各記下失真機制與修法 本文邊界:素材是該專案 v0.38 的 main log(含對照實驗數據);三個 case 發生在同一週、彼此互相印證
第一層失真:斷言在量機器、不在量程式
expect(elapsed, lessThan(Duration(milliseconds: 10))) 這類絕對計時門檻做 pass-fail 斷言,在這個專案留下了自我矛盾的記錄:ErrorHandler < 500μs 在 main 環境 FAIL、worktree 環境 PASS;batchUpdate < 10ms 某次實測 40ms FAIL、後續兩個環境都 PASS。同一份程式碼、同一條斷言、結論隨機器負載擺盪。
log 的判語直接:它們量測機器負載而非程式正確性。這類斷言的破壞不只是自己不穩——它讓「全套件紅燈」失去診斷力:紅燈亮起時無法分辨是程式缺陷還是環境抖動,於是「降至 0」的目標先天無法驗證。效能要守,用專門的 benchmark 管道(相對比較、多次取樣、獨立於 pass-fail 之外);單元測試套件裡的計時斷言是把非決定性摻進決定性訊號裡。
第二層失真:量測工具讀錯了結果
flaky 判定的翻案暴露了更隱蔽的一層。兩個測試被判定 flaky、進豁免清單——後來在乾淨 baseline 上以 N=5 重新取樣,兩檔各 5/5 綠、全套件逐筆驗證 100% 成功:非 flaky。誤判有兩個成因、缺一不可:取樣只有 3 次(低於專案自訂的 N ≥ 5 門檻、三連勝的機率不足以排除運氣)、且判定當時 baseline 還有 4 項確定性紅燈在干擾——「間歇失敗」跟「被其他紅燈拖累」根本分不開。
追這個案子時還挖出量測工具自己的 bug:flutter test --reporter compact 的純文字輸出在高並行大套件下有嚴重的行覆寫交錯,拿檔名或測試描述去搜尋輸出會產生假陰性——執行中一度誤判某測試「全套件中完全未執行」。修法是改用 --file-reporter json:<path> 的結構化 testDone 事件逐筆比對。這層的教訓可以一般化:給人看的輸出格式不能當機器判讀的資料源,凡是要在輸出上做字串比對的自動化,都該走結構化 reporter。
第三層失真:環境讓整包結果作廢
最大的一筆失真來自環境。worktree(git 的隔離工作目錄)上跑全套件出現 88 個失敗、其中 87 個是 Compilation failed。第一個歸因假說是「高並行編譯器資源耗盡」——聽起來合理、而且把失敗歸為環境噪音。但另一個競爭假說沒被排除:worktree 是 fresh checkout,lib/l10n/generated/ 是 gitignored 的生成產物、新 checkout 裡不存在,缺了就連鎖編譯失敗。
兩個假說的含義相反(噪音 vs 系統性不可信),處置是不在未驗證的假說上直接修、先做單一變因的對照實驗:
| 環境 | 編譯失敗 | 明確指向 l10n |
|---|---|---|
| main(有 generated) | 0 | 0 |
| worktree(無 generated) | 86 | 85 |
結果決定性:所有在 worktree 環境跑的全套件結果、在補跑 gen-l10n 之前全部不可信。根治不是「記得先跑 gen-l10n」的 SOP、是把 lib/l10n/generated/ 移出 gitignore 納入版控(對齊該專案 .mocks.dart 的既有慣例)——fresh checkout 預設就有產物、不依賴任何人記得任何事。納入版控前的驗證方法也值得記:重跑 flutter gen-l10n 看有沒有 diff,零 diff 才證明納進去的不是 stale 產物;直接讀檔案內容分辨不出新舊。
這個 case 還有一筆流程教訓:更早的一張 ticket 其實觀察過「worktree 缺 generated l10n 導致連鎖編譯失敗」,但觀察停在那張 ticket 的執行紀錄裡、沒有升格成 SOP 或 error-pattern,於是下一個執行者重蹈覆轍還給出錯誤歸因。log 的原句:「單張 ticket 裡的環境觀察若未升格,下一個執行者不會讀到它。」
收束:訊號先於目標
三層排在一起,共同的結構是:「全套件失敗數」這個數字要能當目標用,先要三層都乾淨——斷言只量程式(決定性)、量測工具忠實轉錄(結構化)、環境具備完整前置(可重現)。任何一層失真,追「降至 0」就是在追一個會說謊的數字:修好的測試可能本來就沒壞(flaky 誤判)、沒修的失敗可能不是程式的錯(環境缺檔)、紅綠的翻轉可能只是機器忙不忙(計時斷言)。
這週的三個修正各對一層,最後全套件基準首次成立:4557 個測試、失敗 1、exit code 與失敗數一致——這個「1」才開始值得追。
相關閱讀
- 假綠的產品側形態:產品碼自己是 mock——那篇是綠燈說謊、本文是紅燈說謊,合起來是「測試訊號可信度」的兩面
- 環境觀察要升格的原則層:#221 檢查規則的作用域要顯式列舉——「觀察存在」與「觀察被制度化」是兩個獨立的 fact,同構於「規則存在 vs 規則涵蓋」
- 兩次門檻的取樣版:#42 2 次門檻:第一次是運氣、第二次是訊號——flaky 判定的 N ≥ 5 是同一個統計直覺在「連續成功」方向的應用:三連勝不足以證明穩定
#flutter #dart #testing #flaky #ci #l10n #worktree #debugging