改動不多,測試全綠,可只剩三個名額時,預訂四個也成功了。剩餘數量看起來甚至很正常:零。把測試檔案的 diff 一起開啟,問題才露出來:原來要求拋錯的地方,現在只檢查結果是不是非負數。
假設讓 AI 簡化一個計算剩餘名額的函式,同時保留原來的接受和拒絕規則。下面的修補程式和測試為教學編寫,用 Node 內建測試工具就能在本機執行。
先寫清楚原來的測試在保護什麼
這裡假定上游已經檢查過 available:它是非負安全整數。函式接收預訂數量,返回準確的剩餘數量;請求不合法時丟擲 RangeError。允許的數量是從 1 到 available 的數字型別安全整數,不做取整、字串轉換,也不接受部分預訂。
export function remainingSeats(available, requested) {
if (
!Number.isSafeInteger(requested) ||
requested < 1 ||
requested > available
) {
throw new RangeError(
'requested seats must be an integer from 1 to available',
);
}
return available - requested;
}
剩三個名額時,幾種結果都可以手算:
| 請求數量 | 應有行為 | 原因 |
|---|---|---|
1、2、3 |
分別返回 2、1、0 |
完整接受請求 |
0、-1、1.5 |
丟擲 RangeError |
數量不能是零、負數或小數 |
4 |
丟擲 RangeError |
剩餘名額不足以滿足整個請求 |
'2'、NaN、Infinity |
丟擲 RangeError |
必須是數字型別的安全整數 |
沒有剩餘名額時,請求一個也要錯誤。注意,返回零已經表示「剛好訂完」,不能再拿它表示失敗。
審查真實程式碼時,要從需求、現有呼叫方和基線版本的測試裡確認這些規則。舊斷言也可能寫錯,但修改它之前,得說明究竟要改哪一種行為。這個例子的要求很明確:原來的拒絕規則要保留。
把兩份 diff 中刪掉的東西放在一起看
修補程式把輸入檢查換成了截斷:
export function remainingSeats(available, requested) {
- if (
- !Number.isSafeInteger(requested) ||
- requested < 1 ||
- requested > available
- ) {
- throw new RangeError(
- 'requested seats must be an integer from 1 to available',
- );
- }
- return available - requested;
+ const count = Math.min(available, Math.max(1, Math.trunc(requested)));
+ return available - count;
}
傳入 (3, 4),區域性變數 count 被壓成 3,函式返回 0。傳入 (3, 1.5),它先取整為 1,最後返回 2。普通請求仍然算對了,但原來應該錯誤的請求現在也能成功返回。
再看測試。三項修改都用了下面的寫法:
- test('rejects more seats than available', () => {
- assert.throws(() => remainingSeats(3, 4), RangeError);
+ test('handles more seats than available', () => {
+ assert.ok(remainingSeats(3, 4) >= 0);
});
輸入沒變,要求的行為變了。新斷言直接呼叫函式;如果函式還像以前那樣拋錯,這條測試反而會失敗。悄悄接受超額請求的實作,現在可以透過。測試名也從「拒絕」改成了「處理」,看報告時更難發現規則已經被換掉。
零和小數的測試也刪掉了異常斷言。整個測試檔案仍然有五項測試,其中兩項正常預訂測試完全沒動。數一數測試有幾條,看不出哪些行為已經沒人檢查了。
在真實專案裡,可以把實作與測試一起展開,再讀基線中的原斷言:
git diff BASE HEAD -- src/remaining-seats.mjs test/remaining-seats.test.mjs
git show BASE:test/remaining-seats.test.mjs
把版本與路徑換成專案裡的實際值,記下這次對比的準確版本。如果工作區已經有後續修改,別把工作區裡的呼叫方當成所選提交裡的程式碼。下載包裡的 ai-style.patch 可以直接閱讀,不需要為例子建立 Git 提交。
每看到一條被刪掉的檢查,就試著寫出一個輸入:刪掉 requested > available,反例就是 (3, 4)。每看到一條被刪掉的斷言,就說清它原來要求什麼:這個請求必須在呼叫方繼續執行前拋錯。這樣兩邊就連起來了。
開啟那個一行都沒改的呼叫方
下面的呼叫方由兩個版本共用,修補程式沒有修改它。為了讓例子完全在記憶體中執行,數量函式和同步的 save 都透過參數傳入:
export function createBooking(remainingSeats, save) {
return function book(available, requested) {
const remaining = remainingSeats(available, requested);
const reservation = { seats: requested, remaining };
save(reservation);
return reservation;
};
}
這裡有兩個數量。remaining 來自計算函式,記錄中的 seats 卻仍然使用原始的 requested。舊實作要麼完整接受這個數量,要麼在執行到 save 之前拋錯。換成修補程式後,計算時用的是三個,儲存時寫的卻是四個。
讓同一個呼叫方接收 (3, 4),實際得到:
| 使用的實作 | 外層呼叫者看到的結果 | save 呼叫 |
|---|---|---|
| 原實作 | RangeError |
沒有呼叫 |
| 修補程式 | { seats: 4, remaining: 0 } |
呼叫一次,參數就是這個物件 |
這個例子觀察到的是記憶體中錯誤的預訂記錄,以及一次儲存呼叫。它沒有連線資料庫、扣款,也沒有造成真實使用者的預訂。
在自己的專案裡,搜尋範圍要越過「有改動的檔案」清單:
rg -n 'remainingSeats|createBooking' src test
順著 import 確認呼叫的是哪個實作,再看呼叫之後怎麼處理:儲存原始輸入、構造響應,還是捕獲某種錯誤?名字搜不全時,可以配合語言服務的引用查詢,繼續檢視包裝函式。程式碼閱讀指南介紹了這些入口的用法。
保留異常檢查,再單獨觀察有沒有儲存
把原來的三項拒絕測試恢復,就已經能抓住這個修補程式。再補一項呼叫方測試,才能說明為什麼不能刪掉拒絕行為:
const writes = [];
const book = createBooking(remainingSeats, (row) => writes.push(row));
assert.throws(() => book(3, 4), RangeError);
assert.deepEqual(writes, []);
這裡傳入的是真實的原實作或修補程式,只把 save 換成陣列記錄器。如果連數量函式也替換成「總是拋錯」的樁,恰好繞過了要審查的改動。
這段緊湊的測試會在第一條斷言失敗後停止。下載包還把「沒有嘗試儲存」單獨寫成了一項測試,方便一次執行同時看見兩個問題:
const writes = [];
const book = createBooking(remainingSeats, (row) => writes.push(row));
try {
book(3, 4);
} catch {
// A separate test checks that the error is RangeError.
}
assert.deepEqual(writes, []);
這個空 catch 只用於繼續檢查副作用。單獨保留它會太弱:只要在儲存前隨便拋一個錯誤,也能透過。完整測試另外要求丟擲 RangeError,檢查合法預訂準確儲存一次,並確認儲存失敗會傳回撥用者。所以,把所有請求都拒絕掉,或者什麼也不做,同樣過不了。
驗證套件共 15 項:三項準確的成功回傳值,八項非法輸入(包括名額售罄),以及四項呼叫方測試。預期值來自前面寫明的規則,不是把修補程式裡的截斷公式複製進斷言。
用同一組要求分別跑原實作和修補程式
下載完整示例,解壓後在其中的 review-ai-generated-tests 資料夾開啟終端。需要 Node.js 22 或更新版本,不用安裝依賴、連線網路或設定模型服務商。
node run.mjs baseline
node run.mjs proposed-original
node run.mjs proposed-weak
node run.mjs verify-baseline
node run.mjs verify-proposed
每條命令都透過 node:test 註冊測試,並按真實測試結果退出。請分別執行:第二條和第五條就是要失敗。不要用 && 串起來,否則遇到第一次預期失敗就會停。
下面是在 macOS arm64、Node.js v22.22.1 上實際跑出的結果:
| 模式 | 實作 / 斷言 | 透過 | 失敗 | 退出碼 |
|---|---|---|---|---|
baseline |
原實作 / 原斷言 | 5 | 0 | 0 |
proposed-original |
修補程式 / 原斷言 | 2 | 3 | 1 |
proposed-weak |
修補程式 / 弱化斷言 | 5 | 0 | 0 |
verify-baseline |
原實作 / 驗證套件 | 15 | 0 | 0 |
verify-proposed |
修補程式 / 驗證套件 | 5 | 10 | 1 |
修補程式會報 Missing expected exception (RangeError).,意思是預期的異常沒有發生。單獨檢查儲存的測試還會顯示:實際陣列裡有 { seats: 4, remaining: 0 },預期卻是空陣列。執行 node show-caller.mjs 可以直接列印兩個版本的呼叫結果;這個命令只展示結果,沒有斷言哪一個正確。
需要一個預設全綠的檢查命令時,執行 node --test run.mjs,它選擇的是 verify-baseline。這裡沒有把「出現錯誤結果」寫成應該透過的驗證:同一套驗證始終要求正確行為,修補程式執行後確實失敗。命令沒有使用特定作業系統的 shell 語法,但這次只在 macOS 實測,沒有跑 Linux、Windows 矩陣。
把這個做法用到自己的專案時,先保留基線中相關的斷言,讓它們跑修補程式程式碼,再補上 diff 暗示的最小呼叫方反例。如果新測試在舊實作和錯誤修補程式上都能透過,回頭看輸入與斷言,確認它有沒有區分出那條被改掉的規則。
把失敗結果寫成能處理的審查意見
這次審查可以這樣寫:
remainingSeats(3, 4)原來拋錯,改為截斷後返回零。未修改的createBooking隨後儲存{ seats: 4, remaining: 0 }。本次修改的三項測試原本要求RangeError,現在只檢查結果非負,剛好放過這個行為。請保留拒絕檢查,並驗證超額預訂不會呼叫save。原實作透過全部 15 項驗證,修補程式失敗 10 項。
按目前需求,保留原來的輸入校驗,恢復異常斷言即可。如果產品確實要允許部分預訂,就要先定義實際接受的數量怎麼傳給呼叫方、使用者確認的數量是什麼,再一起修改實作、呼叫方和測試。只在輔助函式里悄悄改一個區域性數量,這些問題都還沒解決。
示例覆蓋同步校驗和對同步儲存函式的呼叫,假定 available 已合法。併發搶名額、資料庫原子性、重試和權限檢查都不在這個範圍內,需要到實際整合位置另外驗證。
Yenpo 可以說明你閱讀實作、測試與呼叫方,比較已提交的版本。目前系統要求見安裝指南;這套 Node 示例與審查方法不依賴 Yenpo。在 Git 對比中核對好所選版本後,可以這樣問:
找出這個修補程式刪掉的輸入接受規則,以及一起修改的斷言。繼續看依賴這些異常的未修改呼叫方。每個問題給出具體輸入、應有結果和原始碼位置;基線裡原有的問題與新回歸分開說明。