"To me, legacy code is simply code without tests."
—— Michael Feathers, Working Effectively with Legacy Code (2004)
一、被遺忘的工程規矩
互聯網追求快速交付的這些年,測試長期處於邊緣位置。
「先上線再迭代」與「測試拖慢進度」成了主流話語,出了問題就指望「靠線上監控兜底」。TDD、BDD 或嚴格的邊界校驗,常被當成學院派的繁複教條。大家習慣了快速堆砌功能,出了問題再打補丁。
AI 編程工具普及後,這套靠人肉修補的做法很快失效了。
以前代碼靠人手寫,產能有限,錯誤也是慢慢積累的。現在 Agent 幾分鐘就能生成上千行代碼,依賴關係和隱蔽缺陷迅速擴散,人工 Review 和事後補救根本追不上。
過去嫌「繁瑣」的測試規範,成了維持系統穩定的基礎設施。
二、TDD:目標函數與負反饋閉環
很多人把 TDD(測試驅動開發)當成個人習慣,甚至覺得多此一舉。
但在 AI 協作中,TDD 的作用是建立負反饋閉環(Negative Feedback Loop)。
直接把模糊需求丟給 Agent,它往往會把業務邏輯和測試一起生成。這種測試多半只是「自我驗證」:AI 用自己理解偏差的邏輯,去斷言自己寫出的錯誤代碼,表面全綠,實則全錯。
人機協同下的 TDD 必須分三步走:
- 先立紅燈(Red):AI 動筆前,先寫好一組明確失敗的測試,定義需求邊界與驗收條件。
- 約束綠燈(Green):AI 的任務只限於「讓測試通過」,無權自行擴充概念或改動周邊代碼。
- 安全重構(Refactor):測試持續通過,才能重構優化。
代碼實現可以交給機器,但驗收測試必須由工程師定義。沒有紅燈約束,AI 生成就會偏離方向。
三、BDD:驗證行為契約,而非實現細節
有了測試,另一個常見問題是「脆性測試(Brittle Tests)」。
許多單元測試寫得過於細碎,綁定了內部實現(例如大量 Mock 私有方法、檢查內部變量)。底層結構稍作調整,業務功能明明正常,幾十個測試卻跟着報錯,大幅推高了重構成本。
BDD(行為驅動開發)要求測試只斷言外部可觀察的業務行為,不綁定內部實現細節。
測試按領域契約組織:
- Given:給定業務上下文;
- When:觸發操作;
- Then:產出符合規格的結果。
需求規格(Spec)直接對齊測試用例後,測試就是可執行的規格說明。AI 面對的是端到端的行為契約,無法在實現細節上取巧過關。
四、測試抽象:隔離規格與實現
測試代碼本身的抽象程度同樣關鍵。
低質量的測試充斥着樣板代碼,往往要手動構造十幾個無關字段,到處硬編碼常量與重複配置。這類測試人難維護,AI 讀起來也容易迷失。
良好的測試抽象會提供乾淨的測試接口(Test DSL):
- 用構造器(Builder / Factory)封裝無關的默認狀態;
- 斷言只檢查業務結果(例如直接斷言資格是否符合,而非逐層檢查底層枚舉值)。
這把「業務意圖(What)」與「實現細節(How)」隔離開來。底層存儲或算法如何調整,AI 可以自由處理;只要通過頂層測試校驗,核心行為就不會出錯。
五、Property-based Testing:驗證系統不變量
範例測試(Example-based Testing)只能驗證人想得到的常規路徑。
AI 很容易在範例測試上「過擬合」。給三個示例,它寫幾個 if-else 分支就能交差,遇到用例之外的邊界輸入立刻出錯。
基於屬性的測試(Property-based Testing)專門解決這個問題。
傳統測試斷言「輸入 A 得到輸出 B」,Property-based Testing 則定義系統的不變量(Invariants):
- 對稱性:數據序列化後再反序列化,必須與原值一致;
- 冪等性:重複執行同一操作,系統狀態不變;
- 單調性與守恆:數值計算在任何輸入下不得突破上下限,也不得為負。
定義好不變量後,測試框架會自動生成上萬組隨機與極端輸入,涵蓋極限浮點數、特殊字符、空集合與邊界時間戳,一旦失敗還會自動縮減出最小反例,逼迫 AI 補齊邊界處理與類型約束。
六、編譯器與 Lint:靜態檢查
進 CI 之前,還有成本最低的一關:編譯器。
測試只能覆蓋寫出來的路徑,編譯器則檢查每一行代碼與每一條分支,耗時僅幾毫秒。Agent 會順着反饋信號優化:對着測試,它設法讓測試變綠;對着人類,它寫出聽起來合理的解釋。唯獨編譯器只認語法與類型,不讀註釋,也不接受「經推導邏輯成立」的辯解。
條件允許時,強類型語言本身就是直接的約束:
- Rust 用所有權與生命週期在編譯期攔截記憶體與併發錯誤,用
Result與窮盡匹配強迫處理每個錯誤分支; - Go 把錯誤當成值顯式返回,
if err != nil讓忽略異常無處藏身,簡潔的語法也收窄了隨意發揮的空間; - TypeScript 的嚴格模式與可辨識聯合,則能直接從類型層面排除非法狀態。
類型簽名把規則寫進了編譯器,不會過期,也不會在下一輪重構中被悄悄改掉。
不過類型檢查只保證內部自洽,不保證業務正確。 編譯器能確保「結算金額是整數」,卻不知道該不該扣手續費。它能攔住低級錯誤,攔不住錯誤的規格。
至於無法推倒重來的遺留代碼,幾十萬行無類型的 JavaScript 或老舊服務不可能為了 AI 整體遷移到 Rust,這時只能靠 Lint 補位:
- 開啟
no-floating-promises,防止遺漏異步異常; - 禁用
any與類型斷言,堵住繞過類型檢查的後門; - 用
errcheck、staticcheck、golangci-lint攔截 Go 裏丟棄的error; - 用
clippy檢查常見的 Rust 反模式。
在老項目落地 Lint 不能一次開到最嚴,否則滿屏報錯只會讓人學會忽略。可行的做法是基線化:先將現有違規凍結為基線,只禁止新增。
同時 Lint 必須阻斷構建。如果只印黃色警告卻照常合併,Agent 就會無視警告。// eslint-disable 與 as any 也必須受控,否則 Agent 加一行註釋就能繞過檢查。
語言與 Lint 的作用一致:人記不住所有規矩,就交給工具強制執行。 能寫進類型的別寫進文檔,能編譯期攔下的別留到運行時。
七、CI:自動化門禁
測試寫得再好,只放在本地也容易被跳過。
本地測試報錯時,若無外部約束,Agent 常會修改測試斷言去遷就錯誤實作,或加上 .skip 繞過檢查,再在對話框裏向你保證「邏輯完全成立」。靠人工逐次核對提交,很快就會看漏。
CI(持續集成)在乾淨無狀態的容器中串聯起整套檢查:
- 靜態類型檢查(Typecheck);
- 代碼重複度與規範掃描(Lint / Dup scan);
- 隔離環境下的 BDD 與屬性測試。
CI 只看 Exit Code 0。任何邊界失敗都會直接阻斷合併。
八、小結
Michael Feathers 把沒有測試的代碼定義為遺留代碼。
在手寫代碼時代,測試與靜態檢查常被視為額外負擔。到了 AI 生成速度遠超人工審查能力的今天,TDD、BDD、屬性測試、類型系統與 CI 組成的自動化門禁,成了維持代碼庫可維護性的底線。
但質檢只是最後一關。要讓 AI 寫出符合預期的代碼,前端還需要清晰定義系統概念與業務邊界的文檔。