iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護系列 第 13

Day 13|測試全綠,為什麼仍漏掉「剛好 24 小時」?用整潔測試、驗收測試與受控 Mutation 檢查答案

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天談測試紀律時,我比較了直接完成、TDD 與 TCR。那些實驗回答的是:測試何時介入,會怎麼改變 Agent 的前進節奏與還原範圍。

今天遇到的問題更麻煩:測試介入得很早,不代表測試問對了問題。

在產品範例驅動驗收的三次實驗裡,我都先提供產品範例、Gherkin、固定時間,以及 SQLite 寫入再讀回的檢查。候選完成後,主流程重新執行驗證也全部通過。照理說,這套準備已經很完整。

接著我做了一個受控 Mutation,刻意把 Production Code 的 <= 改成 <,模擬「剛好二十四小時」不再升級通知的錯誤。

結果三次測試仍然全綠。

這次不是 Agent 沒有照規格做,而是我提供的 Oracle 本身出了問題。測試名稱明明寫著「剛好二十四小時要升級」,資料經過 SQLite 寫入再讀回後,卻早已不是等號。

這就像門禁測試報告寫著「午夜整禁止進入」,測試人員卻每次都在午夜過後一秒刷卡。不論門禁有沒有正確處理「剛好午夜」,報告都可能顯示通過。

AI Coding 會放大這項風險。測試可信時,我們可以讓 Agent 自主重構、替換演算法與調整內部結構;測試不可信時,後續 Agent 也可能一次又一次接受同一個錯誤答案,而且每次都拿到綠燈。

所以今天不比哪套測試框架比較熱門,而是要追查三件事:整潔的測試與驗收測試各自保護什麼、三種需求輸入方式會帶出哪些盲點,以及 User 要留下哪些檢查,才有資格把更大的修改範圍交給 Agent。

這篇會先建立判讀測試的共同語言,再進入實驗,不需要先記住所有名詞。閱讀順序可以先抓住這條因果線:

閱讀階段 要回答的問題 本次觀察
先分清楚測試責任 Test Code 好讀,是否等於產品答案正確? 整潔測試與驗收測試是兩個不同判斷軸
再比較三種需求輸入 Agent 從既有實作、行為 DSL 或產品範例取得答案,差異在哪裡? 三種方式都能交付,成本與盲點不同
接著故意改錯 Production Code 全綠測試是否真的能辨識錯誤? 受控 Mutation 讓隱藏盲點現形
最後追查「剛好 24 小時」 為什麼 <<= 都能通過? SQLite 儲存精度讓測試資料失去原本的等號

換句話說,前半段介紹的是判讀工具,後半段才用同一個時間邊界驗證這些工具是否可信。

測試何時介入,和測試答案是否可信,是兩件不同的事

昨天的直接完成、TDD 與 TCR,主要控制 Agent 的前進節奏;今天則檢查那套測試值不值得信任。

TDD 可以先取得 Red,但 Red 仍可能來自錯誤資料。TCR 可以每一小步都維持 Green,但 Green 也可能只是在反覆驗證錯誤期待值。

測試紀律決定「何時檢查」;整潔測試與驗收測試則要回答「檢查內容是否清楚,而且真的對準產品答案」。兩者缺一不可,也不能互相代替。

整潔測試關心可讀性,驗收測試關心產品是否完成

整潔的測試(Clean Tests)檢查 Test Code 是否容易閱讀、維護與信任;驗收測試(Acceptance Tests)則確認產品是否真的符合需求。同一項測試可以同時落在這兩個判斷軸上。

判斷軸 主要問題 審查重點
整潔的測試 這份測試是否容易理解、執行、維護與判讀? 命名、資料、結構、隔離、重跑穩定性與失敗訊息
驗收測試 使用者或產品要求的情境是否真的完成? 業務範例、公開契約、可觀察結果與完成條件

Unit Test、Integration Test 或 E2E 都可能寫得乾淨,也都可能亂到只有原作者看得懂;驗收測試也可能是清楚的活規格,或是一份把錯誤需求寫得很漂亮的程式碼。

Test Code 好讀,不代表產品答案正確;案例符合需求,也不代表測試本身容易維護。真正值得長期交給人類與 Agent 反覆執行的,是兩邊都站得住腳的測試:規則容易理解,答案漂移時也能可靠地變紅。

Test Code 可以接受適度重複,但仍要讓行為容易閱讀與維護

Clean Code 對命名、編排、抽象與單一責任的要求同樣適用於 Test Code;差別在於測試首先要讓案例與預期結果容易被看懂。

