當待測程式碼依賴檔案系統、網路、時間這些你控制不了的東西,測試就會因為外部原因而失敗——即使邏輯是對的。測試替身(test double)就是那個受控的替代品:像 NASA 用模擬器訓練太空人,你用假的協作物件,把待測程式碼放進一個可控的環境裡。關鍵是分清兩種替身——stub 餵輸入、mock 驗互動。
🧠 Why It Matters
- 打破外部依賴:對檔案系統/時間的直接依賴是一種「妨礙測試的設計」。作法是找到依賴的介面、加一層間接、用可控替代品換掉真實作。
- 三種結果,三種測法:一個工作單元的最終結果只有三種——回傳值(value)、狀態變化(state)、呼叫別的物件(interaction)。前兩種用真實斷言,第三種才需要 mock。
- Stub 與 Mock 的關鍵差異:你不會對 stub 做斷言——它只是提供假輸入、讓測試跑得下去,斷言仍打在待測類別上。Mock 才是被斷言的對象——你驗證待測類別有沒有正確地呼叫它。一句話:stub 永遠不會讓測試失敗,mock 可以。
- 優先值/狀態測試:互動測試會讓測試更複雜、更脆弱、更難維護。只有當「呼叫另一個物件」本身就是最終結果(例如呼叫第三方 logger)時,才用 mock。
NOTE
灌溉系統的比喻:狀態測試是讓系統跑 12 小時、再去看土壤濕不濕——真實但昂貴;互動測試是在水管末端裝記錄器,只驗「有沒有在對的時間、放對的次數水」——不必真的種一棵樹。
⚖️ Stub vs. Mock
Stub — 餵假輸入,斷言打在待測類別上
// 待測:LogAnalyzer 依賴檔案系統判斷副檔名是否合法
// Stub 假裝成設定來源,回傳可控的值;我們斷言的是 analyzer 的「回傳值」
test("valid extension is accepted", () => {
const configStub = { isSupported: () => true }; // stub:只提供輸入
const analyzer = new LogAnalyzer(configStub);
const result = analyzer.isValidLogFileName("data.log");
expect(result).toBe(true); // ← 斷言打在待測類別,stub 不決定成敗
});Stub 只是輔助測試順利運行,本身不會讓測試 fail。
Mock — 驗證互動,斷言打在替身上
// 待測:當檔名不合法時,analyzer「必須呼叫 logger 記錄一次」
// 「有沒有呼叫 logger」本身就是最終結果 → 用 mock 驗互動
test("an invalid filename is logged", () => {
const loggerMock = { calls: [], log(msg) { this.calls.push(msg); } };
const analyzer = new LogAnalyzer({ isSupported: () => false }, loggerMock);
analyzer.isValidLogFileName("data.xxx");
expect(loggerMock.calls.length).toBe(1); // ← 斷言打在 mock 身上
});待測類別與 mock 之間的溝通被記錄下來,測試透過 mock 驗證互動是否正確。
WARNING
一個測試裡通常只該有一個 mock——因為 mock 代表「這次要驗證的那個互動」。其餘協作物件都用 stub。滿場都是 mock,等於把實作細節焊死在測試裡,稍一重構就整片變紅。
🔑 Takeaways
- 測試替身把不可控的外部依賴換成可控替代品,讓測試只驗邏輯。
- Stub 餵輸入、不被斷言;Mock 驗互動、被斷言。 Stub 不會讓測試失敗,mock 會。
- 優先用值/狀態測試;只有當「呼叫別的物件」就是結果時才用 mock。
- 一測一 mock,其餘用 stub,避免測試與實作過度耦合。
No notes yet — jot your takeaways or Q&A here.