# Day 41:什麼是優良的單元測試;用 flatMap、Array.from 與 Fisher‑Yates 洗牌設計隨機分配邏輯 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2026-01-19 **URL:** https://paragraph.com/@gcake/day-41 ## Content 一、從《單元測試的藝術》理解「好測試」長什麼樣子今天讀的是《單元測試的藝術(第 3 版)》第一章 1.7.2 之後的內容,作者在這裡聚焦在兩件事:區分「單元測試」和「整合測試」。定義什麼是「好的單元測試」。1. 單元測試 vs. 整合測試:關鍵差在「依賴控制」作者給的定義可以濃縮成一句話:單元測試測的是一個「工作單元」,而且完全控制該單元所有的依賴; 整合測試則是讓這個單元真的去碰外部依賴(資料庫、網路、時間、隨機數等)。換句話說:單元測試:工作單元(被測試的 function / class)要跟依賴「拆開」,用 stub/mock 或其他方式隔離。測試應該能在沒資料庫、沒網路、沒部署的情況下跑完,結果也要穩定。整合測試:允許、甚至刻意使用真實依賴,例如:真實資料庫連線真實 HTTP 請求真實時間(Date.now())真實隨機數(Math.random())用來驗證系統各部分「真的串得起來」。這也解釋了為什麼很多「看起來像單元測試」的測試,其實是整合測試:只要你沒完全掌握依賴(例如測試會打到線上 API),它的結果就容易不穩定,也就稱不上嚴格意義的單元測試 。 kojenchieh.pixnet2. 單元測試檢查清單作者列了一份清單,來檢查你寫的是不是「真正的單元測試」:可以隨時重跑兩週前、兩個月前、兩年前的測試嗎?團隊成員可以在自己的機器上跑你寫的測試嗎?所有測試可以在幾分鐘內跑完嗎?能不能「一鍵」跑完全部測試?一個基本的測試能在幾分鐘內寫完嗎?別的團隊的程式壞掉時,你的測試會不會跟著掛掉?在不同機器或環境上執行,結果會一樣嗎?沒有資料庫、網路或部署的情況下,測試還跑得起來嗎?刪除/移動一個測試,不會影響其他測試嗎?只要其中有一題回答是「否」,作者就傾向把那個測試視為整合測試,而不是單元測試 。 gist.github 這個觀點滿實際的:與其在定義上糾結,不如看它實際上是不是「小而快、穩定可重現」。3. 一個「優良單元測試」的定義作者對「好單元測試」的定義,可以簡短改寫成:單元測試是一段自動化的程式碼,透過某個進入點呼叫工作單元,並檢查一個或少數明確的行為假設。它應該:易寫、跑得快。完全自動化,可重複執行。在相同輸入與相同程式碼下,每次結果都一樣(不依賴隨機、時間、外部資源)。具可讀性、可維護性。值得信任:看到它紅/綠時,不需要再開 debugger 二次確認 。 artofunittesting這裡有幾個關鍵字值得記在心裡:Automated:不能需要人眼看 log 才知道結果。Fast:慢測試會讓人不想常跑,TDD 節奏也會被打亂。Consistent:不能今天紅,明天綠,後天又紅(隨機數/時間/外部依賴是常見元兇)。Readable / Maintainable / Trustworthy:好測試本身也要是「好程式碼」。4. 單元測試不等於 TDD作者特別提醒:「會寫單元測試」和「會用 TDD 開發」是兩套不同的技能。TDD 是一種開發流程:先寫測試 → 寫最小實作 → 重構。 它高度仰賴「測試本身是優良的」,不然紅燈/綠燈給的訊號會非常混亂。在 TDD 的紅燈 → 綠燈 → 重構循環裡,有一個很重要的點:紅燈階段,在不改測試的前提下,修改產品程式碼讓紅燈變綠燈,其實同時也在「測試測試程式本身」: 測試能在該失敗時失敗、該通過時通過,可信度就會變高。作者也建議可以把 TDD 拆成三個獨立練習的技能:能寫出優良的單元測試(本書重點)。能習慣「先寫測試,再寫實作」的節奏。能用測試回饋來推動系統設計(例如應用 SOLID 等設計原則)。這三個可以分階段練,不用一次全部到位。二、洗牌發牌 Kata:flatMap、Array.from 與 Fisher‑Yates 洗牌接著是今天實作的小 Kata,可以想像成一個「洗牌發牌系統」:有一副牌(為了簡化,只看「顏色」或「花色」)。每種顏色有固定數量,例如 5 種顏色 × 每種 30 張 → 總共 150 張牌。要先建立牌堆 → 洗牌 → 均勻發給 N 個玩家,每人固定拿 3 張。這一路剛好練到三組實用的 JS 語法:用 flatMap + Array(n).fill() 生成固定數量的元素。用 Fisher-Yates 洗牌做公平隨機。用 Array.from + slice 把大陣列分段。1. 生成給定數量元素的陣列:flatMap + Array(n).fill()需求: 「每種花色生成固定數量的牌,最後組成一個牌堆(一維陣列)」。 可以用 Array(n).fill() 搭配 flatMap() 寫得很精簡 : developer.mozillaconst suits = ['♠️', '♥️', '♦️', '♣️']; // 或顏色:['red', 'blue', ...] const cardsPerSuit = 30; const deck = suits.flatMap((suit) => Array(cardsPerSuit).fill(suit)); // deck 範例:['♠️', '♠️', ..., '♥️', '♥️', ..., '♣️', ...] 拆解:Array(cardsPerSuit) 建立一個長度為 cardsPerSuit 的陣列,搭配 .fill(suit) 後變成每個位置都是該花色的「密集陣列」。 developer.mozillasuits.flatMap(...):對每個 suit 產生一段陣列。把所有小陣列攤平成一個大陣列(只攤平一層)。 developer.mozilla相較之下,flat() 只做「攤平」,不做轉換;flatMap() 可以同時完成「轉換 + 攤平一層」,在這種「一對多展開」場景特別好用 。 developer.mozilla2. 隨機排序:從「亂數映射」到 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 裡,像 chunkBySize 或 dealHands 這種「我要確實跑 n 次 callback」的場景,選擇 Array.from({ length: n }) 會比 Array(n) 安全,也更語意化。三、語法選擇背後的設計思考在這次 Kata 的過程裡,順便把幾個「最終沒有被採用」的方案也記錄下來,當作自己的設計決策歷史。1. 隨機產生花色/顏色的方式直覺方案: suits[Math.floor(Math.random() * suits.length)] 這是「放回抽樣」,每次從花色陣列隨機挑一個,總數不受限制。實際需求: 類似「一副牌有固定張數」,每張牌只能被發一次 → 不放回抽樣。結論: 先產出完整的牌堆,再用 Fisher-Yates 做不放回的公平洗牌,符合總量守恆的要求 。 stackoverflow2. 建立牌堆的多種寫法評估過的版本:手動展開:[...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.mozillaflatMap 最終採用: suits.flatMap((suit) => Array(cardsPerSuit).fill(suit)) 一眼就看得出「每種花色展開成多張牌再攤平成一個牌堆」。3. Array(n) vs Array.from({ length: n })Array(n):需要先 .fill() 才能用 map,否則會得到一個 callback 根本沒執行的稀疏陣列 。 geeksforgeeksArray.from({ length: n }):天然產生可遍歷的密集陣列,配合 mapping 函式是最直覺的「產生 n 個元素」工具 。 developer.mozilla4. 隨機 / 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 寫這類洗牌/發牌/配對邏輯時,可以:把「生成隨機牌堆」視為整合層,單測只針對純邏輯(例如:給定一個牌堆,發牌結果是否符合規則)。用「紅燈 → 綠燈 → 重構」循環,持續檢查: 測試寫得好不好、測試本身是否可信,以及是否真的有幫助設計出更好維護的程式。 ## Publication Information - [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/): Publication homepage - [All Posts](https://paragraph.com/@gcake/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@gcake): Subscribe to updates