為了讓一個案例完整呈現,Test Code 可以保留比 Production Code 更多的資料準備與重複。不過,測試名稱、Arrange、Act、Assert、Fixture 與失敗訊息仍要清楚。讀者不必追過五個 Helper,便能回答:

  1. 這個情境在保護哪一條規則?
  2. 哪些輸入決定結果?
  3. 系統執行了什麼主要動作?
  4. 哪個可觀察結果代表成功或失敗?

到了 AI Coding,Test Code 還多了一位讀者。Agent 會從測試名稱、資料、Assertion 與既有慣例,推斷哪些行為不能改。測試越模糊,它就越容易把技術細節誤認成產品規則,或在補測試時複製現有 Bug。

3A 讓情境、動作與結果照閱讀順序出現

測試常見的 3A 原則,是 Arrange、Act、Assert 的縮寫:

  • Arrange:準備輸入、初始狀態、依賴與環境。
  • Act:執行這個 Test 真正要觀察的主要行為。
  • Assert:檢查外部可觀察結果是否符合預期。

3A 的重點不是硬切成三個註解區塊,而是讓資料準備、主要動作與結果判斷一眼可辨。每個區塊可以有多行,也可以進一步包裝成 Given/When/Then;讀者仍要能沿著「先發生什麼、做了什麼、最後得到什麼」理解案例。

以「High 工作項目剛好逾期二十四小時要使用升級通知」為例,直接使用既有測試基礎設施可以寫成:

using var factory = new WorkItemsApiFactory();
using var client = factory.CreateClient();
var workItem = CreateWorkItem(
    "高優先序剛好逾期二十四小時",
    "Open",
    factory.UtcNow.AddHours(-24),
    "High");
await SeedAsync(factory, cancellationToken, workItem);

using var response = await client.PostAsync(
    "/api/work-items/process-overdue",
    null,
    cancellationToken);

Assert.Equal(
    [workItem.Id],
    factory.Notifications.EscalatedWorkItemIds);

WorkItemsApiFactory 建立測試主機與 Client,SeedAsync 寫入初始資料,這些內容都屬於 Arrange;PostAsync 是主要 Act,最後的 Assertion 則檢查通知路由。

這份測試沒有炫技,規則也看得出來。缺點是當類似案例增加,測試環境、初始資料與 HTTP 呼叫細節會一再蓋過真正的差異。

一個主要 Act,是為了讓失敗能對回明確行為

「一個主要 Act」不等於整個 Test 只能呼叫一個函式。

例如「同一批逾期工作重跑後,不得再次增加處理數量」本來就需要呼叫兩次 API,兩次呼叫共同構成同一個重跑情境。相反地,若一個 Test 同時新增、改期、完成工作項目,再執行逾期流程與驗證數個無關結果,其中一個 Assertion 失敗時,人類與 Agent 都很難定位原因。

測試名稱也要直接說出情境與結果:

ProcessOverdue_HighPriorityExactly24HoursOverdue_UsesEscalationNotification

這比 ProcessOverdue_Test1ShouldWork 更有用。下一個 Agent 搜尋 Exactly24HoursEscalationNotification 時,也能直接找到要保護的規則。

用 F.I.R.S.T. 的五項特性檢查測試是否適合長期執行

F.I.R.S.T. 用五個特性檢查自動化測試是否可靠:

原則 白話說明 對 AI Coding 的影響
Fast 回饋速度足以支撐頻繁執行 Agent 每一小批修改後真的跑得動,不會因等待而跳過
Isolated 測試彼此不依賴、也不互相污染 單獨、整批或換順序執行,答案仍一致
Repeatable 相同條件能重複得到相同結果 Clock、Random、Culture、外部服務與資料都受控制
Self-Validating 測試自己判斷 Pass 或 Fail 不必人工打開 Log 或 Database 才知道結果
Timely 在功能開發與決策當下建立 Agent 動手前就知道哪些行為必須守住

本篇依照我手上的繁中第二版,將 I 寫成 Isolated(獨立)。部分第一版資料使用 Independent,核心都在提醒我們控制測試之間的耦合,但本文不混用兩個版本的縮寫。

xUnit 是本系列使用的 .NET 測試框架。它預設會為每個 Test Method 建立新的 Test Class Instance,但這只隔離該類別的 Instance State。

如果測試共用 Database、Static State、Filesystem、Clock、Queue 或 Fixture,仍可能互相影響。Fixture 是跨測試共用的建置內容或資源,User 必須另外設計資料重建、生命週期與平行執行邊界。xUnit 的 Sharing Context 官方文件也特別區分每個測試自己的內容,以及跨測試共用的 Fixture。

