# Day 24-25：用 Vitest + TDD 重構 Node.js CLI：從 dummy 回傳到呼叫 askWithValidator 的一小步

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

---

> 用 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
---------------

為了解決上面的兩個問題，下一步決定讓設計演化成：

1.  抽出一個只處理「一輪問答」的函式 `askOneRound`：
    
    *   輸入：`questionConfigs` + `askWithValidator`。
        
    *   輸出：單一物件 `{ age: 25, name: 'Alice' }`。
        
2.  `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 準則：

### 核心原則

1.  **只在測試全綠時 commit**
    
    *   紅燈時不要 commit。
        
    *   綠燈（包含重構後仍綠燈）時，才把這一小步的成果記錄下來。
        
2.  **每個 commit 代表一個「可以說得出口的小故事」**
    
    *   例如：
        
        *   `feat: add basic runCliQuestions pipeline`
            
        *   `feat: make runCliQuestions async and call askWithValidator`
            
        *   `refactor: extract askOneRound from runCliQuestions`
            
    *   避免把不相關的改動塞在同一個 commit 裡。
        

### 實務上的可能節奏

假設這次從頭來過，可以這樣切：

*   commit 1：
    
    *   建立最初的 dummy 版 `runCliQuestions`，回傳 `"OK"` 或 `questionConfigs`。
        
    *   message：`feat: add initial runCliQuestions stub`
        
*   commit 2：
    
    *   支援多題問題流程，回傳 dummy 結構（物件陣列）。
        
    *   message：`feat: support multiple questions in runCliQuestions`
        
*   commit 3（紅燈 5 完成）：
    
    *   引入 mock 版 `askWithValidator`，讓 `runCliQuestions` 使用 async/await 呼叫它並組裝真實答案。
        
    *   message：`feat: make runCliQuestions async and call askWithValidator`
        
*   commit 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」開始，實作：

1.  `askOneRound`：單輪多題 → 回傳單一物件。
    
2.  把 `runCliQuestions` 改成呼叫 `askOneRound`，並加上 `rounds = 1` 預設參數。
    
3.  針對「多輪」情境（例如輸入四個英文名字）寫測試，確認物件陣列結構符合預期。
    

參考文件
----

*   [Promise - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)
    
*   [JavaScript Promise 全介紹 | 卡斯伯Blog](https://www.casper.tw/development/2020/02/16/all-new-promise/)
    
*   [Mocking | Guide - Vitest](https://vitest.dev/guide/mocking)
    
*   [Mocks - Vitest](https://vitest.dev/api/mock)
    
*   [Red, Green, Refactor - Codecademy](https://www.codecademy.com/article/tdd-red-green-refactor)
    
*   [The Three Laws of TDD－從紅燈變綠燈的過程](https://tdd.best/blog/the-three-laws-of-tdd/)
    
*   [The Cycles of TDD - Clean Coder Blog - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2014/12/17/TheCyclesOfTDD.html)
    
*   [Array - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)
    
*   [Data Structures: Objects and Arrays - Eloquent JavaScript](https://eloquentjavascript.net/04_data.html)
    
*   [git commit best practices - Stack Overflow](https://stackoverflow.com/questions/6543913/git-commit-best-practices)

---

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