# Day 27：用 TDD 設計可測試的 Node.js CLI 架構：實作 runCliSession 與錯誤邊界

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

---

前言
--

在使用 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、最後關閉 rl

`runCliQuestionsCore`

問題流程編排（多輪、多題），不知道 readline 是什麼

`askWithValidator`

單一問題的提問與重試邏輯

`validators`

純值驗證與轉換

`runCliQuestions` (façade)

組裝所有依賴，對外提供簡單 API

這樣設計的好處：

*   每一層責任單一，便於單元測試
    
*   底層可以用依賴注入，方便 mock
    
*   新增題目只要改 config，不用動底層邏輯
    

* * *

核心實作：runCliSession（錯誤邊界與生命週期管理）
-------------------------------

### 自然語言描述函式責任

`runCliSession` 的職責：

1.  用注入的 `createRl` 建立一個 readline interface（`rl`）。
    
2.  把 `rl` 傳給傳入的 `callback` 執行，並回傳 callback 的結果。
    
3.  不論 callback 成功還是失敗，最後都要呼叫 `rl.close()` 關閉資源。
    

**這一層應該知道：** `rl` / `createRl` / `callback`  
**這一層不應該知道：** `questionConfigs` / `validators` / `rounds` 等業務邏輯

### 程式步驟（機械式流程）

1.  呼叫 `createRl()`，取得一個 `rl`。
    
2.  用 `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<result>
    
    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`，傳給 `askOneRound`
        
*   `askOneRound`：負責「一輪多題」
    
    *   不認識 `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 小循環

每一個函式都可以照這個節奏：

1.  用自然語言寫「這個函式的責任」
    
2.  把責任拆成 3–5 個機械式步驟
    
3.  每個步驟對應 1–2 個 assertion
    
4.  最小實作讓測試變綠，再重構命名與分層
    

* * *

附錄：Vitest Mock 常用方法速查
---------------------

### 決策流程

1.  先判斷 mock 的函式是同步／非同步（Promise / async/await）？
    
2.  多次呼叫需不需要不同結果？
    
3.  是要測「成功（resolved / return）」還是「失敗（rejected / throw）」？
    
4.  `vi.fn()`：建立一個可監控的空函式
    

    const fn = vi.fn();
    

*   建了一個「什麼都不做」的函式。
    
*   可以呼叫它 `fn()`，也可以用 `expect(fn).toHaveBeenCalled()` 之類的斷言來檢查它的呼叫次數、參數。
    

1.  如果被 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
    

如果需要比較複雜的邏輯，或需要依照參數決定回傳值時使用

2.  如果被 mock 的函式是 async / 回傳 Promise
    

2-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 官方文件](https://nodejs.org/api/readline.html)
    
*   [Vitest Getting Started](https://vitest.dev/guide/)
    
*   [Vitest Mocking Guide](https://vitest.dev/guide/mocking)
    
*   [Vitest API Reference](https://vitest.dev/api/)
    
*   [ECMAScript 2026 Language Specification](https://tc39.es/ecma262/)

---

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