LLM 開發下,TDD 好像開始出現新的問題
目錄
最近在使用 LLM 做開發時,我開始重新思考一件以前覺得已經很理所當然的事情:TDD 到底應該放在什麼位置?
我原本使用的流程其實滿直覺的。
我們有一份使用者提供的 SRS,先把需求拆成比較細的 Specification,接著使用 TDD 開發:
這個流程在一開始其實非常好用。
尤其現在有 LLM 幫忙寫 Test,TDD 的成本比以前低很多。Specification 拆出來之後,可以很快建立 Test,再讓 Agent 根據 Test 實作。
問題是,最近實際做了一些需求變動比較大的系統後,我開始發現一件奇怪的事情。
每次需求修改之後,Code 好像只會越來越多。
一開始我以為是錯覺。直到回頭翻了幾次 change 的 diff,才發現新增的行數遠多於刪除,而且很多舊邏輯只是被條件包起來,並沒有真的離開系統。
需求其實不是一直增加 #
原本我直覺上會把需求修改理解成:
| |
所以 Code 自然就是:
| |
但實際上的需求根本不是這樣。
使用者可能會說:
這個流程改一下。
接著又說:
前面的規則其實不是這樣。
過幾天又發現:
這個情況我們之前沒考慮到。
甚至在實作過程中,我們自己也可能發現原本的需求存在一些不確定性。
所以最後比較像一個一直繞回去的圈:
這並不是單純「增加需求」。
比較像是:
我們一直在重新定義系統到底應該是什麼。
這個差異其實滿大的。加法不會讓任何舊東西失效,重新定義會。但我手上的流程,好像預設了前者。
TDD 在這裡開始出現一個問題 #
假設第一版需求是:
訂單完成後要發 Email。
於是我們寫了:
| |
後來需求改成:
不要 Email,改成 Notification。
正常來說,應該是:
| |
但在實際使用 Agent 的情況下,很容易變成:
| |
最後舊的東西不一定真的消失。
可能只是被包在某個條件裡。
甚至 Test 也會留下來。
回頭想,這件事其實不難理解。Agent 收到的輸入是「這次的變更要求」,而滿足這個要求最便宜的做法,通常是加東西。刪除需要先確定沒有其他地方依賴它,Agent 沒有這個信心,所以傾向保守地留著。這不是 Agent 的錯,是我的流程從來沒有要求它去問「舊的行為還該不該存在」。
於是幾次修改之後,Code 變成某種「需求變更的歷史紀錄」。
這時候我開始覺得:
我們現在的流程是不是太偏向 Change → Patch?
Test 也有一樣的問題 #
一開始使用 TDD,我會覺得 Test 是很好的安全網。
但 LLM 讓一件事情變得非常容易:
產生 Test。
你只要叫它:
幫我補 edge cases。
它可以很快列出一大堆:
| |
而且每個 Test 看起來都很合理。
這反而讓我開始懷疑:
Test 越多真的越好嗎?
讓我起疑的不是數量本身,而是我發現自己說不出這些 Test 之間的關係:哪些是同一條規則的重複驗證?哪些在保護一個其實已經不存在的需求?需求一改,一片 Test 紅掉的時候,我分不出哪些紅是「行為真的錯了」,哪些只是「Test 過期了」。
Code Coverage 也不一定能回答這個問題 #
假設現在有 100 個 Test,Code Coverage 100%。
我們很容易覺得:
這個系統應該很安全。
但 Coverage 計算的是「哪些行被執行過」,不是「哪些規則被斷言過」。一行 Code 被跑過,不代表有任何 assert 盯著它的行為;反過來,一條重要的規則,可能需要好幾個不同角度的 Test 才算真的被證明。
所以 Coverage 只能證明很多 Code 被執行過。
它不一定能告訴我們:
Specification 裡重要的規則是不是都有被驗證?
甚至可能有很多 Test 其實是在測同一件事情。
所以我開始把這兩件事情分開看:
| |
這兩個東西,可能從來就不是同一件事情。
後來我才知道,這個區分早就被研究透了。Model-based testing 領域在 2006 年就有 requirements coverage 的正式定義(Whalen et al., ISSTA 2006);航空軟體的 DO-178C 更是整套制度都建立在 requirements-based testing 上——測試必須源自需求,程式碼覆蓋率只是用來檢查「需求測試有沒有漏掉程式結構」的輔助指標,從來不是主角。
所以這不是新發現。比較像是我在 LLM 開發的脈絡下,重新撞見了一個航空業用了二十年的常識。
後來我覺得更麻煩的是 Test Drift #
這比 Test 太多更值得注意。
因為需求一直修改之後,Test 很可能開始跟著歷史一路累積。
最開始很單純:
| |
幾次修改之後:
我猜這件事會發生,是因為需求修改的時候,沒有任何一個步驟負責宣告「哪些舊 Test 該作廢」。而當我要求 Agent「把測試修到全綠」時,它會選最小的修改:調整 assert、放寬條件、或乾脆留著不動。每一步都很合理,但累積起來,Test 就從「規格的證明」慢慢變成「現狀的快照」。
問題是到了最後,我們看到這些 Test 時,真的還知道:
這個 Test 是在保護哪一條現在還有效的規則嗎?
如果不知道,那這個 Test 其實就開始偏移了。
它測試的可能不是:
「現在系統應該是什麼」
而是:
「系統以前曾經是什麼」。
「Test Drift」這個詞是討論時隨口冒出來的。我查了一下,目前似乎還沒有人把它當正式術語用,那就先這樣叫。不過現象本身早有研究:學術上最接近的叫 obsolete tests——ECOOP 2013 有個研究發現,大約 20% 的回歸測試失敗,其實是「測試過時了」,不是程式有 bug。看到這個數字我反而鬆了一口氣:這不是我的流程特別爛,是這件事本來就一直在發生,只是 LLM 把累積的速度加快了。
這讓我想到 Specification 和 Test 中間可能缺了一層 #
我一開始想到的解法比較直接:那就把 Specification 寫得更詳細、更接近 Test 一點。但試著想下去就發現不太對——Specification 是給人讀的,硬寫成測試的形狀會失去表達力;直接跳到 Test,又會把「要證明什麼」跟「怎麼證明」綁死在一起。需求修改的時候,真正該先更新的是前者,但前者在我的流程裡沒有自己的位置。
所以這幾天和 AI 來回討論,冒出一個我覺得滿有意思的概念:
Verification Model。
我暫時還不確定這個名字是不是最適合。
它不是 Test Case。
它比較像是在描述:
Specification 裡,哪些事情是我們必須證明的。
例如:
| |
Verification Model 可能描述:
| |
這些東西才是真正的「驗證責任」。
至於最後要怎麼驗證:
| |
反而是下一層的事情。
寫到這裡我特地去查了一下,發現這個概念其實一點都不新。形式方法把「必須被證明的條件」叫做 proof obligation,model-based testing 有幾乎同構的 test obligation。更尷尬的是,「verification model」這個詞本身在 SPIN/Promela 的形式驗證語境裡另有定義——指為了驗證而建立的系統抽象模型,跟我這裡講的是兩回事。
所以這一層不是我發明的,它在別的領域早就有名字。我先繼續沿用 Verification Model 這個叫法,但如果你有形式方法背景,請把它讀成「驗證義務的清單」,不要讀成 Promela 那種模型。真正讓我覺得還有空間的,是在 LLM 輔助的日常開發裡,把這一層當成顯式維護的 artifact——這件事我還沒看到誰在做。
這跟 BDD 有點像,但我覺得又不完全一樣 #
BDD 的 Scenario 很適合人閱讀。
例如:
| |
這很好。
但 Scenario 本質上是「列舉具體情境」,而列舉會隨著條件組合爆炸。如果我們一直把所有情況都變成 Scenario,最後可能又得到另一個巨大的 Test Suite——只是換了一種格式。(用具體實例直接當可執行規格這條路,其實就是 Gojko Adzic 的 Specification by Example。)
Verification Model 想描述的東西不太一樣。它比較像「驗證義務」:這條規則成立,需要證明哪幾件事。義務的數量跟著規則走,不跟著情境組合走。
所以我現在比較傾向這樣的分層:
Verification Model 是比較穩定的東西。
Test 則是它的實作。
這樣未來即使 Test 改掉,只要 Verification Model 還在,我們至少知道:
這個 Test 原本是為了證明什麼。
但後來我發現,光有 Verification Model 還是不夠 #
因為我真正遇到的問題其實是:
需求一直改。
所以即使我們有:
| |
如果每次 Change 還是:
| |
那最後還是會累積很多歷史殘留。
所以真正缺少的可能是另一個東西:
Reconciliation。
Reconciliation #
這個概念我覺得可以拿 Kubernetes 來理解。
Kubernetes 並不是一直告訴系統:
把這個 Pod 改一下。
而是一直維持:
| |
我覺得 LLM 開發也可能需要類似的思維。
需求改變之後,不是直接問:
「這次要改哪段 Code?」
而是先問:
「修改完之後,系統現在應該長什麼樣子?」
然後再比較:
這個 Delta 才是接下來真正要做的事情。
而 Delta 裡面不只有 Add,也應該包含 Modify、Refactor、Remove。
這件事情其實正好解釋了我最近遇到的問題。
因為現在的流程很容易一直 Add。
但真正健康的系統演化應該也會有:
| |
查資料的時候發現,這個類比也不是只有我想到。GitHub Spec Kit 已經有一個 /speckit.converge 指令:反覆評估 codebase 與 spec 的差距,把還沒做的補成新的 task,直到回報 Converged。Tessl 走得更極端,直接把 spec 當唯一的 source,code 是生成物。
但仔細看會發現,這些 converge 做的都是正向 delta——把 spec 有、code 沒有的補上。我在意的是反過來的那一半:需求變更之後,把 code 有、但 spec 已經沒有的清掉。這一半,目前我還沒看到哪個工具認真做。
不過老實說,這個類比目前對我來說還有一個很大的問號:Kubernetes 做得到 Reconcile,是因為 Desired State 是機器可讀的宣告式設定。軟體需求沒有這麼乾淨。每次 Change 都重新推導一次「系統現在應該是什麼」,成本到底有多高,我還不知道。
所以我現在開始重新看 SDD #
先說清楚:SDD(Spec-Driven Development)不是我要定義的東西,它在 2025 年已經從模糊的想法變成明確的生態——GitHub 出了 Spec Kit、AWS 出了 Kiro,Thoughtworks 的 Birgitta Böckeler 在 martinfowler.com 把它分成三級:spec-first、spec-anchored、spec-as-source。
我想講的是我自己理解的變化。以前我會把 SDD 理解成:
| |
再搭配 TDD:
| |
但現在我會比較傾向:
| |
然後當需求改變:
| |
TDD 還是在。
只是我開始覺得:
TDD 比較像是一個 Development Loop,而不是完整的需求演化機制。
它很適合幫我們把一個已經知道的行為實作出來。
但它不負責回答:
「需求改了之後,原本那些行為還應不應該存在?」
這也讓我重新看待 Change-driven Development #
我現在手上的一個 Plugin,本來就是以 Change 作為主要流程入口。
原本比較像:
| |
現在我開始覺得,它可能需要慢慢往:
| |
演進。
這一段目前純粹是方向,我還沒有真的改下去。
重點不是增加更多流程。
反而是希望 Agent 在面對 Change 時,不要第一時間就去找 Code Patch。
而是先重新理解:
現在這個系統到底應該是什麼。
我現在最在意的一個現象 #
我甚至開始覺得,可以用一個很簡單的東西來觀察這套流程到底健康不健康:
Change Residue。
也就是:
一次需求變更之後,還留下多少其實已經不再需要的 Code、Test 或 Design?
如果每次修改之後都是:
| |
卻很少看到:
| |
那可能代表我們不是在演化系統。
而是在系統上不斷疊加歷史。
「Change Residue」這個詞我也查過,沒有人用,就先當作我自己的命名。但這個主張本身,其實有一個 1980 年的原始版本:Lehman 的軟體演化第二律——系統的複雜度會隨著變更持續增加,除非有人投入工作去維持或降低它。dead code、Fowler 說的 cruft,講的也都是這堆殘留物的某個切面。四十多年前就有人把話說完了,LLM 只是讓「不投入清理工作」的後果來得更快。
這還不是一個能量化的指標。目前我只是在每次 change 之後多問自己一句:這次有沒有刪掉任何東西?如果連續好幾次答案都是「沒有」,我就會開始警覺。
最後 #
我現在還不敢說這就是一套完整的方法論。
老實說,這篇文章裡的概念,多數是我和 AI 來回討論時浮現的,不是我獨自想出來的。寫完之後我逐一查證,結果也誠實地標在各段落裡了:Specification Coverage 是航空業用了二十年的老概念,Verification Model 的本體叫 proof obligation,SDD 已經是一個完整的生態。真正查不到出處的,只剩 Test Drift 和 Change Residue 這兩個名字,以及一個觀察:
現有工具的收斂,都只做正向 delta,沒有人認真做負向 delta。
Spec Kit 的 converge 會把「還沒做的」補上,但不會把「不該留的」清掉。Böckeler 也直說,spec 的長期維護策略在多數工具裡是「left vague or totally open」。我遇到的 code 只增不減、test 越疊越厚,就是這個缺口在日常開發裡的樣子。而我遇到的問題是確實的——這一點查證不會改變。
但也因為這樣,我覺得有一個方向越來越清楚:
以前軟體開發的問題,很多時候是:
如何有效率地把需求變成 Code。
LLM 出現之後,這件事情反而變得非常便宜。
現在比較麻煩的可能變成:
需求一直改的時候,如何讓 Code 不斷接近「現在真正需要的狀態」,而不是把所有歷史修改全部留下來。
所以我現在會開始把 AI 時代的開發流程,往這個方向移:
也許這才是接下來 Specification Engineering 值得繼續往下研究的地方。
參考資料 #
- Whalen, Rajan, Heimdahl, Miller, Coverage Metrics for Requirements-Based Testing(ISSTA 2006)
- DO-178C 的 structural coverage analysis
- Is This a Bug or an Obsolete Test?(ECOOP 2013)
- Lehman, Programs, Life Cycles, and Laws of Software Evolution(Proc. IEEE, 1980)
- Gojko Adzic, Specification by Example(2011)
- Martin Fowler, Technical Debt / cruft
- Birgitta Böckeler, Exploring Gen AI: Spec-driven development tools(martinfowler.com, 2025)
- GitHub Spec Kit、Amazon Kiro、Tessl、OpenSpec
- SPIN/Promela 的 verification model、Dafny 與 proof obligation