iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 14 篇

Day 14|測試變綠了,真的代表規則被守住了嗎?

  • 分享至 

  • xImage
  •  

昨天把訂單狀態的轉換規則定下來,其中有一條規則很重要:

結算時如果餘額不足,訂單必須進入 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);
}

測試是綠的,看起來沒毛病,問題是——魔鬼總是藏在細節裡,這裡其實混在一起測了兩件事情:

  1. Order 物件的狀態有沒有變成 FAILED
  2. 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 與資料庫狀態,還需要整合測試補上。

測試變綠只是第一關;能在規則被破壞時重新變紅,才代表它真的守住了規則。


上一篇
Day 13|設計訂單狀態機
下一篇
Day 15|分頁查詢:資料變動時,分頁可能位移
系列文
做一個團購後端,順便搞懂那些事 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言