Flaky Test 是同一份 Code、同一組條件下,有時通過、有時失敗的不穩定測試。它對 Agent 特別危險:Agent 可能反覆重跑、錯誤歸因,甚至開始修改原本正確的 Production Code。速度很快但不穩定的測試,仍然不是好護欄。

驗收測試先固定產品答案,再把內部實作交給 Agent

驗收測試關心的是一項使用者或產品情境是否完成。以今天的 Work Item API 來說,一條驗收條件可以是:

High 工作項目剛好逾期二十四小時時,系統要傳送升級通知。

這句話沒有指定 Processor 必須使用 if、Strategy 或 Dictionary,也沒有指定 Private Method 名稱。只要公開 API、資料狀態、通知路由與失敗語意符合預期,Agent 仍能自行整理內部結構。

驗收測試固定的是外部可觀察答案,例如輸入、狀態、回應與副作用;User 約束的是系統不能換答案,不是逐行指定程式碼。

產品範例把「很久」與「應該」換成可執行答案

一句「高優先序逾期很久要升級」仍有太多空白。Agent 得猜「很久」是多少、等號算不算、Normal 要怎麼處理,以及通知失敗後是否還要儲存狀態。

比較好的方式,是先列出具體範例:

Priority 初始狀態 距離到期時間 通知結果 預期路由 ProcessedCount
High Open 剛好 24 小時 成功 升級通知 1
High Open 還差 1 ms 才滿 24 小時 成功 一般通知 1
Normal Open 超過 24 小時 成功 一般通知 1
High Overdue 超過 24 小時 成功 升級通知 0
High Open 超過 24 小時 失敗 升級通知,保留失敗摘要,狀態仍儲存 1

Gherkin Scenario 是以 Given/When/Then 寫成的一個具體業務例子:Given 交代初始情境,When 描述事件,Then 則說明外部可觀察結果。

Scenario: High 工作項目剛好逾期二十四小時
  Given 一筆 Priority 為 High、Status 為 Open 的工作項目
  And 它的 DueAtUtc 剛好早於目前時間二十四小時
  When 系統執行逾期處理
  Then 應傳送升級通知
  And 不應傳送一般逾期通知

Cucumber 官方也把 Scenario/Example 定位成 Business Rule 的具體例子,並建議 Then 優先觀察使用者或外部系統看得到的結果。Gherkin Reference有更完整的語法與使用方式。

不過,Gherkin 只是可閱讀的規格格式,不會因為寫了三行文字就自動成為測試。Step Definition(步驟定義)負責把每個步驟連到可執行程式;本系列沒有另外加入 Gherkin 執行套件,而是把重要範例直接轉成 dotnet test 會執行的 xUnit Test。

驗收測試應優先觀察公開結果,不要綁死內部結構

如果產品承諾的是「傳送升級通知」,驗收測試應觀察通知 Gateway 收到什麼;如果承諾的是 HTTP Response,就檢查 Status Code 與 Body。直接讀取 Private Field、指定某個 Helper 被呼叫幾次,通常是在固定實作,不是在驗收產品。

直接查 Database 並不是一律錯誤。當資料狀態本身就是契約,或目前尚未提供公開查詢入口時,它可以成為 Integration Test 的 Oracle;但要清楚標示這是技術整合檢查,不要把內部資料表當成使用者永遠不變的公開介面。

Unit、Integration、Acceptance、E2E 與 UAT 分別在回答什麼

這些名詞常被排成固定階梯,其實它們描述的維度不完全相同。

名詞 主要在描述什麼 今天的例子
Unit Test 小範圍行為能否快速、隔離地驗證 單獨驗證逾期門檻計算
Integration Test 多個元件或技術邊界能否正確合作 ASP.NET Core、EF Core 與 SQLite 一起執行
Acceptance Test 產品完成條件是否被滿足 High 剛好逾期 24 小時要走升級通知
E2E/端對端測試 從公開入口走過一條完整系統流程 從 HTTP Request 到可觀察的通知與資料結果
UAT/使用者驗收測試 真實或代表性使用者是否接受系統符合工作需求 由產品或使用者依實際流程驗收

Unit、Integration 與 E2E 比較偏向技術範圍;Acceptance 比較偏向需求目的。因此,一份 Test 可以同時是 Integration Test 與 Acceptance Test。

今天用 WebApplicationFactory 啟動 ASP.NET Core 測試主機,再由 HttpClient 經過 HTTP 請求處理流程、Service 與 SQLite。依 Microsoft 的 ASP.NET Core Integration Tests 文件,這屬於使用 TestServer 執行的整合測試,也就是測試程序內的 ASP.NET Core 測試伺服器。

請求雖然走過 HTTP,仍不等於部署環境的完整 E2E,也不能代替真正的 UAT。

