# Day 22:測試替身 Test Doubles **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2025-12-19 **URL:** https://paragraph.com/@gcake/day-22 ## Content 今日讀物:《Clean Craftsmanship 無暇的程式碼 軟體工匠篇》(Chapter 3 進階 TDD)、《Test-Driven Development 學習手冊》(迅速翻完,沒有實作)測試替身 Test DoublesTest Double 2000 年 Gerard Meszaros 的著作《xUnit Test Patterns: Refractoring Test Code》定義了非正式模擬物件 (Mock Object) 的五種類型,統稱這些物件為「測試替身 Test Doubles」:執行測試時,測試替身代替了另一個物件Dummy 虛擬物件Stub 虛擬常式或擬態物件Spy 情蒐或間諜物件Mock 模擬物件Fake 假物件 Test Double | +-----------------------+------------------------+ | | 行為鏈:能力遞增 簡化真實實作 | | 只佔位 ──► Dummy | (不驗證,不被使用) | | | 給假資料 ──► Stub | (控制間接輸入,做 state verification) | | | 記錄互動 ──► Spy | (記錄間接輸出,事後 assert 行為) | | | 檢查互動是否符合預期 ──► Mock Fake Object (事先定義期望,行為驗證 behavior verification) (有邏輯的簡化實作)使用時機Dummy 虛擬物件:只佔位置的參數被測函式需要輸入物件但不需要對物件做任何邏輯處理的時候,Dummy 此時實作介面 Implement interface 但什麼都不做,以避免在測試程式中宣告一個複雜的物件,通常不會真的被 SUT (System Under Test) 使用到,就是在輸入參數的地方佔位;若要回傳通常會是 null 或 0,多數情況不會被讀取 例:登入驗證函式必須在接收使用者名稱和密碼輸入值後才啟動,Dummy 就可以直接設定成一組空的使用者名稱和密碼 Uncle Bob 個人不是很喜歡,因為會有路徑引用和依賴鏈問題// auth-service.js export const login = (username, password, logger) => { // logger 會被呼叫,但這個測試用不到 if (!username || !password) { logger.error('missing credentials'); return false; } return true; }; // auth-service.test.js import { describe, it, expect } from 'vitest'; import { login } from './auth-service.js'; describe('login', () => { it('returns false when username is empty', () => { const dummyLogger = { // 實作介面,但什麼都不做 error: () => {}, }; // Dummy const result = login('', 'password', dummyLogger); expect(result).toBe(false); }); });dummyLogger 實作了需要的介面 (error),但測試完全不會對它做任何驗證,就是純佔位。Stub 虛擬常式或擬態物件:提供測試專用值Stub 是一種 Dummy,但可以回傳一些驅動被測函式繼續執行邏輯流程的值 test-specific value (或稱測試專用值),用 SUT 的狀態或回傳值驗證 回傳的是「預先寫死」的值,不太關心呼叫了幾次、順序如何 例:回傳 true/ false,確認登入驗證函式接收合法/非法使用者名稱和密碼輸入值後是否按照設計流程執行// user-repo.js export class UserRepository { findByUsername(username) { throw new Error('not implemented'); } } // auth-service.js export const createAuthService = (userRepository) => ({ async login(username, password) { const user = await userRepository.findByUsername(username); if (!user) return false; return user.password === password; }, }); // auth-service.stub.test.js import { describe, it, expect } from 'vitest'; import { createAuthService } from './auth-service.js'; describe('AuthService with Stub', () => { it('returns true when username and password match', async () => { const userRepoStub = { // 只回傳測試專用值 async findByUsername(username) { if (username === 'alice') { return { username: 'alice', password: 'secret' }; } return null; }, }; // Stub const auth = createAuthService(userRepoStub); const result = await auth.login('alice', 'secret'); expect(result).toBe(true); }); });userRepoStub 不記錄誰呼叫它、也沒有期望被呼叫幾次,只單純「給假資料」,這就是 Stub。Spy 情蒐或間諜物件:記錄互動,驗證在測試裡做Spy 是一種 Stub,回傳測試專用值使被測函式通過期望路徑,同時會「紀錄被怎麼呼叫」(包含呼叫的歷史、呼叫時使用的參數),之後由測試來檢查紀錄與斷言// email-service.js export const notifyLogin = async (emailClient, user) => { await emailClient.send({ to: user.email, subject: 'Login detected', }); }; // email-service.spy.test.js import { describe, it, expect } from 'vitest'; import { notifyLogin } from './email-service.js'; describe('notifyLogin with Spy', () => { it('sends an email to user email', async () => { const calls = []; const emailClientSpy = { async send(message) { calls.push(message); // 也可以在這裡回傳測試專用值 return { ok: true }; }, }; // Spy:同時是 Stub + 記錄呼叫 const user = { email: 'user@example.com' }; await notifyLogin(emailClientSpy, user); // 驗證「怎麼被呼叫」在測試裡寫 expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ to: 'user@example.com', subject: 'Login detected', }); }); });emailClientSpy 自己不會「判斷測試成敗」,只是把資訊記下來,由測試 expect 來驗證,這就是 Spy 的典型用法。如果使用 Vitest 的 vi.fn() ,不對它做任何期望檢查,只讀 mock.calls 來 assert,其角色偏向 Spy;一旦搭配 toHaveBeenCalledWith 等 API,就是 Mock。Mock 模擬物件:事先設定期望,由框架驗證Mock 是一種 Spy,回傳測試專用值使被測函式通過期望路徑,同時會記錄被怎麽呼叫,根據事先設定的預期結果判斷測試成功或失敗,屬於行為驗證(behavior verification) Spy 和 Mock 都用來驗證間接輸出,只是一個是「把記錄交給測試自行斷言」,一個是「把期望預先設定好,由框架檢查」。 Uncle Bob 個人不太喜歡,因為 Mock 讓 Spy 的行為和測試驗證整個流程緊密耦合,而不是他個人偏好的直接陳述驗證內容// payment-service.js export const chargeOrder = async (paymentGateway, order) => { const amount = order.items.reduce((sum, item) => sum + item.price, 0); return paymentGateway.charge(order.userId, amount); }; // payment-service.mock.test.js import { describe, it, expect, vi } from 'vitest'; import { chargeOrder } from './payment-service.js'; describe('chargeOrder with Mock', () => { it('charges total amount to user', async () => { const paymentGatewayMock = { charge: vi.fn().mockResolvedValue({ success: true }), }; // Mock function:有預期回傳、也會被驗證行為 const order = { userId: 'u1', items: [ { price: 10 }, { price: 20 }, ], }; const result = await chargeOrder(paymentGatewayMock, order); expect(result).toEqual({ success: true }); // 行為驗證:這裡才是「Mock 的重點」 expect(paymentGatewayMock.charge).toHaveBeenCalledTimes(1); expect(paymentGatewayMock.charge).toHaveBeenCalledWith('u1', 30); }); });嚴格依照 Fowler 的語意,這種「先設定 mockResolvedValue,再 toHaveBeenCalledWith」的用法,就是典型 Mock:同時驗證狀態與互動Vitest 的 mock function vi,fn() 可以同時扮演 Stub + Spy + Mock,差別在你只設定回傳值、讀 mock.calls 斷言,還是再搭配 toHaveBeenCalledWith 等行為驗證 API。Fake 假物件:可運作但簡化的實作Fake 不是 Dummy, Stub, Spy 或 Mock,它是一種模擬器 simulator,是「真正可工作的簡化實作」,重點在於資料結構和邏輯是存在的,只是比正式實作簡單/走捷徑,例如 in‑memory DB、in‑memory mail sender。 Uncle Bob 很少用 Fake,因為 Fake 會隨著系統複雜度提高、測試條件變多而變大變複雜// real-user-repo.js export class RealUserRepository { constructor(db) { this.db = db; } async findByUsername(username) { // 真實情況:打 DB 或 API return this.db.query('SELECT * FROM users WHERE username = ?', [username]); } } // in-memory-user-repo.fake.js export class InMemoryUserRepository { // Fake:可運作、但只存在記憶體 constructor(initialUsers = []) { this.users = new Map(initialUsers.map((u) => [u.username, u])); } async findByUsername(username) { return this.users.get(username) ?? null; } async save(user) { this.users.set(user.username, user); } } // 使用 Fake 的測試 import { describe, it, expect } from 'vitest'; import { createAuthService } from './auth-service.js'; import { InMemoryUserRepository } from './in-memory-user-repo.fake.js'; describe('AuthService with Fake repository', () => { it('logs in with a real-like repo in memory', async () => { const userRepoFake = new InMemoryUserRepository([ { username: 'alice', password: 'secret' }, ]); // Fake const auth = createAuthService(userRepoFake); const result = await auth.login('alice', 'secret'); expect(result).toBe(true); }); }); InMemoryUserRepository 有「狀態」與「邏輯」,甚至有 save,已經相當接近真實實作,但因為沒有交易、沒有真正的 I/O,所以是典型 Fake。風險可能無法完全複製真實依賴關係,無意間掩蓋了真實情境不明顯的副作用 bug,或引入額外的、真實情境不存在的 bugTDD 的不確定性原則確定性越高,測試就越沒有彈性測試彈性越高,確定性越低測試的脆弱性 Spy 測試很脆弱,因為測試本身和實作行為高度耦合,演算法變更就會讓測試需要修正或重寫,Mock 亦同 這是 Uncle Bob 不喜歡模擬工具 mocking tool 的原因 Uncle Bob 個人喜歡彈性高一點,所以會選用值測試(配對輸入和輸出值)和屬性測試(使用一群輸入值確認條件不變) 也就是 Fowler 文章裡的「狀態驗證」 狀態驗證測試對實作細節相對不敏感;行為驗證測試表達力強但脆弱度高 這份取捨直接影響程式架構的設計過程,也就是解決問題的思考流程、實作使用者介面和商業邏輯的推導過程不同 ## Publication Information - [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/): Publication homepage - [All Posts](https://paragraph.com/@gcake/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@gcake): Subscribe to updates