Day 35:TDD 實作——從浮點數精度到架構重構的完整歷程

日期:2026/01/09
主題:TDD 超小步推進、測試設計方法、Number.EPSILON、架構設計


📝 概述

本筆記記錄一個真實的 TDD 開發流程,包含:

  1. 使用 Uncle Bob 的超小步 TDD 實作複雜邏輯

  2. 系統性測試設計方法(等價類劃分、邊界值分析)

  3. JavaScript 浮點數精度問題的發現與解決

  4. 架構設計缺陷的發現與重構

實戰案例:實作一個「指數成長 + 線性近似」的計算器(生物細菌分裂模擬)


Part 1: 問題定義

需求描述

實作一個計算函數,模擬細菌分裂實驗:

假設條件:
1. 沒有細菌死亡
2. 初始有 b 隻細菌
3. 每經過 20 分鐘,細菌數量翻倍(指數成長)
4. 不足 20 分鐘的部分用線性成長近似

輸入:initialBacteria(初始數量),minutesPassed(經過分鐘)
輸出:最終細菌數量(無條件捨去小數)

核心數學公式

設 m 為經過的分鐘數,b 為初始細菌數:

  1. 完整週期數:( \text{fullCycles} = \lfloor m / 20 \rfloor )

  2. 完整週期後細菌數:( \text{afterFullCycles} = b \times 2^{\text{fullCycles}} )

  3. 剩餘分鐘:( \text{remainingMinutes} = m \mod 20 )

  4. 線性成長增量:( \text{linearGrowth} = \text{afterFullCycles} \times (\text{remainingMinutes} / 20) )

  5. 最終細菌數:( \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

🔄 Part 2: TDD 超小步推進實戰

Uncle Bob 的 TDD 三定律

根據 Clean Coder Blog:

  1. 第一定律:在寫失敗測試之前,不得寫任何生產程式碼

  2. 第二定律:測試程式碼只寫到剛好失敗為止

  3. 第三定律:生產程式碼只寫到剛好讓當前失敗測試通過為止

循環時間:每個 Red-Green-Refactor 循環約 20-40 秒


循環 1:邊界測試(0 分鐘)

🔴 Red:寫失敗測試

// 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

🟢 Green:寫最簡單能通過的程式碼

// calculateBacteriaGrowth.js
export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
  return initialBacteria;  // 最簡單的實作
}

// 執行:npx vitest
// 結果:✅ PASS (1/1)

🔵 Refactor:無需重構

時間:30 秒


循環 2:完整週期(20 分鐘)

🔴 Red:加入新測試

it('20 分鐘後,應回傳初始細菌數量的 2 倍', () => {
  expect(calculateBacteriaGrowth(10, 20)).toBe(20);
});

// 結果:❌ FAIL
// Expected: 20
// Received: 10

🟢 Green:實作指數成長

export function calculateBacteriaGrowth(initialBacteria, minutesPassed) {
  const cycleMinutes = 20;
  const growthRate = 2;
  
  const fullCycles = Math.floor(minutesPassed / cycleMinutes);
  return initialBacteria * (growthRate ** fullCycles);
}

// 結果:✅ PASS (2/2)

🔵 Refactor:提取魔術數字為常數

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 秒


循環 3:線性成長(10 分鐘)

🔴 Red:測試半個週期

it('10 分鐘後,應使用線性近似計算', () => {
  expect(calculateBacteriaGrowth(100, 10)).toBe(150);
  // 計算:100 + (100 × 10/20) = 150
});

// 結果:❌ FAIL
// Expected: 150
// Received: 100

🟢 Green:加入線性計算

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)

🔵 Refactor:改善變數命名

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 秒


TDD 實踐心得

優點:

  • 快速反饋:每次只檢查最後幾行程式碼

  • 防止過度設計:只在測試驅動下加入邏輯

  • 累積測試套件:每個循環都留下可執行的規格

  • 信心倍增:重構時有測試保護

循環節奏:

  • 每個循環 20-60 秒

  • 一小時可完成 30-60 個循環

  • 保持專注,立即獲得反饋


📚 Part 3: 系統性測試設計方法

1. 等價類劃分(Equivalence Partitioning)

核心概念:將輸入範圍分成「行為相似」的區塊,每個區塊選一個代表值測試。

輸入維度分析

// 維度 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+)

測試案例選擇

等價類

代表值

測試目的

初始狀態

(100, 0)

驗證邊界條件

1 個完整週期

(100, 20)

驗證指數成長

2 個完整週期

(100, 40)

驗證多週期

半個週期

(100, 10)

驗證線性近似

混合場景

(100, 30)

驗證指數+線性


2. 邊界值分析(Boundary Value Analysis)

核心概念:錯誤最常發生在「邊界」,測試邊界值和邊界±1的值。

BVA 測試策略

對於範圍 [min, max],測試:

  1. 最小值:min

  2. 最小值+1:min + 1

  3. 最大值-1:max - 1

  4. 最大值: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
});

3. 錯誤猜測(Error Guessing)

核心概念:基於經驗預測可能出錯的地方。

潛在錯誤點

// 錯誤 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)

🐛 Part 4: Number.EPSILON 浮點數精度問題

問題發現

執行測試時發現:

// 測試
it('3 分鐘:應無條件捨去浮點數', () => {
  expect(calculateBacteriaGrowth(100, 3)).toBe(115);
});

// 結果:❌ FAIL
// Expected: 115
// Received: 114

Debug 過程

// 加入 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);
}

根本原因:IEEE 754 浮點數表示

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


解決方案:Number.EPSILON

EPSILON 的定義

根據 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 足夠小,不會影響正確的計算,但足夠補償精度誤差。


何時需要 Number.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(整數加法)

TDD vs. 預防性設計

