iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Software Development

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

Day 19:讓 AI 寫 Test Double 時要注意的邊界——Dummy/Stub/Fake/Spy/Mock

  • 分享至 

  • xImage
  •  

前言:反正都是「假的依賴」,選哪一種有差嗎?

「Test Double 不就是拿一個假的物件頂替真的依賴嗎?隨便用一種 mocking 框架生一個出來,測試能跑不就好了?」

Day 13 講過 AI 容易把不該 mock 的東西也 mock 掉。今天要講一個更細的問題:就算 AI 判斷對了「這個依賴確實該用測試替身頂替」,它選的替身種類也常常是錯的——而且錯的方式很一致:不管情境需要什麼,AI 幾乎永遠伸手去拿 mocking 框架裡最重的那個工具。

今日目標

  • 認識 Dummy、Stub、Fake、Spy、Mock 五種 Test Double 各自的定位
  • 理解 AI 為什麼傾向「全部用 Mock 解決」,這個偏好從哪裡來
  • 看一組具體案例,示範選錯 Test Double 種類會讓測試變得多脆弱
  • 建立「這裡該用哪一種替身」的判斷習慣

五種 Test Double,各自答不同的問題

Gerard Meszaros 提出、後來被 Martin Fowler 在《Mocks Aren't Stubs》裡引用討論的五種測試替身分類,每一種其實是在回答一個不同的問題:

  • Dummy:純粹用來填滿參數列表的物件,測試邏輯完全不會用到它——它存在只是因為建構子簽章要求要傳一個東西進去。
  • Stub:對呼叫給出預先設定好的固定回應,讓待測程式碼能在可控的輸入下執行——它回答的問題是「如果這個依賴回傳了 X,我的邏輯會怎麼反應?」。
  • Fake:一個真正能動、但走了捷徑的簡化實作(最常見的例子是用記憶體內的假資料庫取代真的資料庫)——它回答的問題是「如果這個依賴真的照著它該有的行為運作,結果會是什麼?」。
  • Spy:包住真實或假的物件,額外記錄下有哪些呼叫發生過,供測試事後檢查——它回答的問題是「這段程式碼有沒有真的呼叫到這個依賴?呼叫了幾次?帶了什麼參數?」。
  • Mock:預先設定好「應該被怎麼呼叫」的期待,測試執行完會自動驗證這些期待有沒有被滿足——它回答的問題是「這段程式碼有沒有照著我期待的方式跟這個依賴互動?」。

這五種替身不是同一件事的五個名字,是針對五種不同的驗證需求設計出來的五種工具。

AI 為什麼總是伸手拿 Mock

問題出在 mocking 框架(不管哪個語言生態,Mockito、Jest 的 jest.mock、PHPUnit 的 createMock 都一樣)通常會把「產生一個測試替身」這件事,包裝成同一個入口——你呼叫同一支 API,就能同時做出 Stub 的效果(設定回傳值)跟 Mock 的效果(設定呼叫期待)。框架把五種概念揉進同一個工具介面裡,AI 學到的自然也是「有依賴要頂替,就呼叫這支 API」,而不是「先問這裡真正要驗證的是什麼」。

於是 AI 生成測試時最常見的模式是:不管這裡只是需要一個固定回傳值(該用 Stub 的情境),還是需要驗證某個方法真的被呼叫過(該用 Mock 或 Spy 的情境),一律用同一種「設定期待 + 事後驗證呼叫」的寫法解決,因為那是框架提供的、AI 用得最順手的預設路徑。

用一組對照來看這個差異:

