線上課程觀課進度管理小工具開發日誌
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」。
線上課程觀課進度管理小工具開發日誌
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
<100 subscribers
日期:2026/01/09
主題:TDD 超小步推進、測試設計方法、Number.EPSILON、架構設計
本筆記記錄一個真實的 TDD 開發流程,包含:
使用 Uncle Bob 的超小步 TDD 實作複雜邏輯
系統性測試設計方法(等價類劃分、邊界值分析)
JavaScript 浮點數精度問題的發現與解決
架構設計缺陷的發現與重構
實戰案例:實作一個「指數成長 + 線性近似」的計算器(生物細菌分裂模擬)
實作一個計算函數,模擬細菌分裂實驗:
假設條件:
1. 沒有細菌死亡
2. 初始有 b 隻細菌
3. 每經過 20 分鐘,細菌數量翻倍(指數成長)
4. 不足 20 分鐘的部分用線性成長近似
輸入:initialBacteria(初始數量),minutesPassed(經過分鐘)
輸出:最終細菌數量(無條件捨去小數)
設 m 為經過的分鐘數,b 為初始細菌數:
完整週期數:( \text{fullCycles} = \lfloor m / 20 \rfloor )
完整週期後細菌數:( \text{afterFullCycles} = b \times 2^{\text{fullCycles}} )
剩餘分鐘:( \text{remainingMinutes} = m \mod 20 )
線性成長增量:( \text{linearGrowth} = \text{afterFullCycles} \times (\text{remainingMinutes} / 20) )
最終細菌數:( \lfloor \text{afterFullCycles} + \text{linearGrowth} \rfloor )
// 範例 1:0 分鐘
calculateBacteriaGrowth(100, 0) → 100
// 範例 2:20 分鐘(1 個完整週期)
100 × 2^1 = 200
// 範例 3:10 分鐘(半個週期)
基數:100 × 2^0 = 100
線性成長:100 × (10/20) = 50
總數:100 + 50 = 150
// 範例 4:30 分鐘(1 個完整週期 + 10 分鐘)
基數:100 × 2^1 = 200
線性成長:200 × (10/20) = 100
總數:200 + 100 = 300
根據 Clean Coder Blog:
第一定律:在寫失敗測試之前,不得寫任何生產程式碼
第二定律:測試程式碼只寫到剛好失敗為止
第三定律:生產程式碼只寫到剛好讓當前失敗測試通過為止
循環時間:每個 Red-Green-Refactor 循環約 20-40 秒
// calculateBacteriaGrowth.test.js
import { describe, it, expect } from 'vitest';
import { calculateBacteriaGrowth } from './calculateBacteriaGrowth.js';
describe('calculateBacteriaGrowth', () => {
it('0 分鐘後,應回傳初始細菌數量', () => {
expect(calculateBacteriaGrowth(10, 0)).toBe(10);
});
});
// 執行:npx vitest
// 結果:❌ FAIL - Cannot find module
// calculateBacteriaGrowth.js
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
return initialBacteria; // 最簡單的實作
}
// 執行:npx vitest
// 結果:✅ PASS (1/1)
時間:30 秒
it('20 分鐘後,應回傳初始細菌數量的 2 倍', () => {
expect(calculateBacteriaGrowth(10, 20)).toBe(20);
});
// 結果:❌ FAIL
// Expected: 20
// Received: 10
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const cycleMinutes = 20;
const growthRate = 2;
const fullCycles = Math.floor(minutesPassed / cycleMinutes);
return initialBacteria * (growthRate ** fullCycles);
}
// 結果:✅ PASS (2/2)
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
return initialBacteria * (GROWTH_RATE ** fullCycles);
}
時間:40 秒
it('10 分鐘後,應使用線性近似計算', () => {
expect(calculateBacteriaGrowth(100, 10)).toBe(150);
// 計算:100 + (100 × 10/20) = 150
});
// 結果:❌ FAIL
// Expected: 150
// Received: 100
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
const fullCycleMultiplier = GROWTH_RATE ** fullCycles;
const remainingMinutes = minutesPassed % CYCLE_MINUTES;
const linearGrowthMultiplier = fullCycleMultiplier * (remainingMinutes / CYCLE_MINUTES);
const totalGrowth = initialBacteria * (fullCycleMultiplier + linearGrowthMultiplier);
return Math.floor(totalGrowth);
}
// 結果:✅ PASS (3/3)
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
// 計算完整週期數
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
const exponentialMultiplier = GROWTH_RATE ** fullCycles;
// 計算剩餘分鐘的線性成長
const remainingMinutes = minutesPassed % CYCLE_MINUTES;
const linearMultiplier = exponentialMultiplier * (remainingMinutes / CYCLE_MINUTES);
// 合併指數成長與線性成長
const totalMultiplier = exponentialMultiplier + linearMultiplier;
return Math.floor(initialBacteria * totalMultiplier);
}
時間:60 秒
優點:
快速反饋:每次只檢查最後幾行程式碼
防止過度設計:只在測試驅動下加入邏輯
累積測試套件:每個循環都留下可執行的規格
信心倍增:重構時有測試保護
循環節奏:
每個循環 20-60 秒
一小時可完成 30-60 個循環
保持專注,立即獲得反饋
核心概念:將輸入範圍分成「行為相似」的區塊,每個區塊選一個代表值測試。
// 維度 1:minutesPassed(經過的分鐘數)
等價類 1:0 分鐘(初始狀態)
等價類 2:完整週期(20, 40, 60...)
等價類 3:不足一個週期(1-19 分鐘)
等價類 4:混合情況(完整週期 + 剩餘分鐘)
// 維度 2:remainingMinutes(剩餘分鐘)
等價類 A:0 分鐘(剛好完整週期)
等價類 B:半個週期(10 分鐘,線性成長 50%)
等價類 C:其他分鐘數(需捨去小數)
// 維度 3:initialBacteria(初始細菌數)
等價類 α:小數量(10, 50)
等價類 β:中等數量(100, 200)
等價類 γ:大數量(1000+)
等價類 | 代表值 | 測試目的 |
|---|---|---|
初始狀態 |
| 驗證邊界條件 |
1 個完整週期 |
| 驗證指數成長 |
2 個完整週期 |
| 驗證多週期 |
半個週期 |
| 驗證線性近似 |
混合場景 |
| 驗證指數+線性 |
核心概念:錯誤最常發生在「邊界」,測試邊界值和邊界±1的值。
對於範圍 [min, max],測試:
最小值:min
最小值+1:min + 1
最大值-1:max - 1
最大值:max
// 週期邊界(20 分鐘)
it('19 分鐘:週期前 1 分鐘', () => {
expect(calculateBacteriaGrowth(100, 19)).toBe(195);
// 100 × (1 + 19/20) = 195
});
it('20 分鐘:剛好完整週期', () => {
expect(calculateBacteriaGrowth(100, 20)).toBe(200);
});
it('21 分鐘:週期後 1 分鐘', () => {
expect(calculateBacteriaGrowth(100, 21)).toBe(210);
// 200 × (1 + 1/20) = 210
});
// 初始狀態邊界
it('0 分鐘:邊界', () => {
expect(calculateBacteriaGrowth(100, 0)).toBe(100);
});
it('1 分鐘:最小正整數', () => {
expect(calculateBacteriaGrowth(100, 1)).toBe(105);
// 100 × (1 + 1/20) = 105
});
核心概念:基於經驗預測可能出錯的地方。
// 錯誤 1:浮點數精度問題
// 3 分鐘:100 × (1 + 3/20) = 100 × 1.15 = 114.999... (可能)
it('3 分鐘:應正確處理浮點數', () => {
expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});
// 錯誤 2:整除問題
// 40 分鐘:剩餘分鐘應為 0,不應觸發線性計算
it('40 分鐘:2 個完整週期', () => {
expect(calculateBacteriaGrowth(100, 40)).toBe(400);
});
// 錯誤 3:指數運算錯誤
// 0 ** 0 在數學上有爭議,JavaScript 定義為 1
it('0 分鐘:驗證 2^0 = 1', () => {
expect(calculateBacteriaGrowth(100, 0)).toBe(100);
});
測試類型 | 涵蓋範圍 | 測試數量 | 重要性 |
|---|---|---|---|
等價類劃分 | 初始/完整週期/線性/混合 | 4-5 個 | ⭐⭐⭐⭐⭐ |
邊界值分析 | 0, 20, 40 分鐘邊界 | 3-4 個 | ⭐⭐⭐⭐ |
浮點數處理 | Math.floor 正確性 | 1-2 個 | ⭐⭐⭐⭐ |
不同初始值 | 參數化測試 | 2-3 個 | ⭐⭐⭐ |
// calculateBacteriaGrowth.test.js
import { describe, it, expect } from 'vitest';
import { calculateBacteriaGrowth } from './calculateBacteriaGrowth.js';
describe('calculateBacteriaGrowth', () => {
describe('邊界測試', () => {
it('0 分鐘後,應回傳初始細菌數量', () => {
expect(calculateBacteriaGrowth(100, 0)).toBe(100);
});
});
describe('完整週期測試', () => {
it('20 分鐘:1 個完整週期', () => {
expect(calculateBacteriaGrowth(100, 20)).toBe(200);
});
it('40 分鐘:2 個完整週期', () => {
expect(calculateBacteriaGrowth(100, 40)).toBe(400);
});
});
describe('線性成長測試', () => {
it('10 分鐘:半個週期線性成長', () => {
expect(calculateBacteriaGrowth(100, 10)).toBe(150);
});
it('5 分鐘:1/4 週期線性成長', () => {
expect(calculateBacteriaGrowth(100, 5)).toBe(125);
});
});
describe('混合場景測試', () => {
it('30 分鐘:1 週期 + 10 分鐘', () => {
expect(calculateBacteriaGrowth(100, 30)).toBe(300);
});
it('25 分鐘:1 週期 + 5 分鐘', () => {
expect(calculateBacteriaGrowth(100, 25)).toBe(250);
});
it('50 分鐘:2 週期 + 10 分鐘', () => {
expect(calculateBacteriaGrowth(100, 50)).toBe(600);
});
});
describe('浮點數精度測試', () => {
it('3 分鐘:應無條件捨去浮點數', () => {
expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});
it('7 分鐘:應無條件捨去浮點數', () => {
expect(calculateBacteriaGrowth(100, 7)).toBe(135);
});
});
});
// 執行:npx vitest
// 結果:✅ PASS (9/9 tests)
執行測試時發現:
// 測試
it('3 分鐘:應無條件捨去浮點數', () => {
expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});
// 結果:❌ FAIL
// Expected: 115
// Received: 114
// 加入 console.log 追蹤
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / 20);
const exponentialMultiplier = 2 ** fullCycles;
const remainingMinutes = minutesPassed % 20;
const linearMultiplier = exponentialMultiplier * (remainingMinutes / 20);
console.log('exponentialMultiplier:', exponentialMultiplier); // 1
console.log('linearMultiplier:', linearMultiplier); // 0.15
console.log('Sum:', exponentialMultiplier + linearMultiplier); // 1.15
const totalMultiplier = exponentialMultiplier + linearMultiplier;
const result = initialBacteria * totalMultiplier;
console.log('Before floor:', result); // 114.99999999999999 ← 問題!
console.log('After floor:', Math.floor(result)); // 114
return Math.floor(result);
}
JavaScript 使用雙精度浮點數(IEEE 754),某些十進制小數無法精確表示:
// 預期
100 * 1.15 = 115.0
// 實際(IEEE 754)
100 * 1.15 = 114.99999999999999
// Math.floor 導致錯誤
Math.floor(114.99999999999999) = 114 // ❌
原理:
電腦使用二進制表示數字
1.15 在二進制中是無限循環小數
儲存時會產生微小誤差
誤差累積導致 114.999... 而非 115.0
根據 ECMAScript 規範和 MDN:
Number.EPSILON 是「1 與大於 1 的最小浮點數之間的差值」
值為:2.220446049250313e-16
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
const exponentialMultiplier = GROWTH_RATE ** fullCycles;
const remainingMinutes = minutesPassed % CYCLE_MINUTES;
const linearMultiplier = exponentialMultiplier * (remainingMinutes / CYCLE_MINUTES);
const totalMultiplier = exponentialMultiplier + linearMultiplier;
// ✅ 加入 Number.EPSILON 補償浮點數誤差
return Math.floor(initialBacteria * totalMultiplier + Number.EPSILON);
}
// 執行測試
// 結果:✅ PASS (9/9 tests)
// 修正前
114.99999999999999 + 0 = 114.99999999999999
Math.floor(114.99999999999999) = 114 // ❌
// 修正後
114.99999999999999 + 2.220446049250313e-16 ≈ 115.0
Math.floor(115.0) = 115 // ✅
關鍵:EPSILON 足夠小,不會影響正確的計算,但足夠補償精度誤差。
需要 EPSILON 的充要條件:
1. 涉及浮點數運算(除法、乘法、特定小數)✅
2. 使用 Math.floor/ceil/round 等整數化函數 ✅
3. 運算結果可能接近整數邊界(例如 114.999... 接近 115)✅
需要 EPSILON:
// 場景 1:Math.floor 處理接近整數的浮點數
const price = 100;
const taxRate = 1.15;
Math.floor(price * taxRate + Number.EPSILON); // 115 ✅
// 場景 2:比較浮點數相等性
function areEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON;
}
areEqual(0.1 + 0.2, 0.3); // true ✅
// 場景 3:除法產生無限循環小數
const ratio = 3 / 20; // 0.15
Math.floor(100 * (1 + ratio) + Number.EPSILON); // 115 ✅
❌ 不需要 EPSILON:
// 場景 1:整數運算
const cycles = Math.floor(30 / 20); // 1(整數除法)
// 場景 2:結果本身是浮點數(不做整數化)
const average = (100 + 115) / 2; // 107.5(保持小數)
// 場景 3:只有加減法的整數
const total = 100 + 50 + 25; // 175(整數加法)
// 在所有 Math.floor 前都加 EPSILON
const result = Math.floor(value + Number.EPSILON);
優點:
一勞永逸,避免未來出錯
程式碼明確表達「處理浮點數精度」的意圖
缺點:
⚠️ 增加複雜度(如果運算本來就是整數)
⚠️ 可能隱藏真正的邏輯錯誤
// 先不加,讓測試發現問題
const result = Math.floor(value);
// 測試失敗後,精確定位並修復
const result = Math.floor(value + Number.EPSILON);
優點:
✅ YAGNI 原則:不過度設計
測試案例記錄了問題場景
只在真正需要的地方加入
缺點:
⚠️ 依賴測試覆蓋率
遇到 Math.floor/Math.ceil 時,問自己:
□ 有浮點數運算嗎?(除法、乘法、特定小數)
→ 是 → 繼續檢查
→ 否 → 不需要 EPSILON
□ 結果可能接近整數嗎?(例如 114.999...)
→ 是 → 需要 EPSILON
→ 否 → 不需要 EPSILON
□ 有測試覆蓋嗎?
→ 是 → 讓測試指引你
→ 否 → 預防性加入
假設我們要建立一個通用的 CLI 問答系統,可以透過配置文件定義問題和處理邏輯:
// 使用範例
const questionConfigs = [
{
name: "initialBacteria",
prompt: "請輸入初始細菌數量:",
validator: isPositiveInteger,
},
{
name: "minutesPassed",
prompt: "請輸入經過分鐘數:",
validator: isNonNegativeInteger,
processor: ({ initialBacteria, minutesPassed }) =>
calculateBacteriaGrowth(initialBacteria, minutesPassed),
},
];
const result = await runQuestions(questionConfigs);
console.log('結果:', result);
// processUserInputs.js
export function processUserInputs(userInputs, questionConfigs) {
const processedValues = {};
for (const config of questionConfigs) {
const input = userInputs[config.name];
if (config.processor) {
// ❌ 問題:用 config.name 作為結果的 key
processedValues[config.name] = config.processor(userInputs);
} else {
processedValues[config.name] = input;
}
}
return processedValues;
}
// Config
{
name: "minutesPassed",
processor: ({ initialBacteria, minutesPassed }) =>
calculateBacteriaGrowth(initialBacteria, minutesPassed),
// processor 返回:300(細菌數量)
}
// processUserInputs 執行後
processedValues.minutesPassed = 20; // 1. 先存入輸入值(20 分鐘)
processedValues.minutesPassed = 300; // 2. 被 processor 覆蓋(300 隻細菌)
// 問題:無法同時保留輸入和輸出
console.log(result.minutesPassed); // 300(細菌數)
// 原本的 20 分鐘不見了!
// 期望的輸出
console.log(`經過 ${result.minutesPassed} 分鐘後,細菌數量為 ${result.numberOfBacteria} 隻`);
// 實際輸出
console.log(`經過 ${result.minutesPassed} 分鐘後,細菌數量為 ${result.minutesPassed} 隻`);
// → "經過 300 分鐘後,細菌數量為 300 隻" ❌ 語義錯誤!
// processUserInputs.js(改進版)
export function processUserInputs(userInputs, questionConfigs) {
const processedValues = {};
// 第一階段:收集所有輸入
for (const config of questionConfigs) {
processedValues[config.name] = userInputs[config.name];
}
// 第二階段:執行 processor
for (const config of questionConfigs) {
if (config.processor) {
// ✅ 驗證:強制要求有 resultKey
if (!config.resultKey) {
throw new Error(
`Config "${config.name}" has processor but missing resultKey.`
);
}
// ✅ 結果存到獨立欄位
processedValues[config.resultKey] = config.processor(processedValues);
}
}
return processedValues;
}
// bacteriaGrowthConfig.js
export const questionConfigs = [
{
name: "initialBacteria",
prompt: "請輸入初始細菌數量:",
validator: isPositiveInteger,
},
{
name: "minutesPassed",
prompt: "請輸入經過分鐘數:",
validator: isNonNegativeInteger,
needsAllFields: true, // 標記需要多個輸入
resultKey: "numberOfBacteria", // ✅ 指定結果欄位名
processor: ({ initialBacteria, minutesPassed }) =>
calculateBacteriaGrowth(initialBacteria, minutesPassed),
},
];
// main.js
const result = await runQuestions(questionConfigs);
// ✅ 輸入和輸出分離
console.log('輸入的初始細菌數:', result.initialBacteria); // 100
console.log('輸入的經過分鐘數:', result.minutesPassed); // 30
console.log('計算的細菌數量:', result.numberOfBacteria); // 300
// ✅ 語義正確的輸出
console.log(`經過 ${result.minutesPassed} 分鐘後,細菌數量約為 ${result.numberOfBacteria} 隻`);
// → "經過 30 分鐘後,細菌數量約為 300 隻" ✅
方案 A:統一用 result(通用化)
// Config
resultKey: "result", // 所有題目都用 result
// Main
console.log('結果:', data.result);
// 🤔 result 是什麼?細菌數?分數?金額?
優點:
✅ 模板化程度高,複製貼上即可
不用思考命名
缺點:
語義模糊:看到 result 不知道是什麼
Debug 時不友善
複雜場景難擴展(如果需要多個結果?)
方案 B:語義化命名(最終選擇)
// Config
resultKey: "numberOfBacteria", // 清楚表達業務意義
// Main
console.log(`細菌數量:${data.numberOfBacteria} 隻`);
// ✅ 一眼看出是細菌數量
優點:
✅ 可讀性高,一眼看出是什麼結果
符合 Clean Code 原則(自文件化)
Debug 友善
易於擴展多結果場景
缺點:
⚠️ 每題要思考命名(但只多花 10 秒)
⚠️ 模板不能完全統一(但可以模式化)
// 推薦:使用語義化命名
// 範例 1:細菌實驗
resultKey: "numberOfBacteria"
// 範例 2:成績計算
resultKey: "finalGrade"
// 範例 3:價格計算
resultKey: "totalPrice"
// 範例 4:比較結果
resultKey: "comparisonResult"
理由:
✅ 六個月後回來看程式碼,立即理解
別人接手時不用猜測
複雜題目可以擴展(例如返回物件)
符合「程式碼是寫給人看的」原則
支援任何 JavaScript 類型:
// Config
{
resultKey: "numberOfBacteria",
processor: () => 300, // 返回 number
}
// 使用
console.log(result.numberOfBacteria); // 300
// Config
{
resultKey: "statistics",
processor: ({ numbers }) => ({
average: calculateAverage(numbers),
sum: calculateSum(numbers),
max: Math.max(...numbers),
min: Math.min(...numbers),
}),
}
// 使用
console.log(result.statistics.average); // 50
console.log(result.statistics.sum); // 300
// 或解構
const { average, sum } = result.statistics;
// Config
{
resultKey: "sortedNumbers",
processor: ({ numbers }) => numbers.sort((a, b) => a - b),
}
// 使用
console.log(result.sortedNumbers); // [1, 2, 3, 4, 5]
console.log(result.sortedNumbers[0]); // 1
// processUserInputs 不需要知道返回類型
processedValues[config.resultKey] = config.processor(...);
// ↑ 這行程式碼適用於所有類型
優點:
✅ 靈活:不限制返回類型
簡單:不需要類型檢查
擴展性強:未來可以返回更複雜的結構
注意:
⚠️ 沒有類型安全(TypeScript 可以解決)
⚠️ 需要文件說明返回格式
2 ** 3 = 8
2 ** 0 = 1
2 ** -1 = 0.5
// 優於 Math.pow
Math.pow(2, 3) // 舊寫法
2 ** 3 // 新寫法(更簡潔)
25 % 20 = 5 // 取得除法餘數
20 % 20 = 0 // 剛好整除
19 % 20 = 19 // 小於除數
Math.floor(4.9) = 4 // 無條件捨去
Math.floor(-4.1) = -5 // 往負無窮方向捨去
Math.floor(114.999) = 114 // ⚠️ 注意浮點數精度
Number.EPSILON = 2.220446049250313e-16
// 用途:補償浮點數精度誤差
Math.floor(114.99999999999999 + Number.EPSILON) = 115 // ✅
1. 列出輸入維度 → initialBacteria, minutesPassed
2. 劃分等價類 → 初始/完整週期/線性/混合
3. 找出邊界值 → 0, 20, 40 分鐘
4. 錯誤猜測 → 浮點數、大數值
5. 優先級排序 → 核心邏輯 > 邊界 > 極端
6. 寫測試案例 → 從優先級 1 開始
循環時間:20-60 秒
只寫剛好失敗的測試
只寫剛好通過的程式碼
重構改善設計,不改變行為
只在以下條件皆符合時才提交:
所有測試皆通過
所有編譯器或程式碼檢查警告皆已解決
變更為單一邏輯工作單元
採用「小而頻繁」的提交
// Config:定義結構和驗證規則
export const questionConfigs = [...]
// Processor:處理業務邏輯
export function calculateBacteriaGrowth(...) {...}
// Main:組合和輸出
async function main() {...}
// ✅ 好命名
resultKey: "numberOfBacteria"
resultKey: "finalScore"
resultKey: "totalPrice"
// ❌ 差命名
resultKey: "result" // 太通用
resultKey: "num" // 縮寫
resultKey: "data" // 模糊
// 強制驗證:有 processor 必須有 resultKey
if (config.processor && !config.resultKey) {
throw new Error(`Missing resultKey for config "${config.name}"`);
}
技術知識:
TDD 超小步推進實戰(9 個測試案例)
系統性測試設計方法(等價類、邊界值、錯誤猜測)
JavaScript 浮點數精度問題與 Number.EPSILON
Config-Driven 架構設計與重構
軟技能:
問題分解能力(將複雜需求拆成小循環)
Debug 思維(從測試失敗追蹤到根本原因)
設計決策能力(語義化 vs. 通用化的權衡)
重構勇氣(發現架構缺陷後果斷改進)
不是「先寫測試」,而是「讓測試引導設計」
每個測試都是一個需求規格
測試失敗指出下一步要做什麼
重構時測試提供安全網
累積的測試套件就是活文件
JavaScript 的浮點數不是 Bug,是特性
IEEE 754 是標準,不是 JavaScript 特有
不只 JavaScript,所有語言都有此問題
關鍵是「知道何時需要補償」
用 TDD 發現問題比預防性設計更精準
好的架構是演進出來的,不是一次設計對的
先滿足需求(MVP)
遇到問題時重構(Pain-Driven Refactoring)
每次重構都讓系統更好一點
測試保證重構不破壞功能
日期:2026/01/09
主題:TDD 超小步推進、測試設計方法、Number.EPSILON、架構設計
本筆記記錄一個真實的 TDD 開發流程,包含:
使用 Uncle Bob 的超小步 TDD 實作複雜邏輯
系統性測試設計方法(等價類劃分、邊界值分析)
JavaScript 浮點數精度問題的發現與解決
架構設計缺陷的發現與重構
實戰案例:實作一個「指數成長 + 線性近似」的計算器(生物細菌分裂模擬)
實作一個計算函數,模擬細菌分裂實驗:
假設條件:
1. 沒有細菌死亡
2. 初始有 b 隻細菌
3. 每經過 20 分鐘,細菌數量翻倍(指數成長)
4. 不足 20 分鐘的部分用線性成長近似
輸入:initialBacteria(初始數量),minutesPassed(經過分鐘)
輸出:最終細菌數量(無條件捨去小數)
設 m 為經過的分鐘數,b 為初始細菌數:
完整週期數:( \text{fullCycles} = \lfloor m / 20 \rfloor )
完整週期後細菌數:( \text{afterFullCycles} = b \times 2^{\text{fullCycles}} )
剩餘分鐘:( \text{remainingMinutes} = m \mod 20 )
線性成長增量:( \text{linearGrowth} = \text{afterFullCycles} \times (\text{remainingMinutes} / 20) )
最終細菌數:( \lfloor \text{afterFullCycles} + \text{linearGrowth} \rfloor )
// 範例 1:0 分鐘
calculateBacteriaGrowth(100, 0) → 100
// 範例 2:20 分鐘(1 個完整週期)
100 × 2^1 = 200
// 範例 3:10 分鐘(半個週期)
基數:100 × 2^0 = 100
線性成長:100 × (10/20) = 50
總數:100 + 50 = 150
// 範例 4:30 分鐘(1 個完整週期 + 10 分鐘)
基數:100 × 2^1 = 200
線性成長:200 × (10/20) = 100
總數:200 + 100 = 300
根據 Clean Coder Blog:
第一定律:在寫失敗測試之前,不得寫任何生產程式碼
第二定律:測試程式碼只寫到剛好失敗為止
第三定律:生產程式碼只寫到剛好讓當前失敗測試通過為止
循環時間:每個 Red-Green-Refactor 循環約 20-40 秒
// calculateBacteriaGrowth.test.js
import { describe, it, expect } from 'vitest';
import { calculateBacteriaGrowth } from './calculateBacteriaGrowth.js';
describe('calculateBacteriaGrowth', () => {
it('0 分鐘後,應回傳初始細菌數量', () => {
expect(calculateBacteriaGrowth(10, 0)).toBe(10);
});
});
// 執行:npx vitest
// 結果:❌ FAIL - Cannot find module
// calculateBacteriaGrowth.js
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
return initialBacteria; // 最簡單的實作
}
// 執行:npx vitest
// 結果:✅ PASS (1/1)
時間:30 秒
it('20 分鐘後,應回傳初始細菌數量的 2 倍', () => {
expect(calculateBacteriaGrowth(10, 20)).toBe(20);
});
// 結果:❌ FAIL
// Expected: 20
// Received: 10
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const cycleMinutes = 20;
const growthRate = 2;
const fullCycles = Math.floor(minutesPassed / cycleMinutes);
return initialBacteria * (growthRate ** fullCycles);
}
// 結果:✅ PASS (2/2)
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
return initialBacteria * (GROWTH_RATE ** fullCycles);
}
時間:40 秒
it('10 分鐘後,應使用線性近似計算', () => {
expect(calculateBacteriaGrowth(100, 10)).toBe(150);
// 計算:100 + (100 × 10/20) = 150
});
// 結果:❌ FAIL
// Expected: 150
// Received: 100
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
const fullCycleMultiplier = GROWTH_RATE ** fullCycles;
const remainingMinutes = minutesPassed % CYCLE_MINUTES;
const linearGrowthMultiplier = fullCycleMultiplier * (remainingMinutes / CYCLE_MINUTES);
const totalGrowth = initialBacteria * (fullCycleMultiplier + linearGrowthMultiplier);
return Math.floor(totalGrowth);
}
// 結果:✅ PASS (3/3)
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
// 計算完整週期數
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
const exponentialMultiplier = GROWTH_RATE ** fullCycles;
// 計算剩餘分鐘的線性成長
const remainingMinutes = minutesPassed % CYCLE_MINUTES;
const linearMultiplier = exponentialMultiplier * (remainingMinutes / CYCLE_MINUTES);
// 合併指數成長與線性成長
const totalMultiplier = exponentialMultiplier + linearMultiplier;
return Math.floor(initialBacteria * totalMultiplier);
}
時間:60 秒
優點:
快速反饋:每次只檢查最後幾行程式碼
防止過度設計:只在測試驅動下加入邏輯
累積測試套件:每個循環都留下可執行的規格
信心倍增:重構時有測試保護
循環節奏:
每個循環 20-60 秒
一小時可完成 30-60 個循環
保持專注,立即獲得反饋
核心概念:將輸入範圍分成「行為相似」的區塊,每個區塊選一個代表值測試。
// 維度 1:minutesPassed(經過的分鐘數)
等價類 1:0 分鐘(初始狀態)
等價類 2:完整週期(20, 40, 60...)
等價類 3:不足一個週期(1-19 分鐘)
等價類 4:混合情況(完整週期 + 剩餘分鐘)
// 維度 2:remainingMinutes(剩餘分鐘)
等價類 A:0 分鐘(剛好完整週期)
等價類 B:半個週期(10 分鐘,線性成長 50%)
等價類 C:其他分鐘數(需捨去小數)
// 維度 3:initialBacteria(初始細菌數)
等價類 α:小數量(10, 50)
等價類 β:中等數量(100, 200)
等價類 γ:大數量(1000+)
等價類 | 代表值 | 測試目的 |
|---|---|---|
初始狀態 |
| 驗證邊界條件 |
1 個完整週期 |
| 驗證指數成長 |
2 個完整週期 |
| 驗證多週期 |
半個週期 |
| 驗證線性近似 |
混合場景 |
| 驗證指數+線性 |
核心概念:錯誤最常發生在「邊界」,測試邊界值和邊界±1的值。
對於範圍 [min, max],測試:
最小值:min
最小值+1:min + 1
最大值-1:max - 1
最大值:max
// 週期邊界(20 分鐘)
it('19 分鐘:週期前 1 分鐘', () => {
expect(calculateBacteriaGrowth(100, 19)).toBe(195);
// 100 × (1 + 19/20) = 195
});
it('20 分鐘:剛好完整週期', () => {
expect(calculateBacteriaGrowth(100, 20)).toBe(200);
});
it('21 分鐘:週期後 1 分鐘', () => {
expect(calculateBacteriaGrowth(100, 21)).toBe(210);
// 200 × (1 + 1/20) = 210
});
// 初始狀態邊界
it('0 分鐘:邊界', () => {
expect(calculateBacteriaGrowth(100, 0)).toBe(100);
});
it('1 分鐘:最小正整數', () => {
expect(calculateBacteriaGrowth(100, 1)).toBe(105);
// 100 × (1 + 1/20) = 105
});
核心概念:基於經驗預測可能出錯的地方。
// 錯誤 1:浮點數精度問題
// 3 分鐘:100 × (1 + 3/20) = 100 × 1.15 = 114.999... (可能)
it('3 分鐘:應正確處理浮點數', () => {
expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});
// 錯誤 2:整除問題
// 40 分鐘:剩餘分鐘應為 0,不應觸發線性計算
it('40 分鐘:2 個完整週期', () => {
expect(calculateBacteriaGrowth(100, 40)).toBe(400);
});
// 錯誤 3:指數運算錯誤
// 0 ** 0 在數學上有爭議,JavaScript 定義為 1
it('0 分鐘:驗證 2^0 = 1', () => {
expect(calculateBacteriaGrowth(100, 0)).toBe(100);
});
測試類型 | 涵蓋範圍 | 測試數量 | 重要性 |
|---|---|---|---|
等價類劃分 | 初始/完整週期/線性/混合 | 4-5 個 | ⭐⭐⭐⭐⭐ |
邊界值分析 | 0, 20, 40 分鐘邊界 | 3-4 個 | ⭐⭐⭐⭐ |
浮點數處理 | Math.floor 正確性 | 1-2 個 | ⭐⭐⭐⭐ |
不同初始值 | 參數化測試 | 2-3 個 | ⭐⭐⭐ |
// calculateBacteriaGrowth.test.js
import { describe, it, expect } from 'vitest';
import { calculateBacteriaGrowth } from './calculateBacteriaGrowth.js';
describe('calculateBacteriaGrowth', () => {
describe('邊界測試', () => {
it('0 分鐘後,應回傳初始細菌數量', () => {
expect(calculateBacteriaGrowth(100, 0)).toBe(100);
});
});
describe('完整週期測試', () => {
it('20 分鐘:1 個完整週期', () => {
expect(calculateBacteriaGrowth(100, 20)).toBe(200);
});
it('40 分鐘:2 個完整週期', () => {
expect(calculateBacteriaGrowth(100, 40)).toBe(400);
});
});
describe('線性成長測試', () => {
it('10 分鐘:半個週期線性成長', () => {
expect(calculateBacteriaGrowth(100, 10)).toBe(150);
});
it('5 分鐘:1/4 週期線性成長', () => {
expect(calculateBacteriaGrowth(100, 5)).toBe(125);
});
});
describe('混合場景測試', () => {
it('30 分鐘:1 週期 + 10 分鐘', () => {
expect(calculateBacteriaGrowth(100, 30)).toBe(300);
});
it('25 分鐘:1 週期 + 5 分鐘', () => {
expect(calculateBacteriaGrowth(100, 25)).toBe(250);
});
it('50 分鐘:2 週期 + 10 分鐘', () => {
expect(calculateBacteriaGrowth(100, 50)).toBe(600);
});
});
describe('浮點數精度測試', () => {
it('3 分鐘:應無條件捨去浮點數', () => {
expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});
it('7 分鐘:應無條件捨去浮點數', () => {
expect(calculateBacteriaGrowth(100, 7)).toBe(135);
});
});
});
// 執行:npx vitest
// 結果:✅ PASS (9/9 tests)
執行測試時發現:
// 測試
it('3 分鐘:應無條件捨去浮點數', () => {
expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});
// 結果:❌ FAIL
// Expected: 115
// Received: 114
// 加入 console.log 追蹤
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / 20);
const exponentialMultiplier = 2 ** fullCycles;
const remainingMinutes = minutesPassed % 20;
const linearMultiplier = exponentialMultiplier * (remainingMinutes / 20);
console.log('exponentialMultiplier:', exponentialMultiplier); // 1
console.log('linearMultiplier:', linearMultiplier); // 0.15
console.log('Sum:', exponentialMultiplier + linearMultiplier); // 1.15
const totalMultiplier = exponentialMultiplier + linearMultiplier;
const result = initialBacteria * totalMultiplier;
console.log('Before floor:', result); // 114.99999999999999 ← 問題!
console.log('After floor:', Math.floor(result)); // 114
return Math.floor(result);
}
JavaScript 使用雙精度浮點數(IEEE 754),某些十進制小數無法精確表示:
// 預期
100 * 1.15 = 115.0
// 實際(IEEE 754)
100 * 1.15 = 114.99999999999999
// Math.floor 導致錯誤
Math.floor(114.99999999999999) = 114 // ❌
原理:
電腦使用二進制表示數字
1.15 在二進制中是無限循環小數
儲存時會產生微小誤差
誤差累積導致 114.999... 而非 115.0
根據 ECMAScript 規範和 MDN:
Number.EPSILON 是「1 與大於 1 的最小浮點數之間的差值」
值為:2.220446049250313e-16
const CYCLE_MINUTES = 20;
const GROWTH_RATE = 2;
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
const fullCycles = Math.floor(minutesPassed / CYCLE_MINUTES);
const exponentialMultiplier = GROWTH_RATE ** fullCycles;
const remainingMinutes = minutesPassed % CYCLE_MINUTES;
const linearMultiplier = exponentialMultiplier * (remainingMinutes / CYCLE_MINUTES);
const totalMultiplier = exponentialMultiplier + linearMultiplier;
// ✅ 加入 Number.EPSILON 補償浮點數誤差
return Math.floor(initialBacteria * totalMultiplier + Number.EPSILON);
}
// 執行測試
// 結果:✅ PASS (9/9 tests)
// 修正前
114.99999999999999 + 0 = 114.99999999999999
Math.floor(114.99999999999999) = 114 // ❌
// 修正後
114.99999999999999 + 2.220446049250313e-16 ≈ 115.0
Math.floor(115.0) = 115 // ✅
關鍵:EPSILON 足夠小,不會影響正確的計算,但足夠補償精度誤差。
需要 EPSILON 的充要條件:
1. 涉及浮點數運算(除法、乘法、特定小數)✅
2. 使用 Math.floor/ceil/round 等整數化函數 ✅
3. 運算結果可能接近整數邊界(例如 114.999... 接近 115)✅
需要 EPSILON:
// 場景 1:Math.floor 處理接近整數的浮點數
const price = 100;
const taxRate = 1.15;
Math.floor(price * taxRate + Number.EPSILON); // 115 ✅
// 場景 2:比較浮點數相等性
function areEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON;
}
areEqual(0.1 + 0.2, 0.3); // true ✅
// 場景 3:除法產生無限循環小數
const ratio = 3 / 20; // 0.15
Math.floor(100 * (1 + ratio) + Number.EPSILON); // 115 ✅
❌ 不需要 EPSILON:
// 場景 1:整數運算
const cycles = Math.floor(30 / 20); // 1(整數除法)
// 場景 2:結果本身是浮點數(不做整數化)
const average = (100 + 115) / 2; // 107.5(保持小數)
// 場景 3:只有加減法的整數
const total = 100 + 50 + 25; // 175(整數加法)
// 在所有 Math.floor 前都加 EPSILON
const result = Math.floor(value + Number.EPSILON);
優點:
一勞永逸,避免未來出錯
程式碼明確表達「處理浮點數精度」的意圖
缺點:
⚠️ 增加複雜度(如果運算本來就是整數)
⚠️ 可能隱藏真正的邏輯錯誤
// 先不加,讓測試發現問題
const result = Math.floor(value);
// 測試失敗後,精確定位並修復
const result = Math.floor(value + Number.EPSILON);
優點:
✅ YAGNI 原則:不過度設計
測試案例記錄了問題場景
只在真正需要的地方加入
缺點:
⚠️ 依賴測試覆蓋率
遇到 Math.floor/Math.ceil 時,問自己:
□ 有浮點數運算嗎?(除法、乘法、特定小數)
→ 是 → 繼續檢查
→ 否 → 不需要 EPSILON
□ 結果可能接近整數嗎?(例如 114.999...)
→ 是 → 需要 EPSILON
→ 否 → 不需要 EPSILON
□ 有測試覆蓋嗎?
→ 是 → 讓測試指引你
→ 否 → 預防性加入
假設我們要建立一個通用的 CLI 問答系統,可以透過配置文件定義問題和處理邏輯:
// 使用範例
const questionConfigs = [
{
name: "initialBacteria",
prompt: "請輸入初始細菌數量:",
validator: isPositiveInteger,
},
{
name: "minutesPassed",
prompt: "請輸入經過分鐘數:",
validator: isNonNegativeInteger,
processor: ({ initialBacteria, minutesPassed }) =>
calculateBacteriaGrowth(initialBacteria, minutesPassed),
},
];
const result = await runQuestions(questionConfigs);
console.log('結果:', result);
// processUserInputs.js
export function processUserInputs(userInputs, questionConfigs) {
const processedValues = {};
for (const config of questionConfigs) {
const input = userInputs[config.name];
if (config.processor) {
// ❌ 問題:用 config.name 作為結果的 key
processedValues[config.name] = config.processor(userInputs);
} else {
processedValues[config.name] = input;
}
}
return processedValues;
}
// Config
{
name: "minutesPassed",
processor: ({ initialBacteria, minutesPassed }) =>
calculateBacteriaGrowth(initialBacteria, minutesPassed),
// processor 返回:300(細菌數量)
}
// processUserInputs 執行後
processedValues.minutesPassed = 20; // 1. 先存入輸入值(20 分鐘)
processedValues.minutesPassed = 300; // 2. 被 processor 覆蓋(300 隻細菌)
// 問題:無法同時保留輸入和輸出
console.log(result.minutesPassed); // 300(細菌數)
// 原本的 20 分鐘不見了!
// 期望的輸出
console.log(`經過 ${result.minutesPassed} 分鐘後,細菌數量為 ${result.numberOfBacteria} 隻`);
// 實際輸出
console.log(`經過 ${result.minutesPassed} 分鐘後,細菌數量為 ${result.minutesPassed} 隻`);
// → "經過 300 分鐘後,細菌數量為 300 隻" ❌ 語義錯誤!
// processUserInputs.js(改進版)
export function processUserInputs(userInputs, questionConfigs) {
const processedValues = {};
// 第一階段:收集所有輸入
for (const config of questionConfigs) {
processedValues[config.name] = userInputs[config.name];
}
// 第二階段:執行 processor
for (const config of questionConfigs) {
if (config.processor) {
// ✅ 驗證:強制要求有 resultKey
if (!config.resultKey) {
throw new Error(
`Config "${config.name}" has processor but missing resultKey.`
);
}
// ✅ 結果存到獨立欄位
processedValues[config.resultKey] = config.processor(processedValues);
}
}
return processedValues;
}
// bacteriaGrowthConfig.js
export const questionConfigs = [
{
name: "initialBacteria",
prompt: "請輸入初始細菌數量:",
validator: isPositiveInteger,
},
{
name: "minutesPassed",
prompt: "請輸入經過分鐘數:",
validator: isNonNegativeInteger,
needsAllFields: true, // 標記需要多個輸入
resultKey: "numberOfBacteria", // ✅ 指定結果欄位名
processor: ({ initialBacteria, minutesPassed }) =>
calculateBacteriaGrowth(initialBacteria, minutesPassed),
},
];
// main.js
const result = await runQuestions(questionConfigs);
// ✅ 輸入和輸出分離
console.log('輸入的初始細菌數:', result.initialBacteria); // 100
console.log('輸入的經過分鐘數:', result.minutesPassed); // 30
console.log('計算的細菌數量:', result.numberOfBacteria); // 300
// ✅ 語義正確的輸出
console.log(`經過 ${result.minutesPassed} 分鐘後,細菌數量約為 ${result.numberOfBacteria} 隻`);
// → "經過 30 分鐘後,細菌數量約為 300 隻" ✅
方案 A:統一用 result(通用化)
// Config
resultKey: "result", // 所有題目都用 result
// Main
console.log('結果:', data.result);
// 🤔 result 是什麼?細菌數?分數?金額?
優點:
✅ 模板化程度高,複製貼上即可
不用思考命名
缺點:
語義模糊:看到 result 不知道是什麼
Debug 時不友善
複雜場景難擴展(如果需要多個結果?)
方案 B:語義化命名(最終選擇)
// Config
resultKey: "numberOfBacteria", // 清楚表達業務意義
// Main
console.log(`細菌數量:${data.numberOfBacteria} 隻`);
// ✅ 一眼看出是細菌數量
優點:
✅ 可讀性高,一眼看出是什麼結果
符合 Clean Code 原則(自文件化)
Debug 友善
易於擴展多結果場景
缺點:
⚠️ 每題要思考命名(但只多花 10 秒)
⚠️ 模板不能完全統一(但可以模式化)
// 推薦:使用語義化命名
// 範例 1:細菌實驗
resultKey: "numberOfBacteria"
// 範例 2:成績計算
resultKey: "finalGrade"
// 範例 3:價格計算
resultKey: "totalPrice"
// 範例 4:比較結果
resultKey: "comparisonResult"
理由:
✅ 六個月後回來看程式碼,立即理解
別人接手時不用猜測
複雜題目可以擴展(例如返回物件)
符合「程式碼是寫給人看的」原則
支援任何 JavaScript 類型:
// Config
{
resultKey: "numberOfBacteria",
processor: () => 300, // 返回 number
}
// 使用
console.log(result.numberOfBacteria); // 300
// Config
{
resultKey: "statistics",
processor: ({ numbers }) => ({
average: calculateAverage(numbers),
sum: calculateSum(numbers),
max: Math.max(...numbers),
min: Math.min(...numbers),
}),
}
// 使用
console.log(result.statistics.average); // 50
console.log(result.statistics.sum); // 300
// 或解構
const { average, sum } = result.statistics;
// Config
{
resultKey: "sortedNumbers",
processor: ({ numbers }) => numbers.sort((a, b) => a - b),
}
// 使用
console.log(result.sortedNumbers); // [1, 2, 3, 4, 5]
console.log(result.sortedNumbers[0]); // 1
// processUserInputs 不需要知道返回類型
processedValues[config.resultKey] = config.processor(...);
// ↑ 這行程式碼適用於所有類型
優點:
✅ 靈活:不限制返回類型
簡單:不需要類型檢查
擴展性強:未來可以返回更複雜的結構
注意:
⚠️ 沒有類型安全(TypeScript 可以解決)
⚠️ 需要文件說明返回格式
2 ** 3 = 8
2 ** 0 = 1
2 ** -1 = 0.5
// 優於 Math.pow
Math.pow(2, 3) // 舊寫法
2 ** 3 // 新寫法(更簡潔)
25 % 20 = 5 // 取得除法餘數
20 % 20 = 0 // 剛好整除
19 % 20 = 19 // 小於除數
Math.floor(4.9) = 4 // 無條件捨去
Math.floor(-4.1) = -5 // 往負無窮方向捨去
Math.floor(114.999) = 114 // ⚠️ 注意浮點數精度
Number.EPSILON = 2.220446049250313e-16
// 用途:補償浮點數精度誤差
Math.floor(114.99999999999999 + Number.EPSILON) = 115 // ✅
1. 列出輸入維度 → initialBacteria, minutesPassed
2. 劃分等價類 → 初始/完整週期/線性/混合
3. 找出邊界值 → 0, 20, 40 分鐘
4. 錯誤猜測 → 浮點數、大數值
5. 優先級排序 → 核心邏輯 > 邊界 > 極端
6. 寫測試案例 → 從優先級 1 開始
循環時間:20-60 秒
只寫剛好失敗的測試
只寫剛好通過的程式碼
重構改善設計,不改變行為
只在以下條件皆符合時才提交:
所有測試皆通過
所有編譯器或程式碼檢查警告皆已解決
變更為單一邏輯工作單元
採用「小而頻繁」的提交
// Config:定義結構和驗證規則
export const questionConfigs = [...]
// Processor:處理業務邏輯
export function calculateBacteriaGrowth(...) {...}
// Main:組合和輸出
async function main() {...}
// ✅ 好命名
resultKey: "numberOfBacteria"
resultKey: "finalScore"
resultKey: "totalPrice"
// ❌ 差命名
resultKey: "result" // 太通用
resultKey: "num" // 縮寫
resultKey: "data" // 模糊
// 強制驗證:有 processor 必須有 resultKey
if (config.processor && !config.resultKey) {
throw new Error(`Missing resultKey for config "${config.name}"`);
}
技術知識:
TDD 超小步推進實戰(9 個測試案例)
系統性測試設計方法(等價類、邊界值、錯誤猜測)
JavaScript 浮點數精度問題與 Number.EPSILON
Config-Driven 架構設計與重構
軟技能:
問題分解能力(將複雜需求拆成小循環)
Debug 思維(從測試失敗追蹤到根本原因)
設計決策能力(語義化 vs. 通用化的權衡)
重構勇氣(發現架構缺陷後果斷改進)
不是「先寫測試」,而是「讓測試引導設計」
每個測試都是一個需求規格
測試失敗指出下一步要做什麼
重構時測試提供安全網
累積的測試套件就是活文件
JavaScript 的浮點數不是 Bug,是特性
IEEE 754 是標準,不是 JavaScript 特有
不只 JavaScript,所有語言都有此問題
關鍵是「知道何時需要補償」
用 TDD 發現問題比預防性設計更精準
好的架構是演進出來的,不是一次設計對的
先滿足需求(MVP)
遇到問題時重構(Pain-Driven Refactoring)
每次重構都讓系統更好一點
測試保證重構不破壞功能
Share Dialog
Share Dialog
No comments yet