從今天開始是這個系列的第四部:實戰案例與總結。前面 23 天講了不少原則——假陽性測試、過度 mock、邊界情境遺漏、循環被打斷。今天用一次除錯過程,看這些原則怎麼在真實情境裡一起發揮(或者說,沒有發揮)作用。
一套訂單系統的付款流程,某天收到回報:少數訂單被重複扣款兩次。追查發現,問題出在「使用者在付款按鈕上快速點擊兩次」這個情境——第一次點擊的請求還在處理中,第二次點擊的請求就已經送出,兩個請求都各自完成了一次扣款。
回頭檢查這段付款邏輯的測試套件,發現測試確實存在,而且看起來寫得挺完整:
❌ 事故發生前的測試套件(節錄):
[Fact]
public void ProcessPayment_有效訂單_應成功扣款()
{
var order = new Order(amount: 100, status: OrderStatus.Pending);
var result = paymentService.Process(order);
Assert.True(result.Success);
}
[Fact]
public void ProcessPayment_訂單已付款_應拒絕重複扣款()
{
var order = new Order(amount: 100, status: OrderStatus.Paid);
var result = paymentService.Process(order);
Assert.False(result.Success);
}
第二個測試案例看起來就是在防重複扣款——但仔細看會發現,這個測試驗證的是「訂單狀態已經是 Paid 時再呼叫會被拒絕」,這是一個循序發生的情境:第一次扣款完全結束、狀態更新完畢之後,第二次呼叫才進來。真實事故裡發生的是兩個請求幾乎同時進來,第一個請求還沒把狀態更新成 Paid,第二個請求就已經讀到了舊的 Pending 狀態——這是一個並發情境,跟測試驗證的循序情境完全不同。
這正是 Day15 講過的「邊界情境容易被忽略」的真實樣貌——AI(或當初寫測試的人)想到了「重複扣款」這個規則需要被保護,也確實寫了一個測試,但這個測試只涵蓋了「循序重複呼叫」這一種情境,沒有涵蓋「並發情境下的重複呼叫」。兩者從業務規則的角度看是同一件事(防止重複扣款),但從程式碼實作跟測試設計的角度看,是完全不同的技術問題——循序重複可以靠檢查狀態欄位擋住,並發重複需要額外的鎖機制或幂等性設計。
測試套件裡「看起來已經測過重複扣款」這件事,製造了一種虛假的安心感——這正是這個系列反覆強調的主題句在這裡的具體樣貌:「已經測過重複扣款」這句話,其實只在「循序呼叫」這個查證範圍內成立,範圍外(並發呼叫)的情況完全沒被驗證過,卻被直覺地當成「這條規則已經被涵蓋了」。
修復這類 bug 的第一步,不是直接改程式碼,是先寫一個能重現「並發呼叫」情境的測試,讓它先紅——確認這個測試真的能抓到問題,再動手修正實作(用資料庫層級的鎖或幂等性 token 之類的機制),最後確認新測試變綠、舊測試依然通過。
✅ 補上的測試:
[Fact]
public async Task ProcessPayment_同時發出兩個請求_只有一個應成功()
{
var order = new Order(amount: 100, status: OrderStatus.Pending);
var task1 = Task.Run(() => paymentService.Process(order));
var task2 = Task.Run(() => paymentService.Process(order));
var results = await Task.WhenAll(task1, task2);
Assert.Equal(1, results.Count(r => r.Success));
}
回想你手上系統裡某條「已經有測試保護」的重要規則:那個測試涵蓋的情境,跟真實世界可能觸發這條規則的所有情境,是不是完全一致?有沒有並發、時序這類容易被漏掉的維度?
明天要看另一種除錯情境:AI 把測試改鬆來讓 CI 通過,而不是真的修好程式碼——這是一種更隱蔽、更容易被忽略的失敗模式。