❌ 情境只需要 Stub,卻被寫成 Mock:
class InvoiceServiceTest {
    @Test
    void generatesInvoiceWithCurrentTaxRate() {
        var taxRateProvider = mock(TaxRateProvider.class);
        when(taxRateProvider.getCurrentRate()).thenReturn(0.05);

        var service = new InvoiceService(taxRateProvider);
        var invoice = service.generate(1000);

        assertEquals(50, invoice.getTaxAmount());
        verify(taxRateProvider, times(1)).getCurrentRate();
        // 這行 verify() 在驗證什麼?這個測試真正關心的是
        // 「稅額算得對不對」,不是「稅率查詢方法被呼叫了幾次」——
        // 呼叫次數是這個測試完全不在乎的實作細節,
        // 卻因為用了 mock() 而被迫多驗證一件不相干的事,
        // 未來重構呼叫方式(例如改成查一次快取兩次)就會讓這個測試無端變紅
    }
}

✅ 情境只需要 Stub,就只設定回傳值:
class InvoiceServiceTest {
    @Test
    void generatesInvoiceWithCurrentTaxRate() {
        var taxRateProvider = stub(TaxRateProvider.class);
        taxRateProvider.setCurrentRate(0.05);

        var service = new InvoiceService(taxRateProvider);
        var invoice = service.generate(1000);

        assertEquals(50, invoice.getTaxAmount());
        // 只驗證真正關心的結果——稅額算對了沒有,
        // 不去管稅率查詢方法內部被呼叫了幾次、用什麼順序呼叫,
        // 這些都是實作細節,跟「這張發票的稅額對不對」無關
    }
}

第一個版本因為多了一行不必要的 verify(),把「這段程式碼怎麼跟依賴互動」也綁進了測試斷言裡——這正是 Day 13 講過的過度 mock 的另一種樣貌:不是 mock 了不該 mock 的依賴,而是對該 mock 的依賴,驗證了不該驗證的細節。

什麼情境該用哪一種,一個簡單的判斷順序

面對一個要頂替的依賴,可以照這個順序問自己:

  1. 這個依賴的回傳值,這次測試完全用不到嗎?——用 Dummy。
  2. 我只需要控制它回傳什麼,讓待測邏輯在特定輸入下執行嗎?——用 Stub。
  3. 我想驗證的是「這段邏輯真的照著依賴該有的行為運作」,而不是某個特定回傳值嗎?——考慮用 Fake(例如記憶體資料庫)。
  4. 我需要在測試跑完後檢查「這個依賴有沒有被呼叫、呼叫了幾次、帶了什麼參數」,但這個檢查是這次測試真正關心的事嗎?——用 Spy 或 Mock。

這個判斷順序的核心是:先問「這裡真正要驗證的是什麼」,再決定用哪一種替身,而不是反過來,先打開 mocking 框架的 API 文件,看它能做什麼就用什麼。

今日思考題

回想你手上一個用 AI 生成、裡面有 mock() 呼叫的測試:那個 mock() 後面接的 verify(),驗證的是這段程式碼真正該保證的行為,還是只是「這個方法有沒有被呼叫」這種跟業務結果無關的實作細節?

今日重點回顧

  • Dummy、Stub、Fake、Spy、Mock 五種 Test Double 各自回答不同的驗證問題,不是同一件事的五個名字
  • AI 傾向一律用 Mock 解決,是因為 mocking 框架把五種概念包進同一個 API 入口,AI 學到的是「有依賴就呼叫這支 API」而非先判斷情境
  • 該用 Stub 卻用 Mock 的後果:測試多驗證了不相干的呼叫細節,未來重構呼叫方式時會無端變紅
  • 判斷順序:先問「這裡真正要驗證的是什麼」,再決定用哪一種替身,不要讓框架的 API 反過來決定測試在驗證什麼

明日預告

明天要換一個角度,看一種更根本的循環打斷情境:AI 一次生成了完整實作跟完整測試,Red-Green-Refactor 三個階段全部被壓縮成一個動作,這件事本身會帶來什麼問題。


上一篇
Day 18:案例——100% 覆蓋率,但測試對重構完全沒有防護力
下一篇
Day 20:TDD 循環被打斷的真實情境——AI 一次生成了完整實作 + 完整測試
系列文
AI 時代的 TDD:讓 AI 寫 Code,但不要讓它決定品質 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言