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

前言

在使用 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

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

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


參考資料