"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 必須分三步走:

  1. 先立紅燈(Red):AI 動筆前,先寫好一組明確失敗的測試,定義需求邊界與驗收條件。
  2. 約束綠燈(Green):AI 的任務只限於「讓測試通過」,無權自行擴充概念或改動周邊代碼。
  3. 安全重構(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 寫出符合預期的代碼,前端還需要清晰定義系統概念與業務邊界的文檔。