測試名稱不是勳章。最重要的是把起點、終點、替身與未涵蓋範圍說清楚,別讓一個聽起來更完整的名稱製造錯誤安全感。

要讓 Agent 擴大自主範圍,測試至少要通過五道關卡

我目前會把 AI Coding 的測試控制整理成五道關卡:

  1. 產品答案:User 先確認範例、等號邊界、失敗路徑與不可改變的 Contract。
  2. 快速回饋:以 Unit 或小型 Integration Test 讓 Agent 在修改過程中快速得知對錯。
  3. 可執行驗收:重要產品範例必須進入自動化測試,不能只留在 Prompt 或 Gherkin 文件。
  4. 辨錯能力:對金額、時間、權限、狀態轉換與外部副作用,故意放入受控錯誤,確認測試真的會變紅。
  5. 外層驗證:最後再依風險執行完整 Integration、E2E、部署環境檢查與 UAT。

這五道關卡能縮小 Agent 自由修改時的風險,不能保證軟體零缺陷,也不能單靠測試取代設計判斷與人工責任。

AI 自主開發前,測試需要通過可讀性、Oracle、獨立環境、Mutation 與人工判斷五道關卡

圖:測試全綠只是入口;能否成為可信的行為約束,還要依序通過五道信任關卡。

以前太昂貴的 CRAP 與 Mutation,為什麼在 Agent 時代重新可行?

CRAP、Coverage 與 Mutation Testing 都不是 AI 時代才出現的新工具。Uncle Bob 回顧過去的經驗時提到,修完大量 CRAP Finding 需要很多人工時間,早期 Mutation Run 甚至可能執行一整晚。方法有價值,執行與修正成本卻讓團隊很難頻繁採用。

Agent 改變的是這筆成本。它能重複執行測試、閱讀失敗結果、修補候選並再次驗證,也不會因工作單調而省略步驟。原本太花時間的品質檢查,現在更有機會放進日常流程。

這不代表每個專案都該跑完整 Mutation。測試規模、工具速度、Equivalent Mutant 與 CI 預算仍會影響選擇。

更重要的是,這些工具回答的問題各不相同。CRAP 把方法複雜度與 Coverage 放在一起觀察改動風險;Coverage 告訴我哪些 Code 被執行過;Mutation 則故意改錯判斷,檢查測試是否真的會反對。它們可以形成 Agent 無法靠完成摘要跳過的 Gate,仍然不能替 User 決定產品 Oracle、架構是否合理,或尚未想到的風險。

實驗情境:測試要守住逾期處理的時間、通知與重跑規則

這次實驗要驗證一條時間邊界規則:工作項目的 Priority 為 High,而且至少逾期二十四小時,才改走升級通知;未滿二十四小時與 Normal 仍走一般通知。

Work Item API 會找出尚未完成且已到期的工作項目,將狀態改成 Overdue,再透過外部通知 Gateway 發送訊息。

除此之外,既有行為也不能漂移:通知失敗仍要保存 Overdue 狀態並回報失敗摘要;已是 Overdue 的項目重跑時不增加 ProcessedCount,但仍依目前規則選擇通知路由;Exception、Cancellation、HTTP Contract 與 SQLite Schema 都要保持原樣。

這個情境同時具備:

  • 很容易被忽略的等號邊界。
  • Database 寫入再讀回的時間精度。
  • 外部通知副作用與失敗語意。
  • 重跑時的可觀察行為。

一條看似簡單的二十四小時規則,會同時影響狀態判斷、外部通知、資料保存與 HTTP Contract,正好可以觀察不同測試策略究竟漏掉什麼。

Baseline 移除既有功能測試,但 Git History 仍可能洩漏答案

我從昨天的接受版本建立 Baseline,移除會直接揭露新規則的既有功能測試,Production Code 則保持不變。Git History 可能讓 Agent 找到前次實驗留下的實作,因此每次 Run 都從相同基準開始,並限制可用線索,降低直接沿用既有答案的可能性。

讀者可以直接切到相同起點:

git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git switch --detach day-13-clean-acceptance-tests-baseline
git rev-parse HEAD

預期 Commit:

9b8c94c79a18eea24645aaee268e81783d94886f

這項控制仍有一個限制:Implementation-coupled Run 02 主動閱讀 Git History,看見被移除的舊測試。因此我保留這次結果,但不拿它證明 Agent 完全只從 Production Code 推導需求。

Git 歷史本來就是 Repository Context;若實驗真的要隔離歷史,應另外建立沒有舊 Commit 的乾淨 Repository。

Preflight 先確認:這個專案使用毫秒精度保存時間

