「Test Double 不就是拿一個假的物件頂替真的依賴嗎?隨便用一種 mocking 框架生一個出來,測試能跑不就好了?」
Day 13 講過 AI 容易把不該 mock 的東西也 mock 掉。今天要講一個更細的問題:就算 AI 判斷對了「這個依賴確實該用測試替身頂替」,它選的替身種類也常常是錯的——而且錯的方式很一致:不管情境需要什麼,AI 幾乎永遠伸手去拿 mocking 框架裡最重的那個工具。
Gerard Meszaros 提出、後來被 Martin Fowler 在《Mocks Aren't Stubs》裡引用討論的五種測試替身分類,每一種其實是在回答一個不同的問題:
這五種替身不是同一件事的五個名字,是針對五種不同的驗證需求設計出來的五種工具。
問題出在 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 的依賴,驗證了不該驗證的細節。
面對一個要頂替的依賴,可以照這個順序問自己:
這個判斷順序的核心是:先問「這裡真正要驗證的是什麼」,再決定用哪一種替身,而不是反過來,先打開 mocking 框架的 API 文件,看它能做什麼就用什麼。
回想你手上一個用 AI 生成、裡面有 mock() 呼叫的測試:那個 mock() 後面接的 verify(),驗證的是這段程式碼真正該保證的行為,還是只是「這個方法有沒有被呼叫」這種跟業務結果無關的實作細節?
明天要換一個角度,看一種更根本的循環打斷情境:AI 一次生成了完整實作跟完整測試,Red-Green-Refactor 三個階段全部被壓縮成一個動作,這件事本身會帶來什麼問題。