Day 16:規格化測試實作、以宣告式寫法重構命令式、DOM 與事件流

今天主要完成兩塊:一是「單元測試的層級該怎麼切才好讀?」,二是「如何把充滿 for 迴圈的命令式程式碼,優化成乾淨的宣告式邏輯」,並整理 DOM/BOM 與事件流的閱讀筆記。


一、測試規格的選擇:什麼時候該用巢狀 describe?

在使用 Vitest 或 Jest 撰寫測試時,我們常會糾結:到底要寫幾層 describe?是越平展越好,還是層層包裹比較清晰?

核心原則其實很簡單:測試報告是寫給人看的規格書。目標不是為了分類而分類,而是為了讓閱讀報告的人能一眼看出「現在壞掉的是哪個情境」。

推薦使用巢狀 describe 的情境

  1. 區分不同面向

    當同一個函式有多種檢查維度時。例如一個字串計算函式:

describe('字串計算器', () => {
  describe('計算正確性', () => { /* 驗證加減乘除結果 */ });
  describe('輸入邊界檢查', () => { /* 驗證非法輸入、空值 */ });
  describe('輸出格式', () => { /* 驗證小數點位數、字串模板 */ });
});

這樣測試報告會呈現:字串計算器 › 輸出格式 › 應保留兩位小數,路徑非常清晰。

  1. 區分不同狀態(Context)

當函式行為依賴外部狀態時:

describe('UserService', () => {
  describe('使用者已登入', () => { /* 測試權限與資料存取 */ });
  describe('使用者未登入', () => { /* 測試轉導與錯誤訊息 */ });
});

  1. 隔離 beforeEach 的作用域

這是一個很實用的技巧。如果你只希望某幾組測試共用一個 setup(例如建立一個含有資料的清單),可以透過巢狀 describe 把 beforeEach 鎖在該層級內,避免汙染全域測試環境。

什麼時候不需要巢狀 describe?

如果你的測試只是針對「同一個邏輯」進行「多組資料窮舉」,請不要再多包一層 describe('多組輸入')。直接使用參數化測試(Parameterized Testing)會更簡潔:

// ✅ 推薦:直接用 test.each
describe('加法運算', () => {
  test.each([
    [1, 2, 3],
    [5, 5, 10],
    [-1, 1, 0]
  ])('%i + %i 應該等於 %i', (a, b, expected) => {
    expect(add(a, b)).toBe(expected);
  });
});

小結判斷標準:

  • 這一群測試講的是「不同的規格/情境」嗎?是 → 包 describe。

  • 這一群測試只是「不同的輸入資料」嗎?是 → 用 test.each。


二、實戰重構:從命令式 (Imperative) 到宣告式 (Declarative)

今天處理了一個經典的資料處理需求:「在指定範圍內找出符合條件的數字(如質數),並計算其衍生的數值(如倍數)」。

第一階段:命令式寫法 (The Naive Way)

一開始為了快速實作,很容易寫出這種充滿 for 迴圈與 if 判斷的程式碼:

// ❌ 充滿細節控制,閱讀時需要腦內編譯
function getRangeData(lower, upper) {
  const result = [];
  for (let i = lower; i <= upper; i++) {
    if (isPrime(i)) {
       // 甚至可能在這裡又寫了一層 for loop 找倍數
       result.push(i);
    }
  }
  return result;
}

這種寫法的缺點在於我們必須手動管理索引(index)、邊界條件以及暫存陣列(temp array),容易出錯且難以閱讀。

第二階段:宣告式重構 (The Refactored Way)

透過 TDD(測試驅動開發)先建立了保護網後,我將上述邏輯重構。首先,抽出一個通用的工具函式來解決「產生範圍」的問題:

// 透過 Array.from 產生連續數列
function createRange(lower, upper) {
  const length = upper - lower + 1;
  return Array.from({ length }, (_, index) => lower + index);
}

接著,主邏輯就可以像寫文章一樣順暢:

// ✅ 關注「我要什麼」而不是「怎麼做」
function getRangeData(lower, upper) {
  return createRange(lower, upper)
    .filter(isPrime)                  // 1. 篩選質數
    .map(num => ({                    // 2. 轉換格式
       prime: num,
       multipliers: findMultipliers(num)
    }));
}

重構心得:

  1. createRange 的價值:把重複的 for 迴圈邏輯抽離後,主程式碼瞬間乾淨了。

  2. Array Method Chaining:善用 filter 接 map,能讓資料流向一目了然(Input → Filter → Transform → Output)。

  3. TDD 的安全感:因為先寫了邊界測試(例如輸入範圍不存在質數時回傳空陣列),重構過程中我完全不擔心改壞功能。


三、JavaScript 基礎複習:DOM 與事件流

除了邏輯訓練,今天也重新整理了瀏覽器運作機制的筆記。

1. 關注點分離 (Separation of Concerns)

前端三要素各司其職:

  • HTML (結構):負責資料語意。

  • CSS (樣式):負責視覺呈現。

  • JavaScript (行為):負責互動邏輯。

原則上,樣式變更應盡量透過 JS 切換 class 來達成,而非直接操作 style 屬性,這樣才能保持 CSS 的可維護性。

2. 事件流 (Event Flow)

post image

from W3C

瀏覽器的事件傳遞並非單向,而是分為三個階段:

  1. 捕捉階段 (Capturing):事件由 window 往下傳遞至目標元素。

  2. 目標階段 (Target):事件到達實際點擊的元素。

  3. 冒泡階段 (Bubbling):事件由目標元素往上回傳至 window。

實戰技巧:

  • event.stopPropagation():這是控制事件流的關鍵,可以用來阻止事件繼續往上冒泡或往下捕捉。在處理複雜的巢狀 UI(如 Modal 內的點擊事件)時非常重要。

  • window vs global:在瀏覽器中,全域變數 var 會掛載在 window 上;但在 Node.js 環境中,全域物件是 global 且行為稍有不同。這是在跨環境開發時需注意的細節。


總結

今天的練習讓我深刻體會到,寫程式除了要把功能做出來,更要注意設計程式碼的結構。無論是測試案例的分組,還是資料處理流程的宣告式寫法,目的都是為了降低未來的維護成本與認知負擔。


參考文件