Preflight 是正式實驗前的小型預檢,用來確認環境、資料與驗證流程是否真的能測到預定變因。

我原本想用「距離二十四小時還差一個 Tick」測邊界;一個 .NET Tick 是 100 奈秒,但這個專案把時間以 Unix millisecond 存進 SQLite。少一個 Tick 的差異寫入後會消失,測試自然分不出來。

因此正式實驗改用少一毫秒,並要求關鍵時間先經過 Database Round-trip。這次的時間精度來自本專案目前的 Converter 與 Schema,不能推論成 SQLite 一律只能保存毫秒。測試資料必須配合 Repository 的實際儲存方式。

這段 Preflight 其實已經提醒我,Provider 會改變時間精度。後面的產品範例仍然選錯固定時間,也正是本篇最值得保留的失敗:知道風險存在,不代表 Oracle 就一定設計正確。

三種需求輸入方式,刻意讓 Agent 依賴不同資訊來源

全部實驗固定使用 Codex GPT-5.6-SOL-HIGH、相同 Baseline 與相同 Production 行為。每種方式各開三個全新 Session,共九次正式執行。

三組實驗沿用相同 Test Framework,差別落在 Prompt 提供的測試視角:綁定既有實作、先建立行為 DSL,或先用產品範例定義驗收條件。

實驗方式 Agent 收到什麼 想觀察什麼 主要風險
依現有實作補測試(Implementation-coupled) Production Code、既有測試慣例與完成條件 Agent 能否快速固定現況 可能把現有 Bug 一起寫成規格
行為 DSL(Behavior DSL) 明確行為、關鍵邊界與測試可讀性規則 業務語彙能否收起重複技術細節,又保留重要資料 DSL 可能膨脹或把條件藏太深
產品範例驅動驗收(Example-driven Acceptance) 產品範例表、Gherkin 與完成條件 Agent 能否在實作前依產品答案建立可執行驗收 User 提供的 Oracle 或測試資料若錯,Agent 會忠實執行錯誤規格

三份完整 Prompt 都保留在公開 Repository:

三份 Prompt 同時改變資訊來源、結構要求與固定時間,因此這次屬於三種實務交付策略的重複觀察,不能把結果歸因於某一句提示詞。九次執行也會受到模型非決定性、Cache 與探索路徑影響,結果不適合拿來做模型排名。

三種方式都能交付測試,但讀法、成本與盲點不同

每個 Agent 完成後,主流程都重新注入同一份獨立 Oracle,並執行相同的 Build、完整 Tests、Format、HTTP 基本流程與 Diff 檢查。接著再套用三個受控 Mutation,確認測試能否拒絕刻意放入的錯誤行為。

實驗方式 三次修改規模 三個受控錯誤 這次看見的優點 這次看見的限制
依現有實作補測試 新增 116~118 行 三次都是 3/3 被抓到 Diff 最小,能快速建立現況保護網 需求來源與實作來源重疊;其中一次還讀到 Git History
行為 DSL 新增 213~266 行 三次都是 3/3 被抓到 情境、動作與通知結果更接近業務閱讀順序 需要額外測試建構器與測試語彙,結構成本較高
產品範例驅動驗收 新增 289~309 行 三次都是 2/3 被抓到 實作前最容易跨角色確認需求 錯誤固定時間讓等號錯誤三次都未被發現

三種方式都通過主流程驗證,因此只看「最後全綠」,仍分不出辨錯能力。直到我刻意改錯 Production 判斷,三套測試真正保護到的行為才被攤開。至於最後採用哪一種策略,必須等盲點、維護成本與適用情境都比較完才能決定。

依現有實作補測試:適合固定已確認的舊行為,不能拿來證明產品需求正確

這三次的 Diff 最小,三個受控錯誤也都會讓測試失敗。對缺少測試的 Legacy Code 而言,這是很實用的 Characterization Test 起點:先把目前可觀察行為固定下來,再開始整理。

但它只能證明「測試能辨識這份實作目前的行為」,不能反過來證明產品本來就該這樣。若 Production Code 與 Agent 同時誤解需求,Agent 很可能把同一個誤解抄進 Tests。

這次沒有另外建立一份「Production 原本就錯,再讓 Agent 補測試」的對照組,因此不能宣稱它必然會複製 Bug;這裡只把資訊來源重疊列為風險。

行為 DSL 讓業務差異更集中,也帶來較大的測試結構成本

Test DSL(測試領域專用語言)是用一組貼近測試情境的 Method,把重複建置與呼叫細節收起來。這次 Agent 產生的測試讀起來像這樣:

using var scenario = await OverdueBehaviorScenario
    .ForWorkItem(
        title: "高優先序剛好逾期二十四小時",
        priority: "High",
        initialStatus: "Open")
    .OverdueFor(TimeSpan.FromHours(24))
    .NotificationReturns(succeeded: true)
    .StartAsync(cancellationToken);

var summary = await scenario.ProcessOverdueAsync(cancellationToken);

Assert.Equal(1, summary.NotificationAttemptCount);
Assert.Equal(
    [scenario.WorkItemId],
    scenario.EscalationNotificationWorkItemIds);
Assert.Empty(scenario.RegularOverdueNotificationWorkItemIds);

HighOpen、二十四小時、通知結果與預期路由仍留在案例附近,重複的測試環境建立、初始資料寫入與 HTTP 串接細節則由 Scenario 收起來。審查時需要追蹤的跳轉較少,三個受控錯誤也都讓測試變紅。

代價同樣清楚:三次候選新增 213~266 行,還出現超過一百行的測試建構器(Test Builder)。只有一兩個案例時,直接測試反而更容易讀;若關鍵資料被藏到多層 Method 或跨檔案追蹤,DSL 就已經抽象過頭。

產品範例驅動驗收讓需求先被討論,也可能忠實執行錯誤 Oracle

這一組先提供產品範例與 Gherkin,讓產品規則能在實作前被討論,Agent 也把重要情境轉成可執行測試。

問題不在做法,而在我提供的固定時間沒有對齊 Repository 的儲存精度。三個 Session 都使用同一份錯誤基準,所以三次都讓 <= 改成 < 的錯誤存活。

Prompt 越完整,Agent 越可能忠實落實 User 的假設;若假設本身錯了,完整規格只會讓錯誤變得更一致。這次責任不能推給模型,Oracle 是我給的。

用三個受控 Mutation 檢查測試是否抓得到高風險錯誤

Mutation Testing 會在 Production Code 產生小型變異,再觀察 Tests 能不能辨識行為改變。今天沒有把整套 Stryker.NET 加進 Repository,也沒有宣稱掃描所有可能變異;我只手動注入三個事前定義的受控 Mutation:

受控錯誤 Production 被改成什麼 測試應有反應
M1:時間等號 <= 改成 < 剛好逾期 24 小時的案例失敗
M2:通知失敗摘要 失敗結果被當成成功 失敗計數與 ID 案例失敗
M3:重跑通知路由 已是 Overdue 的 High 改走一般通知 重跑路由案例失敗

每次只暫時修改同一個 Production 檔案,執行 Tests 後立刻還原,再確認工作樹回到乾淨狀態。

這裡也順便說清楚常見術語:被植入的小型變異叫 Mutant;至少一個 Test 因它失敗,稱為 Killed Mutant;所有 Tests 仍通過,稱為 Survived Mutant。Mutation Score 是被偵測到的有效 Mutant 比例。

今天的 3/3 只代表三個事前挑選的錯誤都被抓到,不能換算成完整 Mutation Score 100%,也不能推論系統零缺陷。Survived Mutant 可能來自測試缺口或無效測試資料;如果程式寫法改變,可觀察行為完全等價,則屬於 Equivalent Mutant,同樣不能直接視為測試缺口。

結果仍要放回業務情境判斷。Stryker 的 Mutant States and MetricsEquivalent Mutants說明了這些狀態與限制。

「剛好 24 小時」其實沒有形成等號

產品範例驅動驗收三次漏掉 M1。Agent 有寫 Assertion,真正的錯誤是我替 Prompt 選錯固定時間。

階段 時間值 對比較結果的影響
Prompt 的目前時間 2026-09-01T08:00:00.0009999Z Processor 由此計算二十四小時門檻
Prompt 建立的 DueAtUtc 2026-08-31T08:00:00.0009999Z 寫入前看起來剛好相等
SQLite 讀回的 DueAtUtc 2026-08-31T08:00:00.0000000Z 小數毫秒被截去,已經更早
Processor 使用的 Threshold 2026-08-31T08:00:00.0009999Z DueAtUtc 已經小於 Threshold

因此,Production 就算從 dueAtUtc <= threshold 改成 dueAtUtc < threshold,結果仍然成立。Agent 另外用 ToUnixTimeMilliseconds() 比較寫入前後時間,也只證明兩者位於同一毫秒,沒有證明 DueAtUtc 等於 Processor 真正使用的 Threshold。

要測等號,固定時間應先對齊本專案的毫秒精度:

var fixedUtcNow = new DateTimeOffset(
    2026,
    9,
    1,
    8,
    0,
    0,
    TimeSpan.Zero);

