Day 38:單元測試的進入點與退出點;物件當 Map 與 Object.values

單元測試裡的「單元」「進入點」「退出點」

在單元測試語境下,「單元」指的是系統內部的一個「工作單元」或「用例(use case)」,不一定只是一個函式。它可以是從一次調用開始,到產生某個明確結果為止,這段過程裡所有一起合作的程式碼。

  • **工作單元(unit of work)**可以是一個函式,也可以是多個函式組成的流程,只要是「從一個調用開始,到產生結果為止」的一整段邏輯。

  • 這段流程從 進入點(entry point) 開始,直到一個或多個 退出點(exit point) 結束。

在寫測試時:

  • 進入點就是「測試程式呼叫哪個 API」:例如呼叫一個函式、送一個 HTTP 請求、操作一個公開 method。

  • 每個退出點代表「一種行為或需求」,通常會為不同的退出方式寫對應的測試案例。

退出點的三種類型

典型的退出點大致可以分成三類,對應不同測試手法:

  1. 回傳值

    • 最直覺的情況,測試會對函式的回傳值做斷言。

    • 適合純計算、純轉換類函式(沒有副作用)。

  2. 狀態改變

    • 函式不一定回傳有用的東西,但會改變某個狀態(物件屬性、全域狀態、DOM、store 等)。

    • 測試會在呼叫後檢查狀態是否符合預期。

  3. 呼叫第三方/依賴項目

    • 例如:呼叫資料庫、發送 HTTP request、寫檔案。

    • 單元測試通常不直接真的去打外部系統,而是用 stub / mock(test doubles)檢查「有沒有用正確參數呼叫依賴」。

這三種退出點也可以同時存在:例如某個函式既會回傳值,又會改變狀態,還會呼叫外部服務,測試時就要清楚知道要驗什麼、哪些要用 stub 取代。


什麼是「好的」單元測試?

書裡整理了一些特徵,這些其實對所有自動化測試都適用:

  • 容易閱讀、容易編寫,一看就知道作者想驗證什麼。

  • 自動化,按一下就能跑完。

  • 結果一致,不會「有時過、有時不過」。

  • 有用:失敗時能指出有意義的資訊,幫助找到問題。

  • 可以在任何地方、任何人一鍵執行,不綁定作者機器上的特殊設定。

常見的單元測試額外特性:

  • 執行速度快。

  • 能完全控制受測程式碼(含 stub 掉外部依賴)。

  • 測試之間彼此獨立,不互相影響。

  • 在記憶體內運行,不依賴檔案系統、網路、資料庫等 I/O。

  • 在合理狀況下盡可能保持同步、線性執行,降低平行執行緒/非同步帶來的不穩定因素。

在 JavaScript 環境裡,這也呼應了為什麼 async/await 這種語法糖這麼重要:它讓非同步流程可以用「看起來像同步」的方式描述,測試時也能寫出比較線性的斷言流程。[1]


用同步測試思維處理非同步

即便底層是非同步(例如 Promise、計時器、HTTP request),測試可以透過幾種方式保持「語意上的同步」:

  • 使用 async/await 把非同步流程寫成「直式」敘述。

  • 若沒有語法糖(例如在 callback 風格 API 中),可以在測試裡明確等待非同步完成:

    • 例如傳入 done callback、或在測試框架提供的 API 裡 await 某個 Promise。

  • 重要的是:測試本身要「知道什麼時候可以斷言」,而不是隨機等一段時間。

這樣一來,即使系統內部充滿非同步,測試的進入點與退出點依然是清楚、可控的。


統計器材數量:Object 當 Map 的實戰模式

接下來的案例,是一個「統計每種器材數量」的小需求,很適合作為「物件當 Map 用」與 Object.values() 的練習。[2][1]

需求:把重複的器材合併計數

輸入:

[
  { name: 'Kettlebell', spec: '6 kg', weight: 6 },
  { name: 'Kettlebell', spec: '6 kg', weight: 6 },  // 重複
  { name: 'Dumbbell', spec: '10 kg', weight: 10 },
]

輸出:

[
  { name: 'Kettlebell', spec: '6 kg', weight: 6, count: 2 },
  { name: 'Dumbbell', spec: '10 kg', weight: 10, count: 1 },
]

核心邏輯:掃過一遍清單,用「名稱 + 規格」當 key 把同一種器材累積起來,最後把結果轉回陣列。


陣列 vs 物件當 Map:效能與語意

方案 A:用陣列搜尋(O(n²))

const equipmentsReport = [];

equipmentsList.forEach((item) => {
  const key = `${item.name}-${item.spec}`;

  const existing = equipmentsReport.find((report) => (
    `${report.name}-${report.spec}` === key
  ));

  if (existing) {
    existing.count += 1;
  } else {
    equipmentsReport.push({ ...item, count: 1 });
  }
});
  • 外層 forEach 跑 n 次,每次裡面 find 平均要掃 n/2 個元素。

  • 整體時間複雜度約 O(n²),資料一多就會明顯變慢。

方案 B:用物件當 Map(O(n))

const countMap = {};

equipmentsList.forEach((item) => {
  const key = `${item.name}-${item.spec}`;

  if (countMap[key]) {
    countMap[key].count += 1;
  } else {
    countMap[key] = { ...item, count: 1 };
  }
});

const summary = Object.values(countMap);
  • countMap[key] 查找是物件屬性存取,平均視為 O(1)。

  • 整體流程是單一遍歷,時間複雜度 O(n)。

  • 最後用 Object.values(countMap) 把 Map 結果轉回陣列。[1]

