線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
<100 subscribers
線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
在使用 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(驗證與解析,可測試)
責任劃分:
模組 | 責任 |
|---|---|
| 建立 readline interface、執行 callback、最後關閉 rl |
| 問題流程編排(多輪、多題),不知道 readline 是什麼 |
| 單一問題的提問與重試邏輯 |
| 純值驗證與轉換 |
| 組裝所有依賴,對外提供簡單 API |
這樣設計的好處:
每一層責任單一,便於單元測試
底層可以用依賴注入,方便 mock
新增題目只要改 config,不用動底層邏輯
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 阻止,確保一定會執行關閉
// 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();
}
}
測試結果:綠燈 ✓
在實作 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 管理
目前 runCliQuestions 的引數太複雜(要傳 askWithValidator / runCliSession),對使用者不友善。
可以拆成兩層:
runCliQuestionsCore:依賴注入版本(測試用)
runCliQuestions:façade 版本(對外 API)
main.js
↓
runCliQuestions (新增——façade:組裝依賴)
↓
runCliQuestionsCore (流程編排層,可測試)
↓
runCliSession (生命週期管理層,可測試)
↓
askWithValidator / validators (單一職責層,可測試)
// 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); // 預設跑一輪,只問一次。需要多次輸入可以直接設定次數。
有些題目拿到使用者輸入後,還需要做進一步處理(例如把輸入的數字丟進公式計算)。
可以在 config 加入 processor 欄位,並用一個 helper 統一處理。
// 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, // 處理函式
},
];
// 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.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() 整個模組?
每一個函式都可以照這個節奏:
用自然語言寫「這個函式的責任」
把責任拆成 3–5 個機械式步驟
每個步驟對應 1–2 個 assertion
最小實作讓測試變綠,再重構命名與分層
先判斷 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 / 回傳 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 建立 CLI 互動程式時,常見的問題包括:
readline 生命週期管理混亂(反覆開關 interface)
錯誤處理與業務邏輯糾纏在一起
try/catch 散落各處,難以統一處理
流程邏輯與 I/O 層耦合,難以單元測試
本文示範如何用 TDD + 依賴注入的方式,逐步設計出清晰分層的 CLI 架構,並以 runCliSession 作為「錯誤邊界與 readline 生命週期管理層」的核心實作。
將 CLI 驗證流程拆分成以下層級:
main.js
↓
runCliQuestions(façade 組裝層,負責把依賴串起來)
↓
runCliQuestionsCore(問題流程編排層,可測試)
↓
runCliSession(readline 生命週期與錯誤邊界,可測試)
↓
askWithValidator / questionOnce(單一問題 + 重試,可測試)
↓
validators / composeValidators(驗證與解析,可測試)
責任劃分:
模組 | 責任 |
|---|---|
| 建立 readline interface、執行 callback、最後關閉 rl |
| 問題流程編排(多輪、多題),不知道 readline 是什麼 |
| 單一問題的提問與重試邏輯 |
| 純值驗證與轉換 |
| 組裝所有依賴,對外提供簡單 API |
這樣設計的好處:
每一層責任單一,便於單元測試
底層可以用依賴注入,方便 mock
新增題目只要改 config,不用動底層邏輯
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 阻止,確保一定會執行關閉
// 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();
}
}
測試結果:綠燈 ✓
在實作 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 管理
目前 runCliQuestions 的引數太複雜(要傳 askWithValidator / runCliSession),對使用者不友善。
可以拆成兩層:
runCliQuestionsCore:依賴注入版本(測試用)
runCliQuestions:façade 版本(對外 API)
main.js
↓
runCliQuestions (新增——façade:組裝依賴)
↓
runCliQuestionsCore (流程編排層,可測試)
↓
runCliSession (生命週期管理層,可測試)
↓
askWithValidator / validators (單一職責層,可測試)
// 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); // 預設跑一輪,只問一次。需要多次輸入可以直接設定次數。
有些題目拿到使用者輸入後,還需要做進一步處理(例如把輸入的數字丟進公式計算)。
可以在 config 加入 processor 欄位,並用一個 helper 統一處理。
// 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, // 處理函式
},
];
// 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.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() 整個模組?
每一個函式都可以照這個節奏:
用自然語言寫「這個函式的責任」
把責任拆成 3–5 個機械式步驟
每個步驟對應 1–2 個 assertion
最小實作讓測試變綠,再重構命名與分層
先判斷 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 / 回傳 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
Share Dialog
Share Dialog
No comments yet