var dueAtUtc = fixedUtcNow.AddHours(-24);

這樣兩個值經過 Database Round-trip 後仍能保持真正相等。

產品範例裡確實寫了等號邊界,測試名稱也寫得很精準;錯誤出在 Oracle 使用了不符合真實 Provider 精度的時間。測試沒有偷懶,它只是精準執行了錯誤題目,所以仍然全部通過。

CLEAN 原則:先校正情境,再要求證據與符合預期的答案

C — Context-Aware Code 情境感知:測試資料要符合 Repository 的真實精度

只讀規格文字,「剛好二十四小時」沒有問題;把 SQLite Converter 與 Unix millisecond 放回 Context,才看得出一個 Tick 與小數毫秒都可能在存取後消失。

User 要先確認 Clock、Culture、Database Provider、序列化、時區與外部服務會怎麼改變資料,再決定測試值。審查時要問的不是「這個變數原本是不是等號」,而是:這份 Test Data 經過真實執行路徑後,還代表原本那個邊界嗎?

A — Auditable by Evidence 實據可審:全綠之外,要證明錯誤真的能讓測試變紅

「Agent 說 Tests Passed」只證明目前的 Assertion 與資料相容。今天再放入三個受控錯誤,才看見哪些規則真的被辨識,以及哪個等號其實從未被測到。

可審查的實據包括 Prompt、Baseline、Diff、獨立 Oracle、受控 Mutation 結果、人工接受理由與未驗證範圍。每個專案可以依風險決定 Mutation 範圍,但「這套測試守住關鍵行為」必須有東西可查。

N — Non-Surprising Behavior 符合預期:測試名稱、資料與系統答案必須一致

測試叫做「剛好二十四小時」,資料卻在讀回後早於門檻,已經違反 N。名稱、Gherkin、寫入前資料與實際送進 Processor 的值,必須指向同一項行為。

User 要先確認公開契約、邊界值、失敗路徑與副作用,再用獨立檢查確認候選沒有偷偷換答案;每個 Assertion 的實作細節仍可交給 Agent 決定。

九次執行的 Token、工具呼叫與 Diff,只能作為本次成本線索

三種方式各跑三次。下表使用中位數;Fresh Input Token 是扣除快取後重新讀入的輸入量,Output Token 是模型產生回覆與程式碼的輸出量,Tool Calls 則是 Agent 搜尋、讀檔、修改與驗證時呼叫工具的次數。

實驗方式 Fresh Input Tokens Output Tokens Tool Calls 執行時間 新增行數
依現有實作補測試 107,721 14,045 32 521.858 秒 116~118
行為 DSL 96,975 16,840 40 532.145 秒 213~266
產品範例驅動驗收 104,303 20,382 43 639.541 秒 289~309

依現有實作補測試的 Output Token、工具呼叫與 Diff 最少;行為 DSL 的 Fresh Input 中位數最低,但需要產生更多測試建構器與測試語彙;產品範例驅動驗收在本次實驗的輸出、時間與 Diff 都最高。

這些數字只能描述本次九個 Session。Cache、模型探索路徑、Prompt 長度、既有測試架構與重試方式都會改變結果。它們不足以證明整潔測試必然節省 Token,也不能證明產品範例越完整,Agent 就一定越正確。

本次接受第二次行為 DSL 結果,因為多個狀態與時間案例已經共享測試細節

三種策略都有各自適合的使用情境:

策略 適合考慮的情境 採用前要確認
依現有實作補測試 Legacy Code 缺少保護,而且現況已由業務確認 不把現有實作直接當成產品真理;必要時另找需求來源
行為 DSL 多個案例共享大量 Setup/HTTP 細節,業務差異反而被雜訊淹沒 關鍵資料仍留在案例附近;DSL 沒有膨脹成第二套 Framework
產品範例驅動驗收 新功能、跨產品與開發角色,或等號、失敗與副作用需要先談清楚 Oracle、Provider 精度與 Test Data 已被獨立驗證

本系列接下來採用三次行為 DSL 實驗中的第二份結果(Run 02),主要有三個原因:

  1. 三個受控 Mutation 全部被測試抓到。
  2. Priority、Status、二十四小時、少一毫秒、通知結果與預期路由都留在 Scenario 附近。
  3. Repository 已經有多個逾期案例共享測試環境建立、初始資料寫入與 HTTP 呼叫細節,額外的 Test Builder 與 DSL 有機會被後續測試重複使用。

這個選擇和最少 Token 無關。行為 DSL 的 Output Token、工具呼叫與 Diff 都較高,我接受的是後續維護路徑:重複技術細節被收起來,真正會改變答案的資料仍留在案例附近。

