# Day 23：TDD 對我來說最難的第一步

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

---

最小第一步與最小實作
==========

目前覺得最難的是：**先用自然語言講清楚「這個函式要做什麼」之後，再把它拆成「可以實作的步驟」，最後才寫出對應的程式碼**。  
這一層「從需求句子 → 步驟化描述」就是 TDD 對我來說最卡的一關。

下面把這次重構 CLI 驗證流程時的幾個關鍵觀念，整理成「心法＋概念程式碼」的形式。

延續 [Day 21](https://paragraph.com/@gcake/day-20-21) 與 [Day 22](https://paragraph.com/@gcake/day-22 ) 的內容

* * *

1\. 先寫出「一顆函式」的語言規格
------------------

以「組合多個驗證器」這個問題為例，需求可以用一句話講完：

> 給我一串 validator 函式，照順序執行，每一個都吃前一個的結果，  
> 任一個丟錯就中止，最後回最後那一步的輸出。

把這句話轉成比較「程式化」的描述：

*   輸入：validators 陣列，每個長得像 `f(value) -> { value } | throws`。
    
*   回傳：一個新函式 `g(input)`。
    
*   行為：
    
    *   設 `current = input`。
        
    *   對每個 validator：
        
        *   執行 `result = validator(current)`。
            
        *   更新 `current = result.value`。
            
    *   全部跑完後回 `{ value: current }`。
        
    *   任一 validator 丟錯 → 不繼續後面，錯誤直接往外拋。
        

用概念化的程式碼來表示，就是：

    // composeValidators 概念版
    composeValidators([f1, f2, f3]) 回傳 g
    
    g(x):
      current = x
      for f in [f1, f2, f3]:
        result = f(current)       // { value: ... } | throws
        current = result.value
      回傳 { value: current }     // 最後一個 value 包成物件
    
    

TDD 的第一步，就是先把這樣的「語言規格」寫進測試描述裡，再寫一個剛好讓測試變綠的實作。

* * *

2\. 先讓「一次成功」的路徑綠燈
-----------------

以 `composeValidators` 的最小測試來看，可以先只描述「一條快樂路徑」：

*   給兩個小小的 validator：
    

    v1(x) => { value: x + 1 }
    v2(x) => { value: x * 2 }
    

*   組成 pipeline 後，希望：
    

    composed(3) === { value: 8 } // (3 + 1) * 2
    

對應到概念程式碼就是：

    const composed = composeValidators([v1, v2])
    const result = composed(3)
    // 預期 result 是 { value: 8 }
    

只要讓這個測試過，最小實作大概長這樣就夠了：

    composeValidators(validators):
      回傳函式 g(value):
        current = value
        對每個 validate in validators:
          result = validate(current)  // { value: ... }
          current = result.value
        回傳 { value: current }
    
    

這時候還**不急著**處理錯誤、中止、各種邊界，只要讓「正常情境」走得通即可。

* * *

3\. 再用第二個測試逼出「中途錯誤就停」
---------------------

下一步再加一個測試，專門描述「如果中途有驗證失敗」：

*   排三個 validator：`v1`, `v2`, `v3`。
    
*   其中 `v2` 會丟錯：
    

    v1(x): 紀錄 'v1'，回 { value: x + 1 }
    v2(x): 紀錄 'v2'，丟 Error('v2 error')
    v3(x): 紀錄 'v3'，回 { value: x * 2 }
    

*   期待行為：
    

    composed(3) 會丟 'v2 error'
    calls === ['v1', 'v2'] // v3 不應該被叫到
    

這裡不用在 `composeValidators` 裡加任何 try/catch，相反地：

*   只要**不要**吃掉錯誤：讓 `v2` 丟的錯照原樣往外跑。
    
*   for 迴圈自然就會中止，不會跑到 `v3`。
    

也就是說，這個測試只是確認：「只要不在 compose 裡處理錯誤，中止的行為就自然而然會出現」，不需要特別加額外邏輯。

* * *

4\. 用「實戰 config」測試 compose 設計是否合理
---------------------------------

當 `composeValidators` 的基本行為穩定之後，就可以寫一個貼近實際 CLI 的測試：

*   組出一條真正會在 CLI 用到的驗證 pipeline，例如「大於等於 4 的偶數」：
    

    const validateEvenNum = composeValidators([
    ensureIntegerString, // ' 6 ' → { value: '6' }
    toInt, // '6' → { value: 6 }
    (n) => isEvenNum(n, 4), // 6 → { value: 6 } 或丟錯
    ])
    

*   驗證三件事：
    

    validateEvenNum(' 6 ') → { value: 6 }
    validateEvenNum('abc') 丟錯
    validateEvenNum('3') 丟錯
    

這個測試的目的不是「再檢查一次 validators 行為」，而是確認：

*   `composeValidators` 的介面設計（吃純值 validator、回 `{ value }`）能支撐實際 CLI 的使用情境。
    
*   真正 `validators.js` 裡的實作跟 compose 的設計不打架，之後 refactor validators，也有一個小整合測試可以「兜住」。
    

* * *

5\. 控制流程的核心：從 while 到 Promise，再到 async/await
--------------------------------------------

在「問問題＋驗證，錯了要重問」這個場景裡，本質上的控制流程其實非常簡單：

    // 原先 while loop 的邏輯（同步版腦中模型）
    while (true) {
    詢問;
    if (成功) return 值;
    else 印錯再來;
    }
    

只是 CLI 世界裡的「詢問」是非同步 (`rl.question`)，不能真的寫成同步 while，就會演變出不同寫法。

### 5.1 改寫成 Promise + 遞迴

先把「問一次」抽成 `questionOnce`（回 Promise），再在外層寫一個 loop 函式：

    const loop = () => {
    問一次()
    .then(驗證成功就回值)
    .catch(印錯後 return loop()); // 下一圈
    };
    
    return loop(); // 啟動第一圈
    

語意對照：

*   `then` 裡：對應「if (成功) return 值」。
    
*   `catch` 裡的 `return loop()`：對應「else 印錯再來」。
    
*   外層的 `return loop()`：對應「從第一次提問開始」。
    

### 5.2 再展開成 async/await + while

如果已經把 I/O 抽出去（有了 `questionOnce`），在一個**職責單一**的小函式裡，用 async/await 寫回「看起來像同步 while」的東西，會更好讀：

    async function askWithValidator(詢問一次, 驗證器) {
      while (true) {
        const raw = await 詢問一次(); // 問一次（Promise 版 readline）
        try {
          const value = 驗證器(raw);  // 同步驗證：成功回值，失敗 throw
          return value;                // 成功 → 結束整個函式（等價 resolve 這個值）
        } catch (error) {
          console.log('驗證器錯誤訊息：', error.message);
          // 失敗 → 不 return，while 進入下一圈，再 await 詢問一次()
        }
      }
    }
    
    

可以直接對應回最一開始那個同步 while 的腦中模型：

*   `while (true)`：一直問。
    
*   `await 詢問一次()`：每圈的「詢問」。
    
*   `try` 裡 `return value`：成功就跳出整個流程。
    
*   `catch` 裡印錯不 return：失敗就回到 while 頭再來一次。
    

* * *

6\. 測試替身：先手刻 stub / fake，再考慮 vi API
-----------------------------------

今天在練這些模組時，測試裡用到的替身都是「手刻的小型假物件／函式」，還沒有用到 Vitest 的 `vi.fn` / `vi.spyOn`。

### 6.1 什麼情況適合手刻 stub/fake？

*   依賴的介面很小、行為很簡單：
    
    *   像 `rl.question` 只需一個方法。
        
*   只需要「回假資料」，不太關心呼叫紀錄：
    
    *   例如 fake readline：
        
            const calls = [];
            const fakeRl = {
              question: (prompt, cb) => {
                calls.push(prompt);
                cb('user input');
              },
            };
            
        
    *   或控制輸入序列：
        
            const userInputs = ['abc', '6'];
            let callIndex = 0;
            const rl = {
              question: (prompt, cb) => {
                const userInput = userInputs[callIndex];
                callIndex += 1;
                cb(userInput);
              },
            };
            
        
*   好處：
    
    *   型別與介面一眼就懂。
        
    *   不用先學 mock API，把心力放在「函式規格與流程」。
        
    *   假物件很小，不會變成又大又難維護的 Fake 系統。
        

### 6.2 什麼時候該導入 `vi.fn` / `vi.spyOn`？

*   當開始需要注意「怎麼被叫」而不只是「回什麼」時：
    
    *   需要檢查有沒有被叫、被叫幾次、參數是什麼。
        
    *   例如：
        
        *   `runCliSession` 要確認 `createRl` 被叫一次、`rl.close` 不論成功或失敗都會被呼叫。
            
        *   `runCliQuestions` 要確認 `askWithValidator` 有依序被呼叫過每個題目。
            
*   當某個 fake / stub 開始在多個測試中複用、越寫越肥時：
    
    *   可以考慮用 `vi.fn` 做一個工廠函式，或用 `vi.spyOn` 在現有模組上掛 spy，減少手刻重複。
        

簡單的決策流程可以是：

1.  這個依賴介面小不小？
    
    *   小 → 先手刻。
        
2.  只要假資料、還是要檢查呼叫紀錄？
    
    *   只要假資料 → 手刻。
        
    *   要檢查呼叫方式 → 開始用 `vi.fn` / `vi.spyOn`。
        
3.  這個假物件是不是越寫越肥？
    
    *   是 → 重構為正式模組，或用 vi API 瘦身。
        

* * *

7\. 心法總結
--------

*   TDD 的「最小第一步」，就是先用自然語言寫清楚：「這個函式吃什麼、要做什麼、什麼時候丟錯」。
    
*   再把那句話翻成像「小演算法」一樣的步驟化描述，最後才寫程式。
    
*   控制流程可以從 while 的腦中模型開始想，再決定適合用 Promise 鏈還是 async/await。
    
*   重構時，先把「職責」拆開（I/O / 控制流程 / 驗證邏輯），等每一層責任清楚了，while 或遞迴就只是風格選擇。
    
*   練 test double 時，先手刻最小的 stub/fake，把流程跑通；需要檢查呼叫細節或假物件變肥時，再引入 `vi.fn` / `vi.spyOn`，一步一步加強。

---

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