Day 26:實作 askOneRound + 重構 runCliQuestions;邊界測試/ 單元測試/ 整合測試

實作 askOneRound(單輪問答)+ 重構 runCliQuestions 支援 rounds 參數,完成多輪輸入的設計。確認邊界測試、單元測試、整合測試的設計方式和考量。

前情提要

在上一篇筆記(2025/12/24)中,完成了 runCliQuestions 紅燈 5 的實作,並發現回傳結構 [{age: 25}, {name: 'Alice'}] 語意不清。

決定拆分成兩層 API:

  • askOneRound:一輪問答 → 單一物件

  • runCliQuestions:多輪收集 → 物件陣列(預設 1 輪)

本篇記錄實作過程。


接續昨天的 runCliQuestions 重構

紅燈 6:runCliQuestions 改為回傳單一物件以符合語意

// util/runCliQuestions.test.js
import { describe, it, expect, vi } from "vitest";
import { runCliQuestions } from "./runCliQuestions.js";

describe("runCliQuestions", () => {
  it("紅燈 6:呼叫 askWithValidator 的回傳值組成單一物件", async () => {
    const questionConfigs = [
      {
        key: "age",
        prompt: "請輸入年齡:",
        validator: vi.fn(),
      },
      {
        key: "name",
        prompt: "請輸入姓名:",
        validator: vi.fn(),
      },
    ];

    // 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
    );

    expect(result).toEqual({ age: 25, name: "Alice" });
  });
});

// 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[config.key]= answer;
    }

    return answers;
}

紅燈 7:askOneRound 的第一個測試

// util/runCliQuestions.test.js
import { describe, it, expect, vi } from "vitest";
import { askOneRound, runCliQuestions } from "./runCliQuestions.js";

describe("askOneRound", () => {
  it("紅燈 7:抽出 askOneRound (原來的 runCliQuestions 更名)", async () => {
    const questionConfigs = [
      {
        key: "age",
        prompt: "請輸入年齡:",
        validator: vi.fn(),
      },
      {
        key: "name",
        prompt: "請輸入姓名:",
        validator: vi.fn(),
      },
    ];

    // Mock askWithValidator 的行為
    const mockAskWithValidator = vi
      .fn()
      .mockResolvedValueOnce(25)
      .mockResolvedValueOnce("Alice");

    const result = await askOneRound(questionConfigs, mockAskWithValidator);

    // 驗證:有沒有正確呼叫 askWithValidator
    expect(mockAskWithValidator).toHaveBeenCalledTimes(2);
    expect(mockAskWithValidator).toHaveBeenNthCalledWith(
      1,
      "請輸入年齡:",
      questionConfigs[0].validator
    );
    expect(mockAskWithValidator).toHaveBeenNthCalledWith(
      2,
      "請輸入姓名:",
      questionConfigs[1].validator
    );

    expect(result).toEqual({ age: 25, name: "Alice" });
  });
});

// util/runCliQuestions.js
export async function askOneRound(questionConfigs, askWithValidator){
    const answers = {};

    for(const config of questionConfigs){
        const answer = await askWithValidator(config.prompt, config.validator);
        answers[config.key]= answer;
    }

    return answers;
}

紅燈 8:定義 runCliQuestions

describe("runCliQuestions", () => {
  it("紅燈 8:定義 runCliQuestions", async () => {
    const questionConfigs = [
      {
        key: "age",
        prompt: "請輸入年齡:",
        validator: vi.fn(),
      },
      {
        key: "name",
        prompt: "請輸入姓名:",
        validator: vi.fn(),
      },
    ];

    // Mock askWithValidator 的行為
    const mockAskWithValidator = vi
      .fn()
      .mockResolvedValueOnce(25)
      .mockResolvedValueOnce("Alice");

    const results = await runCliQuestions(questionConfigs, mockAskWithValidator);

    expect(results).toEqual('ok')
  })
})

export async function runCliQuestions(questionConfigs, askWithValidator, rounds = 1){
    return 'ok'
}

紅燈 9:呼叫 askOneRound 一次,回傳含物件的陣列

describe("runCliQuestions", () => {
  it("紅燈 9:呼叫 askOneRound 一次,回傳含物件的陣列", async () => {
    const questionConfigs = [
      {
        key: "age",
        prompt: "請輸入年齡:",
        validator: vi.fn(),
      },
      {
        key: "name",
        prompt: "請輸入姓名:",
        validator: vi.fn(),
      },
    ];


    // Mock askWithValidator 的行為
    const mockAskWithValidator = vi
      .fn()
      .mockResolvedValueOnce(25)
      .mockResolvedValueOnce("Alice");


    const results = await runCliQuestions(questionConfigs, mockAskWithValidator);


    expect(results).toEqual([{ age: 25, name: "Alice" }])
  })
})

