# Day 24-25:用 Vitest + TDD 重構 Node.js CLI:從 dummy 回傳到呼叫 askWithValidator 的一小步 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2025-12-24 **URL:** https://paragraph.com/@gcake/day-24-25 ## Content 用 TDD 最小步實作 runCliQuestions,從 dummy 回傳一路推進到呼叫 askWithValidator,在紅燈 5 之後發現資料結構與責任可以拆分得更清楚,決定引入 askOneRound。這篇是 2025/12/23–24 兩天重構實作的過程記錄與整理,主要聚焦在:用 Vitest 的測試替身(vi.fn())實作 spy / mock。以 TDD 最小步驟實作 runCliQuestions。在紅燈 5 之後,如何從回傳結構看出設計可以拆分成「一輪問答」與「多輪收集」。模組職責設計:先想清楚每層在幹嘛延續前幾天的重構工作,目標是把 CLI 問答流程拆成幾個責任單一的模組,搭配「Parse, don't validate」原則與錯誤分層來設計:runCliSession (錯誤邊界) ↓ runCliQuestions (問題流程編排) ↓ askWithValidator (單一問題+重試) ← 已完成 ↓ validators (驗證規則) ← 已完成 validators:負責「純值」的檢查與轉換,成功回 { value },失敗丟 Error。askWithValidator:負責單一 CLI 問題的讀取與重試邏輯,處理「可預期的驗證錯誤」。runCliQuestions:今天的主角,負責「把多個問題依序問完,收集答案」。runCliSession:日後會作為錯誤邊界與 readline 生命週期管理。這篇專注在 runCliQuestions 的演化過程。Promise 快速複習:物件、狀態與高階函式在這次 CLI 重構裡,askWithValidator 和 runCliQuestions 都會變成 async 函式,背後其實就是在操作 Promise 物件。Promise 物件在做什麼?可以把 Promise 想成「描述一個非同步運算狀態與結果的物件」:狀態:pending / fulfilled / rejected,可以想成規格裡說的 [[PromiseState]]。結果:成功的值或失敗的 reason,可以想成 [[PromiseResult]]。這些欄位是「內部槽位」,JavaScript 本身不讓你直接讀寫,但你可以用 then / catch / finally 這些方法,根據當前狀態建立新的 Promise 並接續運算。Promise 語法:建構函式 + 高階函式最原始的 Promise 寫法長這樣:const p = new Promise((resolve, reject) => { setTimeout(() => { // 這裡決定「物件的最終狀態+結果」 resolve('OK'); // 或:reject(new Error('Oops')); }, 1000); }); 從語言層面拆解:Promise 本身是一個「建構函式」:被 new 呼叫時,會建立並初始化一個 Promise 物件。它的參數是一個函式(executor),因此同時也是「高階函式」。executor((resolve, reject) => { ... }):會在 new Promise(...) 時同步執行一次。拿到兩個函式參數 resolve / reject,在適當時機呼叫,更新 Promise 內部的狀態與結果。目前為止,已經看到三種函式角色:一般函式:可以被當成值使用,存變數、當參數、當回傳值,這是 JS 的一級函式特性。高階函式:接收函式作為參數,或回傳函式作為結果(滿足一個條件就算)。建構函式:可以用 new 呼叫、負責建立與初始化物件實例的函式,Promise 的原始語法是一個例子。把 callback 包成 Promise 的例子在 CLI 或 Node.js 環境中,常見的模式是把 callback-style API 包成 Promise:function readFilePromise(path) { return new Promise((resolve, reject) => { readFile(path, (error, result) => { if (error) { reject(error); return; } resolve(result); }); }); } 這裡的角色拆解:Promise:建構函式 + 高階函式(參數是 executor 函式)。executor:高階函式(拿到 resolve / reject 函式參數)。readFilePromise:回傳一個 Promise 物件;本身也算高階函式(回傳值是由函式建立的物件,可以在其他地方組合)。用 TDD 最小步實作 runCliQuestions接下來是這兩天實作 runCliQuestions 的 TDD 流程,刻意用「非常小的紅燈 / 綠燈」循環往前推。紅燈 1:只確認測試框架有跑起來第一步先寫一個超假的需求,確保 Vitest 可以執行 async 測試、import 沒問題:// util/runCliQuestions.test.js import { describe, it, expect, vi } from "vitest"; import { runCliQuestions } from "./runCliQuestions.js"; describe("runCliQuestions", () => { it("暫時實作:回傳 OK(第一步)", async () => { const questionConfigs = vi.fn(); const result = await runCliQuestions(questionConfigs); expect(result).toBe("OK"); }); }); // util/runCliQuestions.js export function runCliQuestions(questionConfigs){ return 'OK'; } 這個測試只是在確認:測試檔可以 import 被測模組。async 測試可以正常執行。紅燈 2:確認可以吃進 questionConfigs 並回傳開始讓 runCliQuestions 的回傳值跟參數有一點關係:it("第二步:暫時實作,先把 questionConfigs 原樣回傳", async () => { const questionConfigs = [{ key: 'age' }]; const result = await runCliQuestions(questionConfigs); expect(result).toEqual([{ key: 'age' }]); }); // util/runCliQuestions.js export function runCliQuestions(questionConfigs) { return questionConfigs; } 這時候還沒有實際「問問題」,只是先讓函式的介面雛形穩定下來。紅燈 3:單一問題 → 回傳一個固定欄位的物件開始往「答案結構」靠近,先硬編一個 dummy 值:it("第三步:單一問題,回傳 { key: 固定答案 } 結構", async () => { const questionConfigs = [ { key: 'age', prompt: '請輸入年齡:', // 之後才會用到 validator validator: () => {}, }, ]; const result = await runCliQuestions(questionConfigs); expect(result).toEqual({ age: 'dummy-answer' }); }); // util/runCliQuestions.js export function runCliQuestions(questionConfigs) { const answers = {}; const firstConfig = questionConfigs[0]; answers[firstConfig.key] = 'dummy-answer'; return answers; } 這個階段的目的只是:鎖定「一輪多欄位最終會是物件」這個事情。先用固定值,之後再一層一層替換成真實流程。紅燈 4:支援多個問題,先用 dummy 答案接著要讓 runCliQuestions 可以處理多個問題設定:// util/runCliQuestions.test.js import { describe, it, expect, vi } from "vitest"; import { runCliQuestions } from "./runCliQuestions.js"; describe("runCliQuestions", () => { it("問題流程編排", async () => { const questionConfigs = [{ key: 'age', prompt: '請輸入年齡:', validator: vi.fn() }, { key: 'name', prompt: '請輸入姓名:', validator: vi.fn() }]; const result = await runCliQuestions(questionConfigs); expect(result).toEqual([{age: 'dummy-answer'}, {name: 'dummy-answer'}]); }); }); // util/runCliQuestions.js export function runCliQuestions(questionConfigs){ const answers = []; const firstConfig = questionConfigs[0]; const secondConfig = questionConfigs[1]; answers[0] = {[firstConfig.key]: 'dummy-answer'}; answers[1] = {[secondConfig.key]: 'dummy-answer'}; return answers; } 過程中有出現一個宣告錯誤:answers[0] 和 answers[1] 還是 undefined,直接取屬性會噴 TypeError。 重點在於重新記得:陣列的每一格要先有值(例如物件),才能用 answers[0].xxx 這種方式賦值。// util/runCliQuestions.js(中途踩雷版本) export function runCliQuestions(questionConfigs){ const answers = []; const firstConfig = questionConfigs[0]; const secondConfig = questionConfigs[1]; answers[0][firstConfig.key] = 'dummy-answer'; answers[1][secondConfig.key] = 'dummy-answer'; return answers; } 把「一次問多個問題」重構成迴圈改成比較合理的實作(仍然用 dummy):// util/runCliQuestions.js export function runCliQuestions(questionConfigs){ const answers = []; questionConfigs.forEach((configs) => { answers.push({[configs.key]: 'dummy-answer'}) }) return answers; } // 未來要延伸到非同步的版本,改成 for...of export function runCliQuestions(questionConfigs){ const answers = []; for(const config of questionConfigs){ answers.push({[config.key]: 'dummy-answer'}) } return answers; } 這時:answers 的形狀是 [{ age: 'dummy-answer' }, { name: 'dummy-answer' }]。雖然還不是最終想要的資料結構,但已經先練習把「多題問題流程」抽象成陣列迴圈。紅燈 5:使用 mock 注入 askWithValidator,進入 async 階段這一步的目標是:讓 runCliQuestions 不再回傳 dummy,而是真的去呼叫一個「問問題+驗證」的函式。但在單元測試裡,不直接使用真實 askWithValidator,改用 Vitest mock 當 test double。測試:用 mock 驗證呼叫參數與結果組裝 // Mock askWithValidator 的行為 const mockAskWithValidator = vi .fn() .mockResolvedValueOnce(25) .mockResolvedValueOnce("Alice"); const result = await runCliQuestions(questionConfigs, mockAskWithValidator); // 驗證:有沒有正確呼叫 askWithValidator expect(mockAskWithValidator).toHaveBeenCalledTimes(2); expect(mockAskWithValidator).toHaveBeenNthCalledWith( 1, '請輸入年齡:', questionConfigs[0].validator ); expect(mockAskWithValidator).toHaveBeenNthCalledWith( 2, '請輸入姓名:', questionConfigs[1].validator ) // 驗證:回傳值是用 mock 結果組起來的 expect(result).toEqual([{ age: 25 }, { name: 'Alice' }]); }); }); 這裡重點是:透過 mockResolvedValueOnce 控制每次呼叫的非同步回傳值與順序。讓測試只關注明確的行為:呼叫幾次?用什麼參數?如何組成結果?實作:改為 async + await 模式// util/runCliQuestions.js export async function runCliQuestions(questionConfigs, askWithValidator){ const answers = []; for(const config of questionConfigs){ const answer = await askWithValidator(config.prompt, config.validator); answers.push({[config.key]: answer}) } return answers; } 這段程式碼達成:使用 for...of + await,確保每題依序詢問,而不是先把所有 Promise 丟出去再一起 Promise.all。runCliQuestions 對 askWithValidator 採用「依賴注入」方式(從參數傳入),讓單元測試可以輕易替換成 mock,不依賴真實 readline 或 while 重試的邏輯。從紅燈 5 回頭看設計:為什麼要引入 askOneRound?在紅燈 5 綠燈之後,回頭檢查目前的設計,發現兩個問題:問題一:回傳結構語意不清目前的回傳是:[{ age: 25 }, { name: 'Alice' }]看起來像「兩筆紀錄,每筆只有一個欄位」。真正語意卻是「同一個人的兩個欄位」。語意上更貼切的結構會是:{ age: 25, name: 'Alice' } 也就是「一輪問答結果 = 一個欄位完整的物件」。問題二:無法自然描述「多輪」的情境練習題裡有兩種常見模式:類型 A:一輪輸入、一輪計算(多數題目)。類型 B:同一組問題重複輸入多次(例如輸入多組英文名字)。如果要同時描述「一輪」和「多輪」,比較自然的設計是:一輪 → 物件:{ age, name }多輪 → 物件陣列:[{ age, name }, { age, name }, ...]。目前的 runCliQuestions 把「問題流程編排」和「一輪 / 多輪」混在一起,導致回傳結構無法同時清楚對應這兩種情境。設計演化目標:拆成兩層 API為了解決上面的兩個問題,下一步決定讓設計演化成:抽出一個只處理「一輪問答」的函式 askOneRound:輸入:questionConfigs + askWithValidator。輸出:單一物件 { age: 25, name: 'Alice' }。runCliQuestions 改為:負責決定要呼叫 askOneRound 幾輪。支援 rounds = 1 預設值。回傳物件陣列 [{...}, {...}, ...],適合「多輪多筆資料」。角色分工會變成:askOneRound:一次表單填寫 / 一個人的資料。runCliQuestions:多次表單填寫 / 多個人的資料,預設一次。這部分的實作會在下一篇從紅燈 6 開始記錄。這算變更需求嗎?還是 TDD 下正常的設計浮現?從 TDD 的角度來看,這次不是「功能需求被 PM 改」,而是:一開始只知道要「串多題 CLI」。經過紅燈 1–5 的實作,逐步看見真正的抽象模式是「一輪 vs 多輪」。最後決定讓程式設計對齊這個更抽象、語意更清晰的結構。這很符合 TDD 常提到的「設計隨測試浮現(emergent design)」: 不是一開始就設計好所有 API,而是透過測試與重構,讓設計慢慢長出來。git commit 節奏:怎麼配合這種 TDD 小步走?這次實作過程中,紅燈 1–5 都還沒刻意拆 commit。事後回頭看,可以給自己一個下次的 commit 準則:核心原則只在測試全綠時 commit紅燈時不要 commit。綠燈(包含重構後仍綠燈)時,才把這一小步的成果記錄下來。每個 commit 代表一個「可以說得出口的小故事」例如:feat: add basic runCliQuestions pipelinefeat: make runCliQuestions async and call askWithValidatorrefactor: extract askOneRound from runCliQuestions避免把不相關的改動塞在同一個 commit 裡。實務上的可能節奏假設這次從頭來過,可以這樣切:commit 1:建立最初的 dummy 版 runCliQuestions,回傳 "OK" 或 questionConfigs。message:feat: add initial runCliQuestions stubcommit 2:支援多題問題流程,回傳 dummy 結構(物件陣列)。message:feat: support multiple questions in runCliQuestionscommit 3(紅燈 5 完成):引入 mock 版 askWithValidator,讓 runCliQuestions 使用 async/await 呼叫它並組裝真實答案。message:feat: make runCliQuestions async and call askWithValidatorcommit 4(未來抽出 askOneRound):把「一輪問答」抽出成 askOneRound,runCliQuestions 加上 rounds 參數。message:refactor: extract askOneRound and add rounds to runCliQuestions整體原則是: 每一個「紅燈 → 綠燈 →(重構)」的完整小循環,或每 2–3 個非常小的循環,就值得打一個 commit。今天這一輪的學習重點Promise 不只是「語法糖」,而是「用固定欄位描述非同步狀態與結果的物件」,搭配高階建構函式與 chaining API(then / catch / finally)來組合非同步流程。runCliQuestions 的 TDD 實作可以拆得很細:從 dummy 回傳、到回傳 dummy 結構、到多題流程、再到 async + mock 注入 askWithValidator。用 Vitest 的 vi.fn().mockResolvedValueOnce(...) 可以很精確控制非同步函式每次呼叫的回傳值,適合測 CLI 這種「多次問答的流程」。在紅燈 5 綠燈後,回頭檢查回傳結構與責任邊界,可以發現更好的抽象(askOneRound + runCliQuestions(rounds=1)),這是 TDD 下非常正常、也非常有價值的設計浮現過程。git commit 不需要每一個紅燈都打一個,但可以在「每一個完整小故事」綠燈後 commit,讓歷史清楚又安全。下一步下一篇會從「紅燈 6」開始,實作:askOneRound:單輪多題 → 回傳單一物件。把 runCliQuestions 改成呼叫 askOneRound,並加上 rounds = 1 預設參數。針對「多輪」情境(例如輸入四個英文名字)寫測試,確認物件陣列結構符合預期。參考文件Promise - JavaScript | MDNJavaScript Promise 全介紹 | 卡斯伯BlogMocking | Guide - VitestMocks - VitestRed, Green, Refactor - CodecademyThe Three Laws of TDD-從紅燈變綠燈的過程The Cycles of TDD - Clean Coder Blog - Uncle BobArray - JavaScript | MDNData Structures: Objects and Arrays - Eloquent JavaScriptgit commit best practices - Stack Overflow ## 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