快轉到主要內容
  1. 文章/

LLM 開發下,TDD 好像開始出現新的問題

·4 分鐘

最近在使用 LLM 做開發時,我開始重新思考一件以前覺得已經很理所當然的事情:TDD 到底應該放在什麼位置?

我原本使用的流程其實滿直覺的。

我們有一份使用者提供的 SRS,先把需求拆成比較細的 Specification,接著使用 TDD 開發:

原本的開發流程:SRS → Spec → TDD → Coding → Verify

這個流程在一開始其實非常好用。

尤其現在有 LLM 幫忙寫 Test,TDD 的成本比以前低很多。Specification 拆出來之後,可以很快建立 Test,再讓 Agent 根據 Test 實作。

問題是,最近實際做了一些需求變動比較大的系統後,我開始發現一件奇怪的事情。

每次需求修改之後,Code 好像只會越來越多。

一開始我以為是錯覺。直到回頭翻了幾次 change 的 diff,才發現新增的行數遠多於刪除,而且很多舊邏輯只是被條件包起來,並沒有真的離開系統。


需求其實不是一直增加 #

原本我直覺上會把需求修改理解成:

1
Spec v1 + New Requirement = Spec v2

所以 Code 自然就是:

1
Code v1 + New Code = Code v2

但實際上的需求根本不是這樣。

使用者可能會說:

這個流程改一下。

接著又說:

前面的規則其實不是這樣。

過幾天又發現:

這個情況我們之前沒考慮到。

甚至在實作過程中,我們自己也可能發現原本的需求存在一些不確定性。

所以最後比較像一個一直繞回去的圈:

需求重定義循環:Spec v1 → 實作 → 發現新的問題 → 需求修正 → 重新理解,然後再繞回實作

這並不是單純「增加需求」。

比較像是:

我們一直在重新定義系統到底應該是什麼。

這個差異其實滿大的。加法不會讓任何舊東西失效,重新定義會。但我手上的流程,好像預設了前者。


TDD 在這裡開始出現一個問題 #

假設第一版需求是:

訂單完成後要發 Email。

於是我們寫了:

1
Complete Order → Send Email

後來需求改成:

不要 Email,改成 Notification。

正常來說,應該是:

1
原本的行為被替換

但在實際使用 Agent 的情況下,很容易變成:

1
原本的 Code → 加一段新的 Code → 修改幾個地方 → 補 Test

最後舊的東西不一定真的消失。

可能只是被包在某個條件裡。

甚至 Test 也會留下來。

回頭想,這件事其實不難理解。Agent 收到的輸入是「這次的變更要求」,而滿足這個要求最便宜的做法,通常是加東西。刪除需要先確定沒有其他地方依賴它,Agent 沒有這個信心,所以傾向保守地留著。這不是 Agent 的錯,是我的流程從來沒有要求它去問「舊的行為還該不該存在」。

於是幾次修改之後,Code 變成某種「需求變更的歷史紀錄」。

這時候我開始覺得:

我們現在的流程是不是太偏向 Change → Patch?


Test 也有一樣的問題 #

一開始使用 TDD,我會覺得 Test 是很好的安全網。

但 LLM 讓一件事情變得非常容易:

產生 Test。

你只要叫它:

幫我補 edge cases。

它可以很快列出一大堆:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
null
empty
zero
negative
VIP
non-VIP
coupon
expired coupon
VIP + coupon
VIP + expired coupon
...

而且每個 Test 看起來都很合理。

這反而讓我開始懷疑:

Test 越多真的越好嗎?

讓我起疑的不是數量本身,而是我發現自己說不出這些 Test 之間的關係:哪些是同一條規則的重複驗證?哪些在保護一個其實已經不存在的需求?需求一改,一片 Test 紅掉的時候,我分不出哪些紅是「行為真的錯了」,哪些只是「Test 過期了」。


Code Coverage 也不一定能回答這個問題 #

假設現在有 100 個 Test,Code Coverage 100%。

我們很容易覺得:

這個系統應該很安全。

但 Coverage 計算的是「哪些行被執行過」,不是「哪些規則被斷言過」。一行 Code 被跑過,不代表有任何 assert 盯著它的行為;反過來,一條重要的規則,可能需要好幾個不同角度的 Test 才算真的被證明。

所以 Coverage 只能證明很多 Code 被執行過。

它不一定能告訴我們:

Specification 裡重要的規則是不是都有被驗證?

甚至可能有很多 Test 其實是在測同一件事情。

所以我開始把這兩件事情分開看:

1
Code Coverage ≠ Specification Coverage

這兩個東西,可能從來就不是同一件事情。

後來我才知道,這個區分早就被研究透了。Model-based testing 領域在 2006 年就有 requirements coverage 的正式定義(Whalen et al., ISSTA 2006);航空軟體的 DO-178C 更是整套制度都建立在 requirements-based testing 上——測試必須源自需求,程式碼覆蓋率只是用來檢查「需求測試有沒有漏掉程式結構」的輔助指標,從來不是主角。

所以這不是新發現。比較像是我在 LLM 開發的脈絡下,重新撞見了一個航空業用了二十年的常識。


後來我覺得更麻煩的是 Test Drift #

這比 Test 太多更值得注意。

因為需求一直修改之後,Test 很可能開始跟著歷史一路累積。

最開始很單純:

1
Spec → Test

幾次修改之後:

Test Drift:Spec 從 v1 演化到 v3,但 Test v1、v2、v3 全部累積在同一個 Test Suite 裡

我猜這件事會發生,是因為需求修改的時候,沒有任何一個步驟負責宣告「哪些舊 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 裡,哪些事情是我們必須證明的。

例如:

