# Day 22：測試替身 Test Doubles

By [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake) · 2025-12-19

---

今日讀物：《Clean Craftsmanship 無暇的程式碼 軟體工匠篇》（Chapter 3 進階 TDD）、《Test-Driven Development 學習手冊》（迅速翻完，沒有實作）

測試替身 Test Doubles
-----------------

[Test Double](https://www.martinfowler.com/bliki/TestDouble.html)

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，或引入額外的、真實情境不存在的 bug

### TDD 的不確定性原則

*   確定性越高，測試就越沒有彈性
    
*   測試彈性越高，確定性越低
    

### 測試的脆弱性

Spy 測試很脆弱，因為測試本身和實作行為高度耦合，演算法變更就會讓測試需要修正或重寫，Mock 亦同

這是 Uncle Bob 不喜歡模擬工具 mocking tool 的原因

Uncle Bob 個人喜歡彈性高一點，所以會選用值測試（配對輸入和輸出值）和屬性測試（使用一群輸入值確認條件不變）

也就是 Fowler 文章裡的「狀態驗證」

狀態驗證測試對實作細節相對不敏感；行為驗證測試表達力強但脆弱度高

這份取捨直接影響程式架構的設計過程，也就是解決問題的思考流程、實作使用者介面和商業邏輯的推導過程不同

---

*Originally published on [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/day-22)*