方案 A:預防性加入(保守)

// 在所有 Math.floor 前都加 EPSILON
const result = Math.floor(value + Number.EPSILON);

優點:

  • 一勞永逸,避免未來出錯

  • 程式碼明確表達「處理浮點數精度」的意圖

缺點:

  • ⚠️ 增加複雜度(如果運算本來就是整數)

  • ⚠️ 可能隱藏真正的邏輯錯誤

方案 B:TDD 驅動修復(推薦)

// 先不加,讓測試發現問題
const result = Math.floor(value);

// 測試失敗後,精確定位並修復
const result = Math.floor(value + Number.EPSILON);

優點:

  • ✅ YAGNI 原則:不過度設計

  • 測試案例記錄了問題場景

  • 只在真正需要的地方加入

缺點:

  • ⚠️ 依賴測試覆蓋率

推薦策略:混合方法


快速檢查表

遇到 Math.floor/Math.ceil 時,問自己:

□ 有浮點數運算嗎?(除法、乘法、特定小數)
  → 是 → 繼續檢查
  → 否 → 不需要 EPSILON

□ 結果可能接近整數嗎?(例如 114.999...)
  → 是 → 需要 EPSILON
  → 否 → 不需要 EPSILON

□ 有測試覆蓋嗎?
  → 是 → 讓測試指引你
  → 否 → 預防性加入

🏗️ Part 5: 架構設計 - Config-Driven Pattern

背景:可配置的 CLI 工具

假設我們要建立一個通用的 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 隻" ❌ 語義錯誤!

解決方案:引入 resultKey

架構改進

// 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;
}

新的 Config 設計

// 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 隻" ✅

設計決策:語義化 vs. 通用化

考慮過的方案

方案 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"

理由:

  1. ✅ 六個月後回來看程式碼,立即理解

  2. 別人接手時不用猜測

  3. 複雜題目可以擴展(例如返回物件)

  4. 符合「程式碼是寫給人看的」原則


resultKey 的靈活性

支援任何 JavaScript 類型:

純值(Primitive)

// Config
{
  resultKey: "numberOfBacteria",
  processor: () => 300,  // 返回 number
}

// 使用
console.log(result.numberOfBacteria);  // 300

物件(Object)

// 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;

陣列(Array)

// 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

架構優勢總結

JavaScript 的動態特性

// processUserInputs 不需要知道返回類型
processedValues[config.resultKey] = config.processor(...);
// ↑ 這行程式碼適用於所有類型

優點:

  • ✅ 靈活:不限制返回類型

  • 簡單:不需要類型檢查

  • 擴展性強:未來可以返回更複雜的結構

注意:

  • ⚠️ 沒有類型安全(TypeScript 可以解決)

  • ⚠️ 需要文件說明返回格式


關鍵技術知識總結

1. JavaScript 語言特性

指數運算子 **(ES2016)

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()

Math.floor(4.9) = 4    // 無條件捨去
Math.floor(-4.1) = -5  // 往負無窮方向捨去
Math.floor(114.999) = 114  // ⚠️ 注意浮點數精度

Number.EPSILON

Number.EPSILON = 2.220446049250313e-16

// 用途:補償浮點數精度誤差
Math.floor(114.99999999999999 + Number.EPSILON) = 115  // ✅

2. 測試設計方法

系統性測試流程

1. 列出輸入維度 → initialBacteria, minutesPassed
2. 劃分等價類 → 初始/完整週期/線性/混合
3. 找出邊界值 → 0, 20, 40 分鐘
4. 錯誤猜測 → 浮點數、大數值
5. 優先級排序 → 核心邏輯 > 邊界 > 極端
6. 寫測試案例 → 從優先級 1 開始

測試覆蓋平衡


3. TDD 實踐原則

Uncle Bob 的建議

  • 循環時間:20-60 秒

  • 只寫剛好失敗的測試

  • 只寫剛好通過的程式碼

  • 重構改善設計,不改變行為

Kent Beck 的 Commit 規範

只在以下條件皆符合時才提交:

  1. 所有測試皆通過

  2. 所有編譯器或程式碼檢查警告皆已解決

  3. 變更為單一邏輯工作單元

  4. 採用「小而頻繁」的提交


4. 架構設計原則

職責分離

// 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}"`);
}

🏆 總結與反思

今日學習成果

技術知識:

  1. TDD 超小步推進實戰(9 個測試案例)

  2. 系統性測試設計方法(等價類、邊界值、錯誤猜測)

  3. JavaScript 浮點數精度問題與 Number.EPSILON

  4. Config-Driven 架構設計與重構

軟技能:

  1. 問題分解能力(將複雜需求拆成小循環)

  2. Debug 思維(從測試失敗追蹤到根本原因)

  3. 設計決策能力(語義化 vs. 通用化的權衡)

  4. 重構勇氣(發現架構缺陷後果斷改進)


關鍵啟發

1. TDD 的真正價值

不是「先寫測試」,而是「讓測試引導設計」

  • 每個測試都是一個需求規格

  • 測試失敗指出下一步要做什麼

  • 重構時測試提供安全網

  • 累積的測試套件就是活文件

2. 浮點數問題的普遍性

JavaScript 的浮點數不是 Bug,是特性

  • IEEE 754 是標準,不是 JavaScript 特有

  • 不只 JavaScript,所有語言都有此問題

  • 關鍵是「知道何時需要補償」

  • 用 TDD 發現問題比預防性設計更精準

3. 架構設計的演進

好的架構是演進出來的,不是一次設計對的

  • 先滿足需求(MVP)

  • 遇到問題時重構(Pain-Driven Refactoring)

  • 每次重構都讓系統更好一點

  • 測試保證重構不破壞功能


📚 參考資源

文章與文件

測試方法

工具與框架

123