1
2
3
Specification:

使用者每天只能領一次紅包。

Verification Model 可能描述:

1
2
3
4
- 第一次領取必須成功
- 同一天第二次必須失敗
- 隔天可以再次領取
- 一個使用者每天的領取次數不能超過 1

這些東西才是真正的「驗證責任」。

至於最後要怎麼驗證:

1
Unit Test / Integration Test / BDD / Property-based Test

反而是下一層的事情。

寫到這裡我特地去查了一下,發現這個概念其實一點都不新。形式方法把「必須被證明的條件」叫做 proof obligation,model-based testing 有幾乎同構的 test obligation。更尷尬的是,「verification model」這個詞本身在 SPIN/Promela 的形式驗證語境裡另有定義——指為了驗證而建立的系統抽象模型,跟我這裡講的是兩回事。

所以這一層不是我發明的,它在別的領域早就有名字。我先繼續沿用 Verification Model 這個叫法,但如果你有形式方法背景,請把它讀成「驗證義務的清單」,不要讀成 Promela 那種模型。真正讓我覺得還有空間的,是在 LLM 輔助的日常開發裡,把這一層當成顯式維護的 artifact——這件事我還沒看到誰在做。


這跟 BDD 有點像,但我覺得又不完全一樣 #

BDD 的 Scenario 很適合人閱讀。

例如:

1
2
3
Given user has not claimed today
When user claims a red packet
Then claim succeeds

這很好。

但 Scenario 本質上是「列舉具體情境」,而列舉會隨著條件組合爆炸。如果我們一直把所有情況都變成 Scenario,最後可能又得到另一個巨大的 Test Suite——只是換了一種格式。(用具體實例直接當可執行規格這條路,其實就是 Gojko Adzic 的 Specification by Example。)

Verification Model 想描述的東西不太一樣。它比較像「驗證義務」:這條規則成立,需要證明哪幾件事。義務的數量跟著規則走,不跟著情境組合走。

所以我現在比較傾向這樣的分層:

三層結構:Specification 描述系統應該是什麼,Verification Model 描述必須證明什麼,Unit Test、Integration Test、BDD、Property-based Test 則是怎麼證明的實作層

Verification Model 是比較穩定的東西。

Test 則是它的實作。

這樣未來即使 Test 改掉,只要 Verification Model 還在,我們至少知道:

這個 Test 原本是為了證明什麼。


但後來我發現,光有 Verification Model 還是不夠 #

因為我真正遇到的問題其實是:

需求一直改。

所以即使我們有:

1
Spec → Verification Model → Test

如果每次 Change 還是:

1
Change → 找 Code → Patch → 補 Test

那最後還是會累積很多歷史殘留。

所以真正缺少的可能是另一個東西:

Reconciliation。


Reconciliation #

這個概念我覺得可以拿 Kubernetes 來理解。

Kubernetes 並不是一直告訴系統:

把這個 Pod 改一下。

而是一直維持:

1
Current State ── Reconcile ──> Desired State

我覺得 LLM 開發也可能需要類似的思維。

需求改變之後,不是直接問:

「這次要改哪段 Code?」

而是先問:

「修改完之後,系統現在應該長什麼樣子?」

然後再比較:

Reconciliation:比較 Current State 與 Desired State,Reconcile 之後得到 Delta,Delta 裡包含 Add、Modify、Refactor、Remove

這個 Delta 才是接下來真正要做的事情。

而 Delta 裡面不只有 Add,也應該包含 ModifyRefactorRemove

這件事情其實正好解釋了我最近遇到的問題。

因為現在的流程很容易一直 Add。

但真正健康的系統演化應該也會有:

1
Code ↓   Test ↓   Complexity ↓

查資料的時候發現,這個類比也不是只有我想到。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 理解成:

1
Specification → Implementation

再搭配 TDD:

1
Specification → Test → Implementation

但現在我會比較傾向:

1
Specification → Verification → Design → Implementation

然後當需求改變:

1
Change → Reconciliation → Desired State → Delta → Implementation

TDD 還是在。

只是我開始覺得:

TDD 比較像是一個 Development Loop,而不是完整的需求演化機制。

它很適合幫我們把一個已經知道的行為實作出來。

但它不負責回答:

「需求改了之後,原本那些行為還應不應該存在?」


這也讓我重新看待 Change-driven Development #

我現在手上的一個 Plugin,本來就是以 Change 作為主要流程入口。

原本比較像:

1
Change → Spec → TDD → Coding → Verify

現在我開始覺得,它可能需要慢慢往:

1
Change → Specification Reconciliation → Verification Reconciliation → Design Reconciliation → Implementation → Verification

演進。

這一段目前純粹是方向,我還沒有真的改下去。

重點不是增加更多流程。

反而是希望 Agent 在面對 Change 時,不要第一時間就去找 Code Patch。

而是先重新理解:

現在這個系統到底應該是什麼。


我現在最在意的一個現象 #

我甚至開始覺得,可以用一個很簡單的東西來觀察這套流程到底健康不健康:

Change Residue。

也就是:

一次需求變更之後,還留下多少其實已經不再需要的 Code、Test 或 Design?

如果每次修改之後都是:

1
Code +   Test +   Condition +   Special Case +   Compatibility Logic +

卻很少看到:

1
Code -   Test -   Condition -

那可能代表我們不是在演化系統。

而是在系統上不斷疊加歷史。

「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 時代的開發流程,往這個方向移:

流程的轉向:以前是 Change → Patch,歷史不斷累積;現在是 Change → Understand → Reconcile → Determine Delta → Implement

也許這才是接下來 Specification Engineering 值得繼續往下研究的地方。


參考資料 #