export async function runCliQuestions(questionConfigs, askWithValidator, rounds = 1){
    const oneRoundResult = await askOneRound(questionConfigs, askWithValidator);
    return [oneRoundResult]
}

紅燈 10:呼叫 askOneRound 兩次,回傳含物件的陣列

// 用工廠函式建立獨立測試資料
function createBaseQuestionConfigs() {
  return [
    {
      key: 'age',
      prompt: '請輸入年齡:',
      validator: vi.fn(),
    },
    {
      key: 'name',
      prompt: '請輸入姓名:',
      validator: vi.fn(),
    },
  ];
}

describe("runCliQuestions", () => {
  it("紅燈 10:呼叫 askOneRound 兩次,回傳含物件的陣列", async () => {
    const questionConfigs = createBaseQuestionConfigs();

    // Mock askWithValidator 的行為
    const mockAskWithValidator = vi
      .fn()
      // 第一輪
      .mockResolvedValueOnce(25)
      .mockResolvedValueOnce("Alice")
      // 第二輪
      .mockResolvedValueOnce(30)
      .mockResolvedValueOnce("Bob");

    const results = await runCliQuestions(
      questionConfigs,
      mockAskWithValidator,
      2
    );

    expect(mockAskWithValidator).toHaveBeenCalledTimes(4);
    expect(results).toEqual([{ age: 25, name: "Alice" }, { age: 30, name: "Bob" },]);
  });
});

export async function runCliQuestions(
  questionConfigs,
  askWithValidator,
  rounds = 1
) {
  const result = [];
  
  for (let round = 0; round < rounds; round++) {
    const oneRoundResult = await askOneRound(questionConfigs, askWithValidator);
    result.push(oneRoundResult);
  }

  return result;
}

到這邊完成 Happy path 測試和模組化函式實作完成

「邊界/防退化」測試的思考

找到邊界條件的定義

可以試著用一句話描述這個函式的行為,再從這句話裡圈出「最小」「最大」「例外」幾個關鍵詞,把「允許的極端情況」挑出來,當成候選的邊界條件。 像 rounds 不能是負數、不能是字串這種「輸入是否合法」,比較適合在更外層(例如 CLI 解析或型別系統)處理,不一定要在 runCliQuestions 這層當邊界條件測。

以 runCliQuestions 為例: 「在給定一組問題設定 questionConfigs 與 askWithValidator 的前提下,執行 rounds 輪問答,每輪使用同一組題目,並依順序回傳每輪的答案物件陣列。」

從這句話可以挖出幾個邊界:

  • 「每輪使用同一組題目」→ questionConfigs 空/多題/一題,都屬於這層的語意範圍。

  • 「執行 rounds 輪」→ rounds 為 0 時到底算不算「執行」,要不要定義成「回傳空陣列」。

  • 「依順序回傳」→ 多輪時順序錯亂就是邊界錯誤。

挑重要的邊界條件寫測試,不用全部寫

挑那些「很接近這個函式核心責任、出錯代價會很痛」的邊界,給它一個獨立的小紅燈。

runCliQuestions 可能會有幾個邊界條件:

  • questionConfigs :很接近「問答流程與結果結構」的核心責任。如果是空陣列時,一輪結果是不是 {},多輪是不是 [{}, {}]。 - rounds = 0 :屬於「這一層要不要允許、允許就定義行為」的設計點。視設計決定是否支援,不支援就由上層擋掉。定義可以是回傳空陣列[](或自行定義其他形式報錯)。

  • askOneRound 當 askWithValidator 拋錯時,錯誤會直接往外拋,不被吞掉(對齊之後的錯誤邊界設計):屬於重要的行為保證。

單元測試與整合測試的選擇考量(以目前 CLI 專案為例)

定義差異

  • 單元測試:針對單一函式或模組,隔離其他依賴(用 mock / stub 替身),驗證「這個單元本身的邏輯是否正確」。

  • 整合測試:讓多個模組一起運作,驗證「這些模組之間的協作與資料傳遞是否如預期」,通常會接觸真實或接近真實的依賴(例如檔案系統、資料庫、外部 API)。

目前 CLI 驗證流程的分層

整體設計:

