# Day 23:TDD 對我來說最難的第一步 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2025-12-22 **URL:** https://paragraph.com/@gcake/day-23 ## Content 最小第一步與最小實作目前覺得最難的是:先用自然語言講清楚「這個函式要做什麼」之後,再把它拆成「可以實作的步驟」,最後才寫出對應的程式碼。 這一層「從需求句子 → 步驟化描述」就是 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,減少手刻重複。簡單的決策流程可以是:這個依賴介面小不小?小 → 先手刻。只要假資料、還是要檢查呼叫紀錄?只要假資料 → 手刻。要檢查呼叫方式 → 開始用 vi.fn / vi.spyOn。這個假物件是不是越寫越肥?是 → 重構為正式模組,或用 vi API 瘦身。7. 心法總結TDD 的「最小第一步」,就是先用自然語言寫清楚:「這個函式吃什麼、要做什麼、什麼時候丟錯」。再把那句話翻成像「小演算法」一樣的步驟化描述,最後才寫程式。控制流程可以從 while 的腦中模型開始想,再決定適合用 Promise 鏈還是 async/await。重構時,先把「職責」拆開(I/O / 控制流程 / 驗證邏輯),等每一層責任清楚了,while 或遞迴就只是風格選擇。練 test double 時,先手刻最小的 stub/fake,把流程跑通;需要檢查呼叫細節或假物件變肥時,再引入 vi.fn / vi.spyOn,一步一步加強。 ## 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