# Day 27:用 TDD 設計可測試的 Node.js CLI 架構:實作 runCliSession 與錯誤邊界 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2025-12-26 **URL:** https://paragraph.com/@gcake/day-27 ## Content 前言在使用 Node.js readline 建立 CLI 互動程式時,常見的問題包括:readline 生命週期管理混亂(反覆開關 interface)錯誤處理與業務邏輯糾纏在一起try/catch 散落各處,難以統一處理流程邏輯與 I/O 層耦合,難以單元測試本文示範如何用 TDD + 依賴注入的方式,逐步設計出清晰分層的 CLI 架構,並以 runCliSession 作為「錯誤邊界與 readline 生命週期管理層」的核心實作。整體架構設計將 CLI 驗證流程拆分成以下層級:main.js ↓ runCliQuestions(façade 組裝層,負責把依賴串起來) ↓ runCliQuestionsCore(問題流程編排層,可測試) ↓ runCliSession(readline 生命週期與錯誤邊界,可測試) ↓ askWithValidator / questionOnce(單一問題 + 重試,可測試) ↓ validators / composeValidators(驗證與解析,可測試) 責任劃分:模組責任runCliSession建立 readline interface、執行 callback、最後關閉 rlrunCliQuestionsCore問題流程編排(多輪、多題),不知道 readline 是什麼askWithValidator單一問題的提問與重試邏輯validators純值驗證與轉換runCliQuestions (façade)組裝所有依賴,對外提供簡單 API這樣設計的好處:每一層責任單一,便於單元測試底層可以用依賴注入,方便 mock新增題目只要改 config,不用動底層邏輯核心實作:runCliSession(錯誤邊界與生命週期管理)自然語言描述函式責任runCliSession 的職責:用注入的 createRl 建立一個 readline interface(rl)。把 rl 傳給傳入的 callback 執行,並回傳 callback 的結果。不論 callback 成功還是失敗,最後都要呼叫 rl.close() 關閉資源。這一層應該知道: rl / createRl / callback 這一層不應該知道: questionConfigs / validators / rounds 等業務邏輯程式步驟(機械式流程)呼叫 createRl(),取得一個 rl。用 try { ... } finally { ... } 包住主流程:在 try 區塊裡:呼叫 await callback(rl)把結果存在一個變數 result回傳這個 result在 finally 區塊裡:呼叫 rl.close()為什麼用 finally 而不是 catch?拋錯是 callback 的責任,這層只管理資源生命週期finally 不會被 return / throw 阻止,確保一定會執行關閉TDD 實作:紅燈 → 綠燈// util/runCliSession.test.js import { describe, it, expect, vi } from "vitest"; import { runCliSession } from "./runCliSession.js"; // import * as createRlModule from './createRl.js'; describe("runCliSession", () => { it("成功路徑:應該建立 rl、執行 callback,最後關閉 rl", async () => { const fakeRl = { close: vi.fn() }; const createRl = vi.fn().mockReturnValue(fakeRl); const callback = vi.fn().mockResolvedValue("RESULT"); const result = await runCliSession(callback, { createRl }); expect(createRl).toHaveBeenCalledTimes(1); expect(fakeRl.close).toBeCalledTimes(1); expect(callback).toBeCalledWith(fakeRl); expect(result).toBe("RESULT"); }); it("錯誤邊界:就算 callback 拋錯,也要關閉 rl", async () => { const fakeRl = { close: vi.fn() }; const createRl = vi.fn().mockReturnValue(fakeRl); const error = new Error("boom"); const callback = vi.fn().mockRejectedValue(error); await expect(runCliSession(callback, { createRl })).rejects.toThrow("boom"); expect(fakeRl.close).toHaveBeenCalledTimes(1); }); }); // util/runCliSession.js // 負責管理 readline 生命週期:建立 rl,執行回呼,最後關閉 rl。 // API: runCliSession(async (rl) => { ... }) => Promise export async function runCliSession(callback, { createRl }) { const rl = createRl(); try { // 將整個 CLI 問答流程視為一個 callback(rl) const result = await callback(rl); return result; } finally { // 不論成功或失敗,都確保關閉 rl rl.close(); } } 測試結果:綠燈 ✓重構上層:讓 runCliQuestions 透過 runCliSession 拿到 rl在實作 runCliSession 之前,runCliQuestions 還沒有正確管理 readline 生命週期。現在要重構它,讓它內部呼叫 runCliSession。設計思路runCliQuestionsCore:純流程編排(依賴注入版本,方便測試)接收 runCliSession 當參數在 callback 裡拿到 rl把 rl 綁進 askWithValidator,傳給 askOneRoundaskOneRound:負責「一輪多題」不認識 rl,只認識「能幫我問問題的 askWithValidator(prompt, validator)」測試:驗證流程編排// util/runCliQuestionsCore.test.js import { describe, it, expect, vi } from "vitest"; import { askOneRound, runCliQuestionsCore } from "./runCliQuestionsCore.js"; function createBaseQuestionConfigs(){ return[ { key: "age", prompt: "請輸入年齡:", validator: vi.fn(), }, { key: "name", prompt: "請輸入姓名:", validator: vi.fn(), }, ];} describe("askOneRound", () => { it("應該依序呼叫 askWithValidator,組成一個物件回傳", async () => { const questionConfigs = createBaseQuestionConfigs(); // 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" }); }); }); describe("runCliQuestionsCore", () => { it("紅燈 11:fakeRunCliSession,呼叫 askOneRound 兩次,回傳含物件的陣列", async () => { const questionConfigs = createBaseQuestionConfigs(); // Mock askWithValidator 的行為 const mockAskWithValidator = vi .fn() // 第一輪 .mockResolvedValueOnce(25) .mockResolvedValueOnce("Alice") // 第二輪 .mockResolvedValueOnce(30) .mockResolvedValueOnce("Bob"); // 假的 runCliSession:直接執行 callback,給一個 fakeRl const fakeRunCliSession = vi.fn().mockImplementation(async(callback)=>{ const fakeRl = {}; return callback(fakeRl); }) const results = await runCliQuestionsCore( questionConfigs, mockAskWithValidator, fakeRunCliSession, 2 ); expect(fakeRunCliSession).toHaveBeenCalledTimes(1); expect(mockAskWithValidator).toHaveBeenCalledTimes(4); expect(results).toEqual([{ age: 25, name: "Alice" }, { age: 30, name: "Bob" },]); }); }); // util/runCliQuestionsCore.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; } export async function runCliQuestionsCore( questionConfigs, askWithValidator, runCliSession, rounds = 1 ) { return runCliSession(async(rl)=>{ const result = []; // 把 rl 綁進 askWithValidator,讓 askOneRound 使用 const askWithValidatorBoundToRl = (prompt, validator) => askWithValidator(rl, prompt, validator); for (let round = 0; round < rounds; round++) { const oneRoundResult = await askOneRound(questionConfigs, askWithValidatorBoundToRl); result.push(oneRoundResult); } return result}); } 關鍵設計:askWithValidatorBoundToRl 是一個閉包,把 rl 預先綁定好askOneRound 完全不知道 rl 的存在,只知道「有一個函式可以幫我問問題」所有 readline 生命週期交給 runCliSession 管理簡化對外 API:加入 façade 層目前 runCliQuestions 的引數太複雜(要傳 askWithValidator / runCliSession),對使用者不友善。 可以拆成兩層:runCliQuestionsCore:依賴注入版本(測試用)runCliQuestions:façade 版本(對外 API)架構調整修正main.js ↓ runCliQuestions (新增——façade:組裝依賴) ↓ runCliQuestionsCore (流程編排層,可測試) ↓ runCliSession (生命週期管理層,可測試) ↓ askWithValidator / validators (單一職責層,可測試) Façade 實作// util/runCliQuestions.js(façade) import { createRl } from './createRl.js'; import { askWithValidator } from './askWithValidator.js'; import { runCliSession } from './runCliSession.js'; import { runCliQuestionsCore } from './runCliQuestionsCore.js'; export async function runCliQuestions(questionConfigs, rounds = 1) { return runCliQuestionsCore( questionConfigs, askWithValidator, (callback) => runCliSession(callback, { createRl }), rounds, ); } 對外 API 變得超簡單:// main.js import { runCliQuestions } from './util/runCliQuestions.js'; import { questionConfigs } from './config/q6config.js'; const answers = await runCliQuestions(questionConfigs, 2); // 預設跑一輪,只問一次。需要多次輸入可以直接設定次數。 加入業務邏輯:processor 處理層有些題目拿到使用者輸入後,還需要做進一步處理(例如把輸入的數字丟進公式計算)。 可以在 config 加入 processor 欄位,並用一個 helper 統一處理。config 定義// evenNumConfig.js import { ensureIntegerString, toInt, isEvenNum, } from "../util/validators.js"; import { composeValidators } from "../util/composeValidators.js"; import { composeFormula } from "./composeFormula.js"; export const questionConfigs = [ { name: "evenInput", prompt: "請輸入一個大於 4 的偶數:", validator: composeValidators([ ensureIntegerString, toInt, (num) => isEvenNum(num, 4), ]), processor: composeFormula, // 處理函式 }, ]; helper 實作// util/processUserInputs.js // 將每一輪使用者輸入套用對應題目的 processor,產生業務要用的值 export function processUserInputs(userInputRounds, questionConfigs) { return userInputRounds.map((roundInputs) => { const processedValues = {}; for (const config of questionConfigs) { const userInput = roundInputs[config.name]; processedValues[config.name] = config.processor ? config.processor(userInput) : userInput; } return processedValues; }); } main 使用方式// main.js import { questionConfigs } from './evenNumConfig.js'; import { runCliQuestions } from '../util/runCliQuestions.js'; import { processUserInputs } from '../util/processUserInputs.js'; async function main() { const userInputRounds = await runCliQuestions(questionConfigs); const processedRounds = processUserInputs(userInputRounds, questionConfigs); const [firstRound] = processedRounds; console.log('算式結果:', firstRound.evenInput); } main().catch((err) => { console.log('程式發生未預期錯誤:',err); }) 流程清楚分離:runCliQuestions:CLI 互動 + 驗證processUserInputs:業務邏輯處理main:組合流程設計反思與檢查清單常見錯誤:責任混亂一開始容易把 readline 管理寫進流程編排層,導致:流程層同時處理 I/O 與業務邏輯測試時要 mock 整個 readline module很難重用到其他題目解法:讓 runCliSession 專門管 readline 生命週期讓 runCliQuestionsCore 只管「問什麼題、問幾輪」用依賴注入把兩者串起來分層檢查清單每完成一個模組,檢查:✅ 這一層有沒有碰到不應該知道的東西?✅ 這層如果要測試,依賴是注入還是 import?✅ 能不能用 vi.fn() stub,而不是 vi.mock() 整個模組?TDD 小循環每一個函式都可以照這個節奏:用自然語言寫「這個函式的責任」把責任拆成 3–5 個機械式步驟每個步驟對應 1–2 個 assertion最小實作讓測試變綠,再重構命名與分層附錄:Vitest Mock 常用方法速查決策流程先判斷 mock 的函式是同步/非同步(Promise / async/await)?多次呼叫需不需要不同結果?是要測「成功(resolved / return)」還是「失敗(rejected / throw)」?vi.fn():建立一個可監控的空函式const fn = vi.fn(); 建了一個「什麼都不做」的函式。可以呼叫它 fn(),也可以用 expect(fn).toHaveBeenCalled() 之類的斷言來檢查它的呼叫次數、參數。如果被 mock 的函式是同步的1-1. .mockReturnValue(value):每次呼叫都回傳某個值const fn = vi.fn().mockReturnValue(42); fn(); // 會回傳 42 fn(); // 還是 42 適合同步情境,例如假的計算函式 1-2. .mockReturnValueOnce(...)const fn = vi.fn() .mockReturnValueOnce(1) .mockReturnValueOnce(2); fn(); // 1 fn(); // 2 fn(); // undefined(之後沒有定義就會是 undefined) 適合模擬「多次呼叫,每次結果不一樣」的同步情境 1-3. .mockImplementation(fn):自訂同步假實作const fn = vi.fn().mockImplementation((x, y) => x + y); fn(1, 2); // 3 如果需要比較複雜的邏輯,或需要依照參數決定回傳值時使用如果被 mock 的函式是 async / 回傳 Promise2-1. .mockResolvedValue(value):每次呼叫都回傳 Promise.resolve(value)const fn = vi.fn().mockResolvedValue('OK'); await fn(); // 拿到 'OK' // 等同於 const fn = vi.fn().mockImplementation(async () => 'OK'); 適合 async 的假實作 2-2. .mockResolvedValueOnce(...) :限定某一次呼叫 resolve 的值const fn = vi.fn() .mockResolvedValueOnce(1) .mockResolvedValueOnce(2); await fn(); // 1 await fn(); // 2 await fn(); // undefined(之後沒定義就會是 undefined) 適合模擬「多次 await,不同結果」的情境 2-3. .mockRejectedValue(error):每次呼叫都回傳 Promise.reject(error)const error = new Error('boom'); const fn = vi.fn().mockRejectedValue(error); await fn(); // 會丟出 error 適合測試錯誤路徑,例如 API 失敗、驗證錯誤 2-4. mockImplementation(fn):自訂 async 假實作const fn = vi.fn().mockImplementation(async (id) => { return { id, name: 'Alice' }; }); await fn(1); // { id: 1, name: 'Alice' } 和同步版本相同,只是實作本身是 async參考資料Node.js Readline 官方文件Vitest Getting StartedVitest Mocking GuideVitest API ReferenceECMAScript 2026 Language Specification ## 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