runCliSession (錯誤邊界 / readline 生命週期)
    ↓
runCliQuestions (問題流程編排,多輪)
    ↓
askOneRound (單輪多題 → 物件)
    ↓
askWithValidator (單題 + 重試)
    ↓
validators / composeValidators (純值驗證)

單元測試層(目前已完成)

  • validators / composeValidators

    • 測試重點:給定特定輸入,回傳 { value } 或丟 Error。

    • 不需要任何外部依賴,純函式邏輯。

  • askOneRound / runCliQuestions

    • 測試重點:用 vi.fn() mock askWithValidator,驗證:

      • 呼叫次數、參數是否正確。

      • 回傳的資料結構(單一物件 / 物件陣列)是否符合設計。

    • 不實際啟動 readline 或呼叫真實 validators,完全隔離依賴。

優點:

  • 執行速度快(無 I/O、無外部系統)。

  • 容易針對各種輸入組合(空陣列、多輪、單題)寫出精確測試。

  • 當邏輯改動時,測試失敗能快速定位到哪一個函式出問題。

整合測試層(之後會需要補的部分)

  • askWithValidator + 真實 readline

    • 測試重點:真的啟動一個 Node.js readline 實例(或用 helper 模擬 stdin),驗證:

      • 使用者輸入無效值時,會重新提示。

      • 輸入有效值時,會正確回傳 parsed 結果。

  • runCliSession 整體流程

    • 測試重點:從 CLI entrypoint 啟動一個完整 session,模擬使用者輸入一系列答案,驗證:

      • 多輪問答能依序執行。

      • 錯誤邊界能正確捕捉並顯示友善訊息。

      • 最終輸出符合預期格式(例如 JSON 或計算結果)。

    • 屬於最接近「真實使用者操作」的測試,會串起所有模組,偏向 end-to-end。

實務上的折衷策略

多數專案會遵循「測試金字塔」:大量單元測試、較少整合測試、少量 E2E 測試。

對這個 CLI 專案,可以採取:

  1. 優先寫單元測試

    • 對每個模組(validators、askOneRound、runCliQuestions)寫快速、隔離的單元測試,涵蓋 Happy path 與重要邊界。

    • 用 mock 隔離依賴,測試重點放在「資料結構轉換」和「流程編排邏輯」是否正確。

  2. 針對關鍵路徑補整合測試

    • 只針對「最容易出錯、牽涉多層協作」的部分寫整合測試,例如:

      • askWithValidator 真的接上 readline 與 validators,確認重試邏輯正常。

      • runCliSession 整個流程跑一遍,確認錯誤邊界與多輪輸入能正常收尾。

    • 這些測試數量較少,但能補足單元測試無法覆蓋的「模組之間介面不匹配」或「真實環境行為」問題。

  3. 用 Vitest 的 workspace / projects 分開跑

可以在 vitest.config.ts 設定不同的 projects,讓單元測試與整合測試分開執行:

import { defineWorkspace } from "vitest/config";

export default defineWorkspace([
  {
    test: {
      name: "unit",
      include: ["**/*.unit.test.js"],
    },
  },
  {
    test: {
      name: "integration",
      include: ["**/*.integration.test.js"],
    },
  },
]);

這樣可以在 CI 或開發時:

  • 頻繁跑快速的單元測試。

  • 定期跑較慢的整合測試(例如每次 commit 或 PR)。

何時選擇寫哪一種測試?

情境

優先選擇

純邏輯運算、資料轉換(如 validators)

單元測試

流程編排、依賴其他函式但可 mock(如 runCliQuestions)

單元測試

需要確認「真實 I/O」或「外部依賴」行為(如 readline、檔案讀寫)

整合測試

需要驗證「整條使用者流程」或「錯誤邊界是否正確攔截」

整合測試 / E2E

剛開始開發、設計還在調整

優先單元測試,等穩定後再補整合

小結:套用到目前 CLI 專案

  • 目前已完成的 validators、askOneRound、runCliQuestions 都用單元測試覆蓋 Happy path,這是最划算、最快的作法。

  • 接下來實作 runCliSession 或 askWithValidator 真實接 readline 時,會需要補少量整合測試,確認「模組之間的協作」與「真實環境行為」符合預期。

  • 目標不是「所有測試都寫成整合測試」,而是:

    • 用大量單元測試保證每個模組邏輯正確。

    • 用少量整合測試保證模組之間介面能接起來、真實環境能跑。

    • 讓測試既快又穩,維護成本也低。