選擇理由:

  • 效能:從 O(n²) 降到 O(n)。

  • 語意:用「查表」取代「每次搜尋整個陣列」,閱讀起來更貼近問題本質。

  • 實作:Object.values() 是標準 API,支援度很好。[1]


Object.values / Object.entries:把物件轉成陣列

Object.values(obj)

  • 回傳「物件自己的、可列舉、字串 key 的屬性值」陣列。

  • 不會包含原型鏈上的屬性。

  • 不會包含 Symbol key。

const obj = { a: 1, b: 2 };
Object.values(obj); // [1, 2]

這就是前面 countMap 統計完後,用來取得結果陣列的關鍵 API。[3][1]

Object.entries(obj)

  • 回傳「[key, value]」二元陣列組成的陣列。

const obj = { a: 1, b: 2 };
Object.entries(obj); // [['a', 1], ['b', 2]]

常用在需要同時拿 key 與 value 的場合,例如把物件轉成 Map 或是用解構搭配 for...of 做迴圈。[4][2]


物件動態 key 與屬性檢查

1. 動態新增屬性:一定要用方括號

統計時的 key 通常是組合而來,例如 Kettlebell-6 kg,這個 key 裡面有空白與 dash,不可能用點語法:

const obj = {};
const key = 'Kettlebell-6 kg';

// ✅ 正確寫法
obj[key] = { name: 'Kettlebell', spec: '6 kg', weight: 6 };

// ❌ 錯誤:點語法只能用合法識別字
// obj.Kettlebell-6 kg = ... // 這會直接語法錯誤

這也是「物件當 Map」模式中,方括號語法不可或缺的原因。


2. 檢查屬性是否存在:三個層次

實務上有三種常見寫法,各有適用情境。

寫法一:直接判斷 value(最簡潔)

if (countMap[key]) {
  countMap[key].count += 1;
} else {
  countMap[key] = { ...item, count: 1 };
}
  • 適用前提:

    • 確定 value 不會是 0、false、'' 等 falsy 值。

    • 物件是自己建立的,不是外部輸入。

  • 在「統計次數」這種情境,count 一開始是 1 之後只會變大,所以不會是 falsy,非常適合用這種寫法。

寫法二:Object.hasOwn(obj, key)(現代寫法)

if (Object.hasOwn(countMap, key)) {
  // ...
}
  • ES2022 新增,語法精簡,專門用來取代 obj.hasOwnProperty(key)。

  • 使用前要確認執行環境(現代 Node / 瀏覽器基本沒問題)。[5]

寫法三:Object.prototype.hasOwnProperty.call()

if (Object.prototype.hasOwnProperty.call(countMap, key)) {
  // ...
}
  • 相容性最好、最安全的老派寫法。

  • 主要是避免兩種情況:

    1. 物件自己定義了 hasOwnProperty,把原本的方法蓋掉。

    2. 物件是 Object.create(null) 建出來的,根本沒有原型鏈。[6]

ESLint 的 no-prototype-builtins 規則就是在提醒:不要直接用 obj.hasOwnProperty(key),改用上面兩種其中之一。[7][8][9]


統計函式完整實作:countEquipments

/**
 * 統計器材清單中每種器材的數量
 * @param {Array<Object>} equipmentsList - 器材陣列
 * @returns {Array<Object>} 統計後的陣列,格式:[{ name, spec, weight, count }, ...]
 */
export function countEquipments(equipmentsList) {
  const countMap = {};

  equipmentsList.forEach((item) => {
    const key = `${item.name}-${item.spec}`;

    if (countMap[key]) {
      countMap[key].count += 1;
    } else {
      countMap[key] = {
        name: item.name,
        spec: item.spec,
        weight: item.weight,
        count: 1,
      };
    }
  });

  return Object.values(countMap);
}

這個模式可以重複用在各種「統計每種東西出現幾次」的場景,例如:

  • 統計購物車裡每種商品數量。

  • 統計 log 檔中每種錯誤代碼的出現次數。

  • 統計 API 回傳資料中,每個城市出現幾次。

關鍵元素只有三個:

  1. 用 Object 當 Map 儲存中間結果。

  2. 用「某種唯一 key」把同一種東西歸在一起。

  3. 最後用 Object.values() 把 Map 轉回陣列。[1]


小結:這次的幾個重點觀念

  • 在單元測試裡,先想清楚工作單元的進入點和退出點,再設計測試案例:不同退出點通常代表不同需求。

  • 好的單元測試應該可讀、可重複、可自動,並且盡量在記憶體內同步運行,不依賴外部 I/O。

  • JavaScript 的 async/await 是把非同步邏輯以同步形式表達的語法糖,有助於讓測試流程更線性。

  • 在「統計」這種問題上,Object 當 Map + Object.values 是非常實用的模式,時間複雜度從 O(n²) 降到 O(n)。[2][1]

  • 檢查物件屬性存在時,要根據情境選擇:

    • 自己控制的資料 + 值不會是 falsy:if (obj[key]) 就夠用。

    • 嚴謹、泛用:Object.hasOwn(obj, key) 或 Object.prototype.hasOwnProperty.call(obj, key),並搭配 ESLint 規則避免陷阱。[9][7]

如果之後想把這段統計邏輯加上單元測試,下一步就可以練習:為 countEquipments 設計進入點(呼叫函式)、退出點(回傳值)與幾組不同情境的 test cases。