Day 41:什麼是優良的單元測試;用 flatMap、Array.from 與 Fisher‑Yates 洗牌設計隨機分配邏輯

一、從《單元測試的藝術》理解「好測試」長什麼樣子

今天讀的是《單元測試的藝術(第 3 版)》第一章 1.7.2 之後的內容,作者在這裡聚焦在兩件事:

  1. 區分「單元測試」和「整合測試」。

  2. 定義什麼是「好的單元測試」。

1. 單元測試 vs. 整合測試:關鍵差在「依賴控制」

作者給的定義可以濃縮成一句話:

單元測試測的是一個「工作單元」,而且完全控制該單元所有的依賴;
整合測試則是讓這個單元真的去碰外部依賴(資料庫、網路、時間、隨機數等)。

換句話說:

  • 單元測試:

    • 工作單元(被測試的 function / class)要跟依賴「拆開」,用 stub/mock 或其他方式隔離。

    • 測試應該能在沒資料庫、沒網路、沒部署的情況下跑完,結果也要穩定。

  • 整合測試:

    • 允許、甚至刻意使用真實依賴,例如:

      • 真實資料庫連線

      • 真實 HTTP 請求

      • 真實時間(Date.now()

      • 真實隨機數(Math.random()

    • 用來驗證系統各部分「真的串得起來」。

這也解釋了為什麼很多「看起來像單元測試」的測試,其實是整合測試:只要你沒完全掌握依賴(例如測試會打到線上 API),它的結果就容易不穩定,也就稱不上嚴格意義的單元測試 。 kojenchieh.pixnet

2. 單元測試檢查清單

作者列了一份清單,來檢查你寫的是不是「真正的單元測試」:

  • 可以隨時重跑兩週前、兩個月前、兩年前的測試嗎?

  • 團隊成員可以在自己的機器上跑你寫的測試嗎?

  • 所有測試可以在幾分鐘內跑完嗎?

  • 能不能「一鍵」跑完全部測試?

  • 一個基本的測試能在幾分鐘內寫完嗎?

  • 別的團隊的程式壞掉時,你的測試會不會跟著掛掉?

  • 在不同機器或環境上執行,結果會一樣嗎?

  • 沒有資料庫、網路或部署的情況下,測試還跑得起來嗎?

  • 刪除/移動一個測試,不會影響其他測試嗎?

只要其中有一題回答是「否」,作者就傾向把那個測試視為整合測試,而不是單元測試 。 gist.github 這個觀點滿實際的:與其在定義上糾結,不如看它實際上是不是「小而快、穩定可重現」。

3. 一個「優良單元測試」的定義

作者對「好單元測試」的定義,可以簡短改寫成:

單元測試是一段自動化的程式碼,透過某個進入點呼叫工作單元,並檢查一個或少數明確的行為假設。它應該:

  • 易寫、跑得快。

  • 完全自動化,可重複執行。

  • 在相同輸入與相同程式碼下,每次結果都一樣(不依賴隨機、時間、外部資源)。

  • 具可讀性、可維護性。

  • 值得信任:看到它紅/綠時,不需要再開 debugger 二次確認 。 artofunittesting

這裡有幾個關鍵字值得記在心裡:

  • Automated:不能需要人眼看 log 才知道結果。

  • Fast:慢測試會讓人不想常跑,TDD 節奏也會被打亂。

  • Consistent:不能今天紅,明天綠,後天又紅(隨機數/時間/外部依賴是常見元兇)。

  • Readable / Maintainable / Trustworthy:好測試本身也要是「好程式碼」。

4. 單元測試不等於 TDD

作者特別提醒:

  • 「會寫單元測試」和「會用 TDD 開發」是兩套不同的技能。

  • TDD 是一種開發流程:先寫測試 → 寫最小實作 → 重構
    它高度仰賴「測試本身是優良的」,不然紅燈/綠燈給的訊號會非常混亂。

在 TDD 的紅燈 → 綠燈 → 重構循環裡,有一個很重要的點:

紅燈階段,在不改測試的前提下,修改產品程式碼讓紅燈變綠燈,其實同時也在「測試測試程式本身」:
測試能在該失敗時失敗、該通過時通過,可信度就會變高。

作者也建議可以把 TDD 拆成三個獨立練習的技能:

  1. 能寫出優良的單元測試(本書重點)。

  2. 能習慣「先寫測試,再寫實作」的節奏。

  3. 能用測試回饋來推動系統設計(例如應用 SOLID 等設計原則)。

這三個可以分階段練,不用一次全部到位。


二、洗牌發牌 Kata:flatMap、Array.from 與 Fisher‑Yates 洗牌

接著是今天實作的小 Kata,可以想像成一個「洗牌發牌系統」:

  • 有一副牌(為了簡化,只看「顏色」或「花色」)。

  • 每種顏色有固定數量,例如 5 種顏色 × 每種 30 張 → 總共 150 張牌。

  • 要先建立牌堆 → 洗牌 → 均勻發給 N 個玩家,每人固定拿 3 張。

這一路剛好練到三組實用的 JS 語法:

  1. flatMap + Array(n).fill() 生成固定數量的元素。

  2. 用 Fisher-Yates 洗牌做公平隨機。

  3. Array.from + slice 把大陣列分段。

1. 生成給定數量元素的陣列:flatMap + Array(n).fill()

需求:
「每種花色生成固定數量的牌,最後組成一個牌堆(一維陣列)」。

可以用 Array(n).fill() 搭配 flatMap() 寫得很精簡 : developer.mozilla

const suits = ['♠️', '♥️', '♦️', '♣️']; // 或顏色:['red', 'blue', ...]
const cardsPerSuit = 30;

const deck = suits.flatMap((suit) => Array(cardsPerSuit).fill(suit));
// deck 範例:['♠️', '♠️', ..., '♥️', '♥️', ..., '♣️', ...]

拆解:

  • Array(cardsPerSuit) 建立一個長度為 cardsPerSuit 的陣列,搭配 .fill(suit) 後變成每個位置都是該花色的「密集陣列」。 developer.mozilla

  • suits.flatMap(...)

    1. 對每個 suit 產生一段陣列。

    2. 把所有小陣列攤平成一個大陣列(只攤平一層)。 developer.mozilla

相較之下,flat() 只做「攤平」,不做轉換;flatMap() 可以同時完成「轉換 + 攤平一層」,在這種「一對多展開」場景特別好用 。 developer.mozilla

2. 隨機排序:從「亂數映射」到 Fisher-Yates 洗牌

一開始直覺的想法是:

做一個亂數索引陣列,再用它把原本排好序的牌堆重新映射。

概念示意:

const deck = ['A', 'B', 'C', 'D'];
const indices = [2, 0, 3, 1];

const shuffled = indices.map((i) => deck[i]);
// shuffled → ['C', 'A', 'D', 'B']

但這種做法會多出一個「索引陣列」要管理,而且還要先解決「索引陣列本身如何公平洗牌」這個問題。

後來改用經典的 Fisher-Yates 洗牌stackoverflow

從陣列最後一個元素開始,對每個位置 i,在 [0, i] 範圍內選一個 randomIndex,與 i 交換,然後 i-- 繼續往前。

實作:

export function shuffle(array) {
  for (let i = array.length - 1; i > 0; i -= 1) {
    const randomIndex = Math.floor(Math.random() * (i + 1));
    [array[i], array[randomIndex]] = [array[randomIndex], array[i]];
  }
}

兩種思路對比:

做法

需要的陣列

空間複雜度

思考負擔

亂數映射索引 + 重組

原陣列 + 索引陣列 + 新陣列

O(n) 以上

要先想怎麼把索引洗公平

Fisher-Yates 直接洗原陣列

只有原陣列

O(1) 額外空間

只要想「每步跟誰交換」

Fisher-Yates 的優點 : en.wikipedia

  • 原地(in-place)操作,不用額外索引陣列 。 en.wikipedia

  • 每種排列出現的機率完全相同(公平隨機)。

  • 單一一層迴圈,時間複雜度 O(n)。

在洗牌發牌這種情境裡,牌堆 deck 通常是函式內部變數,不需要重用原順序,因此用 in-place 洗牌是合理又簡潔的選擇。

3. 把陣列依固定長度切片:Array.from + slice

接著要做的是「發牌」:
假設洗好的 deck 有 150 張,要把它切成 50 組,每組 3 張。

可以用 Array.from 搭配 slice

function chunkBySize(array, size) {
  return Array.from(
    { length: Math.ceil(array.length / size) },
    (_, index) => array.slice(index * size, index * size + size),
  );
}

// 範例:
const deck = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
const hands = chunkBySize(deck, 3);
// hands → [['A', 'B', 'C'], ['D', 'E', 'F'], ['G']]

重點:

  • Array.from({ length: N }) 會產生一個長度為 N 的密集陣列,每個索引都存在 。 developer.mozilla

  • 第二個參數是 mapping 函式,類似 map 的 callback:

    • index 就是第幾個切片。

    • array.slice(index * size, index * size + size) 就是該段的子陣列 。 developer.mozilla

這個模式也可以直接套在「洗牌發牌」流程裡:

export function dealHands(playerCount) {
  const suits = ['♠️', '♥️', '♦️', '♣️'];
  const cardsPerSuit = 30;

  const deck = suits.flatMap((suit) => Array(cardsPerSuit).fill(suit));
  shuffle(deck);

  return Array.from(
    { length: playerCount },
    (_, index) => deck.slice(index * 3, index * 3 + 3),
  );
}

4. Array.from({ length: n }) vs Array(n):密集 vs 稀疏陣列

這兩個看起來都在建立長度為 n 的陣列,但差異在「每個索引是否真的有元素」 。 geeksforgeeks

// 稀疏陣列:只留空洞,不填值
const sparse = Array(3);

console.log(sparse);        // [ <3 empty items> ]
console.log(sparse.length); // 3
console.log(sparse[0]);     // undefined
console.log(0 in sparse);   // false ← 索引 0 不存在!

// 密集陣列:每個位置都有「實際元素」,值是 undefined
const dense = Array.from({ length: 3 });

console.log(dense);         // [ undefined, undefined, undefined ]
console.log(dense.length);  // 3
console.log(dense[0]);      // undefined
console.log(0 in dense);    // true ← 索引 0 存在!

差別在於很多陣列方法會跳過空洞,例如:

// ❌ 不會產生 [0, 1, 2]
Array(3).map((_, i) => i);
// → [ <3 empty items> ]

// ✅ 產生 [0, 1, 2]
Array.from({ length: 3 }, (_, i) => i);
// → [0, 1, 2]

// ✅ 或者先 fill 再 map
Array(3).fill(null).map((_, i) => i);
// → [0, 1, 2]

MDN 也明確提到:Array.from() 不會建立稀疏陣列,缺少的 index 會被填成 undefined,因此之後用 map / forEach 等方法拿來做索引運算時,行為會比較預期內 。 developer.mozilla

在這個 Kata 裡,像 chunkBySizedealHands 這種「我要確實跑 n 次 callback」的場景,選擇 Array.from({ length: n }) 會比 Array(n) 安全,也更語意化。


三、語法選擇背後的設計思考

在這次 Kata 的過程裡,順便把幾個「最終沒有被採用」的方案也記錄下來,當作自己的設計決策歷史。

1. 隨機產生花色/顏色的方式

  • 直覺方案:
    suits[Math.floor(Math.random() * suits.length)]
    這是「放回抽樣」,每次從花色陣列隨機挑一個,總數不受限制。

  • 實際需求:
    類似「一副牌有固定張數」,每張牌只能被發一次 → 不放回抽樣

  • 結論:
    先產出完整的牌堆,再用 Fisher-Yates 做不放回的公平洗牌,符合總量守恆的要求 。 stackoverflow

2. 建立牌堆的多種寫法

評估過的版本:

  • 手動展開:[...Array(30).fill('♠️'), ...Array(30).fill('♥️'), ...]
    可行但顏色寫死,之後要改會很痛苦。

  • Array.from({ length: 120 }, (_, i) => suits[Math.floor(i / 30)])
    依賴算式 Math.floor(i / 30),可讀性較差。

  • reduce + concat
    flatMap 冗長,效率與語意都不如 flatMap 明確 。 developer.mozilla

  • flatMap 最終採用:
    suits.flatMap((suit) => Array(cardsPerSuit).fill(suit))
    一眼就看得出「每種花色展開成多張牌再攤平成一個牌堆」。

3. Array(n) vs Array.from({ length: n })

  • Array(n):需要先 .fill() 才能用 map,否則會得到一個 callback 根本沒執行的稀疏陣列 。 geeksforgeeks

  • Array.from({ length: n }):天然產生可遍歷的密集陣列,配合 mapping 函式是最直覺的「產生 n 個元素」工具 。 developer.mozilla

4. 隨機 / I/O 與純邏輯的分拆

  • 核心模組(例如 dealHands, countSomething)盡量寫成純函式:輸入 → 輸出,可預期、可測試。

  • 產生隨機輸入、讀寫 console / 檔案的部分,集中在 main 層或 CLI 層。

  • 這讓單元測試可以專心測邏輯,整合測試再負責「真的跑一輪洗牌發牌」。


四、今天的總結:測試觀念 × 語法選擇

今天的閱讀和實作其實剛好互相呼應:

  • 測試觀念:

    • 好的單元測試要快、穩定、可重複、可相信 。 gist.github

    • 要達到這個目標,必須學會把「工作單元」跟「外部依賴」拆開。

  • 實作上的語法選擇:

    • 用 Fisher-Yates 做不放回洗牌,避免寫出結果會「偏向某些排列」的隨機邏輯 。 youtube

    • flatMap + Array(n).fill() 生成固定數量元素的陣列,語意清楚又好維護 。 developer.mozilla

    • Array.from({ length: n }) + slice 拆分陣列,避開 Array(n) 的稀疏陣列陷阱 。 developer.mozilla

未來要真正用 TDD 寫這類洗牌/發牌/配對邏輯時,可以:

  • 把「生成隨機牌堆」視為整合層,單測只針對純邏輯(例如:給定一個牌堆,發牌結果是否符合規則)。

  • 用「紅燈 → 綠燈 → 重構」循環,持續檢查:
    測試寫得好不好、測試本身是否可信,以及是否真的有幫助設計出更好維護的程式。