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

最小第一步與最小實作

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

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

延續 Day 21 與 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,一步一步加強。