iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

AI 時代的 TDD:讓 AI 寫 Code,但不要讓它決定品質系列 第 14

Day 14:案例——一個被過度 mock 掉的測試,通過了但沒抓到真正的 bug

  • 分享至 

  • xImage
  •  

前言:測試綠燈、CI 綠燈,為什麼上線還是炸了?

「這支測試把外部依賴都 mock 掉了,應該很單純、很穩定吧?」

昨天談到 AI 傾向把待測物件依賴的每一個東西都 mock 掉,理由聽起來很合理——隔離、快、不受外部環境影響。但「隔離」跟「隔離過頭」中間只有一線之隔,今天用一個具體案例,示範這條線被跨過去之後,測試會變成什麼樣。

今日目標

  • 看一個「訂單服務呼叫付款閘道」的案例,AI 把付款閘道整個 mock 掉之後發生了什麼
  • 理解 mock 回傳的資料格式,跟真實 API 之間的落差,怎麼被測試完全放過
  • 學會分辨「該 mock 的是行為」還是「該 mock 的是資料契約」
  • 建立一個具體習慣:mock 的回傳值,要跟真實系統的契約對齊,不能憑感覺編

案例:一個綠燈到底、卻在 production 才爆炸的測試

假設有一個 OrderService,下單時要呼叫 PaymentGateway 完成扣款:

❌ 過度 mock 的測試:
class OrderServiceTest extends TestCase
{
    public function test_creates_order_when_payment_succeeds(): void
    {
        $paymentGateway = $this->createMock(PaymentGateway::class);
        $paymentGateway->method('charge')->willReturn([
            'status' => 'success',
            'id' => 'txn_123',
        ]);

        $orderService = new OrderService($paymentGateway);
        $order = $orderService->create($cart = new Cart(['amount' => 1000]));

        $this->assertSame('paid', $order->status);
    }
}

這支測試看起來完全合理:mock 掉外部依賴、驗證訂單狀態變成 paid。它會通過,CI 也會綠燈。

問題出在 willReturn([...]) 這一行——AI 憑著對「付款成功大概長什麼樣」的直覺,自己編了一個回傳格式:status 是字串 'success'id 是交易編號。但真實的 PaymentGateway API 回傳的格式其實是巢狀的:

{
  "result": { "code": 0, "message": "OK" },
  "transaction": { "reference": "txn_123" }
}

OrderService::create() 內部讀的是 $response['result']['code'] === 0$response['transaction']['reference'],不是 mock 裡編的那兩個扁平欄位。實際串接到真實付款閘道時,OrderService 讀不到預期的欄位,觸發例外,訂單建立失敗——但這整個落差,在測試裡完全沒有機會被看見,因為 mock 回傳的假資料,從一開始就沒有依據真實契約去對齊。

為什麼這種落差,測試自己抓不到

這正是過度 mock 最陰險的地方:測試的綠燈,只證明了「待測程式碼在面對這個 mock 訂出的假設下行為正確」,完全沒有驗證「這個假設本身是不是真的」。 待測物件(OrderService)跟它依賴的外部系統(PaymentGateway)之間的契約,一旦被 mock 取代,這個契約有沒有被正確理解,就變成一個測試完全看不見的黑洞。

AI 在寫這種 mock 時,通常不是刻意編造,而是基於「看起來合理」的直覺——status: success 讀起來就是個成功的付款結果,語意上沒有錯,但語意合理不代表格式正確。AI 缺乏「我現在編的這個回傳值,有沒有真的對照過官方文件或真實回應」這個查證動作的內建提醒,而人類工程師在寫這種 mock 時,通常會下意識想到「這個格式我是猜的還是查的」。

用一組對照來看正確的做法:

✅ 對齊真實契約的 mock:
class OrderServiceTest extends TestCase
{
    public function test_creates_order_when_payment_succeeds(): void
    {
        $paymentGateway = $this->createMock(PaymentGateway::class);
        $paymentGateway->method('charge')->willReturn(
            // 格式直接複製自付款閘道官方文件的成功回應範例,
            // 或是從一次真實呼叫的回應錄下來的 fixture
            [
                'result' => ['code' => 0, 'message' => 'OK'],
                'transaction' => ['reference' => 'txn_123'],
            ]
        );

        $orderService = new OrderService($paymentGateway);
        $order = $orderService->create($cart = new Cart(['amount' => 1000]));

        $this->assertSame('paid', $order->status);
    }
}

→ 差別不在「有沒有 mock」,在「mock 出來的資料是不是真的對照過契約」。

兩種該問的問題,不是「要不要 mock」

真正該檢查的問題,不是「這裡該不該 mock」(Day13 已經講過,外部依賴通常都該 mock),而是:

  1. 這個 mock 回傳的資料格式,是從哪裡來的? 是複製自官方 API 文件、還是從一次真實整合測試的錄製結果,還是 AI 憑印象編的?只有前兩者可信。
  2. 如果這個外部依賴的回應格式改版了,這支測試會不會知道? 過度 mock 的測試通常答案是不會——因為 mock 本身完全獨立於真實系統,契約漂移不會反映在測試結果上。這也是為什麼很多團隊會額外保留一小撮「真的打一次外部系統」的合約測試(contract test),跟大量的 mock 測試分工,各自負責不同層次的信心。

今日思考題

回想你手上被 mock 掉的外部依賴:那個 mock 回傳的資料,你是從真實文件或真實回應複製過來的,還是憑印象編的?如果答案是後者,這支測試給你的安全感,可能比你以為的還要薄弱。

今日重點回顧

  • 過度 mock 的測試只驗證「待測程式碼面對這個假設時行為正確」,不驗證「這個假設是不是真的」
  • 案例:mock 回傳的付款結果格式是編出來的,跟真實 API 的巢狀結構不一致,直到 production 才爆炸
  • AI 編 mock 資料時容易「語意合理」但「格式不對」,因為它沒有主動查證契約的習慣
  • mock 的回傳值要對齊官方文件或真實錄製結果,不能憑印象編
  • 真正該分工:mock 測試負責邏輯正確性,合約測試(contract test)負責偵測外部契約漂移

明日預告

明天要往前一步:AI 除了容易 mock 過頭,也容易只測 happy path——邊界情境跟例外狀況,往往要人主動要求才會被想到。


上一篇
Day 13:Mock 的濫用——AI 傾向 mock 一切,隔離過頭反而測不到整合行為
下一篇
Day 15:邊界情境——AI 容易只測 happy path,例外情境要人主動要求
系列文
AI 時代的 TDD:讓 AI 寫 Code,但不要讓它決定品質15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言