若需求只是一個穩定的小型回歸案例,依現有實作補測試的成本反而較低;若需要產品、QA 與工程師共同確認新需求,產品範例驅動驗收會更適合,但要另外檢查 Oracle 與邊界資料是否正確。

接受版本:

讀者可以直接切換到接受版本:

git switch --detach day-13-clean-acceptance-tests
git rev-parse HEAD

預期 Commit:

eb95ef658d5ae5081c1be6e667f7d4e7a8399440

把三種測試策略整理成可放入 Repository 的 Clean Test Policy

我先把這次校準出的判斷條件整理成一份 Policy 範例。讀者可以依 Repository 的測試規模放進 AGENTS.md;規則穩定後,再抽成可重複使用的 Skill。

## Clean Test Policy

### Shared Oracle and Context

- 先列出公開契約、邊界值、失敗路徑、副作用與不可改變的行為,再開始修改 Production Code。
- Clock、Culture、Database Provider、序列化與外部服務會改變測試資料時,必須以真實路徑完成一次 Round-trip 驗證。
- 測試名稱、輸入資料、Act 與 Assertion 必須描述同一項行為。

### Implementation-coupled Tests

- 只在既有行為已由獨立需求來源確認,或 Legacy Code 需要 Characterization Test 時優先採用。
- 不得以 Production Code 本身作為產品需求正確的唯一證明;若只能由實作反推,完成回報要標示這項限制。

### Example-driven Acceptance

- 新功能、跨角色需求、等號邊界、失敗語意與外部副作用,優先先建立產品範例與可觀察結果。
- Gherkin 必須對應到 Build 會執行的自動化測試;只有文字 Scenario 不算完成。
- Acceptance Test 優先觀察公開契約;若直接讀取 Database,要標示技術邊界與理由。

### Behavior DSL

- 多個案例共享大量 Setup 時才考慮 Test DSL,並把會改變答案的資料留在案例附近。
- DSL 若隱藏關鍵條件、需要跨多個檔案才能理解,或只有少量案例,改用直接的 3A 測試。

### Mutation and Final Validation

- 金額、時間、權限、狀態轉換、併發與外部副作用屬高風險規則,至少故意改錯一個關鍵判斷,確認相關測試會失敗。
- Survived Mutant 只作為審查線索;先檢查 Test Data、Equivalent Mutant 與業務價值,不追求沒有脈絡的 100% 分數。
- 完成後回報採用策略、適用理由、未採用方案、測試保護範圍、未涵蓋風險、Mutation 結果與停止理由。

這份 Policy 目前是文章中的示範,尚未寫入 API Demo 的 AGENTS.md。它提供的是選擇條件,不保證 Agent 每次都會產生最少程式碼或使用最少 Token。未來規則穩定後,抽成可重複使用的 Skill 會比長期塞在 AGENTS.md 更合適。

全綠代表目前問答一致,仍要檢查題目是否正確

回到開頭,三次產品範例驅動驗收不是沒有測試,也不是沒有寫「剛好二十四小時」。它們全綠的原因,是測試資料經過真實 Provider 後,早已不再代表等號。

整潔的測試負責讓人類與 Agent 看懂正在保護什麼;驗收測試負責確認產品答案;受控 Mutation 則反過來問:我故意把答案改錯時,這套測試會不會反對?三者各自負責不同問題。

本次接受行為 DSL Run 02,是因為目前的狀態與時間案例已經共享大量 Setup,而且三個受控錯誤都能被抓到。依現有實作補測試與產品範例驅動驗收仍有各自適合的情境,不能因為這次選擇就排成永久名次。

最重要的教訓仍是 Oracle。這次錯誤固定時間是我提供的,Agent 只是忠實把它變成可執行測試。想把更多開發工作交給 Agent,User 必須先對測試涵蓋的答案負責,也要用真實 Provider、獨立驗收與受控錯誤確認那份答案值得信任。

實際上的測試決策遠比今天複雜。Oracle 由誰定義、測試資料是否符合真實 Provider、要隔離到什麼程度、哪一層使用 Unit/Integration/Acceptance/E2E,以及哪些高風險規則值得做 Mutation,都足以各自寫成一篇文章,甚至獨立成一個完整系列。

今天先用一個很小的等號,看見「測試全綠」與「測試可信」之間的距離。明天開始跨出 Code,進入 Design:當幾份候選都能通過這套測試,我們要怎麼判斷哪一份設計已經足夠簡單,同時避免換來另一種更難維護的複雜度?

參考資料與公開實驗


上一篇
Day 12|AI 寫完再測,和先測再改有什麼差別?比較直接完成、TDD 與 TCR
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言