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

用 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. 針對「多輪」情境(例如輸入四個英文名字)寫測試,確認物件陣列結構符合預期。

參考文件