昨天把訂單狀態的轉換規則定下來,其中有一條規則很重要:
結算時如果餘額不足,訂單必須進入
FAILED,不能停在CLOSED。
CLOSED 代表「訂單已關閉,準備進行扣款」,如果扣款失敗卻還停在 CLOSED,系統就無法正確知道這筆訂單最後發生了什麼,定下這規則的同時也產生了一個問題:「如果三個月後有人修改結算流程,不小心讓 FAILED 沒有被寫回資料庫,測試會不會發現?」
直覺上會覺得:「當然會,我不是已經寫測試了嗎?」但實際上,測試很可能是綠的。
payOrder() 在餘額不足時,會先把訂單狀態改成 FAILED,再寫回資料庫:
try {
paymentService.executePayment(order);
} catch (InsufficientBalanceException e) {
// Set FAILED "outside" the rolled-back transaction
order.setStatus(OrderStatus.FAILED);
orderMapper.updateById(order);
throw e;
}
所以測試也確認了狀態:
@Test
@DisplayName("餘額不足 → FAILED + 拋出 InsufficientBalanceException")
void insufficientBalance() {
Order order = createOrder("ord-001", "alice", OrderStatus.CLOSED);
when(orderMapper.selectById("ord-001")).thenReturn(order);
doThrow(new InsufficientBalanceException("alice 餘額不足"))
.when(paymentService).executePayment(order);
lenient().when(orderMapper.updateById((Order) any()))
.thenReturn(1);
assertThatThrownBy(() -> orderService.payOrder("ord-001"))
.isInstanceOf(InsufficientBalanceException.class);
assertThat(order.getStatus())
.isEqualTo(OrderStatus.FAILED);
}
測試是綠的,看起來沒毛病,問題是——魔鬼總是藏在細節裡,這裡其實混在一起測了兩件事情:
Order 物件的狀態有沒有變成 FAILED
FAILED 有沒有真的送進 updateById()
測試裡那行 lenient().when(orderMapper.updateById(...)).thenReturn(1) 看起來像在處理 updateById(),但它只是事先準備好「被呼叫時回傳 1」,不會檢查有沒有被呼叫或帶了什麼,事實上,第一點有測到,而第二點並沒有。
把 updateById() 刪掉會怎樣?
假設三個月後有人不小心把 updateById() 刪掉,程式就不會把狀態寫回資料庫。
catch (InsufficientBalanceException e) {
order.setStatus(OrderStatus.FAILED);
// orderMapper.updateById(order);
throw e;
}
但原本的測試仍然會是綠的。
因為:
assertThat(order.getStatus())
.isEqualTo(OrderStatus.FAILED);
檢查的是記憶體裡的 order 物件,它只知道:「order.status 現在是 FAILED」,它並不知道:
「這個 FAILED 有沒有真的傳給 updateById()。」
既然規則要求的是寫入資料庫時,狀態必須是 FAILED,那測試就不能只檢查 order 最後長什麼樣子,而要檢查呼叫 updateById() 的那一刻,傳進去的 Order 是什麼狀態?
這時候可以讓 Mockito 在 updateById() 被呼叫時,把當下的狀態記錄下來:
List<OrderStatus> persistedStatuses = new ArrayList<>();
when(orderMapper.updateById((Order) any()))
.thenAnswer(invocation -> {
Order saved = invocation.getArgument(0);
persistedStatuses.add(saved.getStatus());
return 1;
});
assertThatThrownBy(() -> orderService.payOrder("ord-001"))
.isInstanceOf(InsufficientBalanceException.class);
assertThat(persistedStatuses)
.containsExactly(OrderStatus.FAILED);
現在測試驗證的就是「送進 updateById() 的狀態是不是 FAILED」。
RED → GREEN 只證明測試會過。GREEN 之後還要把負責規則的程式碼逐一拿掉,確認測試會重新變紅:
| 故意破壞實作 | 修正後的測試 |
|---|---|
拿掉 setStatus(FAILED) |
insufficientBalance 紅 |
拿掉 updateById() |
insufficientBalance 紅 |
拿掉之後測試沒有反應,就代表它只驗證了「程式有跑」,沒有驗證「規則成立」。
這些仍是 Mockito 的單元測試,驗證的是「傳給 Mapper 的資料是否符合預期」,不代表真實資料庫一定會正確更新;交易、rollback 與資料庫狀態,還需要整合測試補上。
測試變綠只是第一關;能在規則被破壞時重新變紅,才代表它真的守住了規則。