# Day 26:實作 askOneRound + 重構 runCliQuestions;邊界測試/ 單元測試/ 整合測試 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2025-12-25 **URL:** https://paragraph.com/@gcake/day-26 ## Content 實作 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:定義 runCliQuestionsdescribe("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 專案,可以採取:優先寫單元測試對每個模組(validators、askOneRound、runCliQuestions)寫快速、隔離的單元測試,涵蓋 Happy path 與重要邊界。用 mock 隔離依賴,測試重點放在「資料結構轉換」和「流程編排邏輯」是否正確。針對關鍵路徑補整合測試只針對「最容易出錯、牽涉多層協作」的部分寫整合測試,例如:askWithValidator 真的接上 readline 與 validators,確認重試邏輯正常。runCliSession 整個流程跑一遍,確認錯誤邊界與多輪輸入能正常收尾。這些測試數量較少,但能補足單元測試無法覆蓋的「模組之間介面不匹配」或「真實環境行為」問題。用 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 時,會需要補少量整合測試,確認「模組之間的協作」與「真實環境行為」符合預期。目標不是「所有測試都寫成整合測試」,而是:用大量單元測試保證每個模組邏輯正確。用少量整合測試保證模組之間介面能接起來、真實環境能跑。讓測試既快又穩,維護成本也低。 ## 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