---
# System prepended metadata

title: QA 工程師在幹麻

---

用一個 QA 測試工程師的古老笑話來理解 Jest 如何幫助我們做 Unit test
# 前情提要 edge case testing 笑話
> 一個測試工程師走進一家酒吧，要了一杯啤酒
一個測試工程師走進一家酒吧，要了一杯咖啡
一個測試工程師走進一家酒吧，要了 999999999 杯啤酒
一個測試工程師走進一家酒吧，要了 0 杯啤酒
一個測試工程師走進一家酒吧，要了 -1 杯啤酒
一個測試工程師走進一家酒吧，要了 0.7 杯啤酒
一個測試工程師走進一家酒吧，要了 -10000 杯啤酒
一個測試工程師走進一家酒吧，要了一杯蜥蜴
一個測試工程師走進一家酒吧，要了一份 Qer@24dg!&(@ 
一個測試工程師走進一家酒吧，什麼也沒要
一個測試工程師走進一家酒吧，又走出去又從窗戶進來又從後門出去再從下水道鑽進來
一個測試工程師走進一家酒吧，又走出去又進來又出去又進來又出去，最後在外面把老闆打了一頓
一個測試工程師走進一家酒吧，要了一杯燙燙燙的錕斤拷
一個測試工程師走進一家酒吧，要了 NaN 杯 Null
一個測試工程師沖進一家酒吧，要了 500 杯啤酒咖啡洗腳水野貓狼牙棒奶茶
一個測試工程師把酒吧拆了
一個測試工程師化妝成老闆走進一家酒吧，要了 500 杯啤酒並且不付錢
一萬個測試工程師在酒吧門外呼嘯而過
酒保從容應對
測試工程師很滿意
測試工程師們滿意地離開了酒吧
一名顧客點了一份炒飯
酒吧陷入火海

---

# Jest
Jest 是目前最流行的 JavaScript 測試框架，最初是為了測試 React 專案而開發，現在已廣泛應用於 Vue、Angular、Node.js 等環境。

單元測試的基本觀念是對 `function` 進行測試，假設酒吧點餐函式是 `orderDrink(item, quantity)`，對應到單元測試會有以下流程：

1. 測試的目標為何？ -> 會使用一段文字描述做什麼、期望的結果為何
    - 一個測試工程師走進一家酒吧，要了一杯啤酒，期望得到一杯啤酒
2. 導入要測試的函式 -> 實際運行的過程
    - 酒保收到訂單，回傳一杯啤酒「這是你點的一杯啤酒」`Ordered 1 beer`
3. 測試的期望是什麼？ -> 驗證的期望值，可以用各種方式比對結果
    - QA 得到一杯啤酒，點了數量沒錯，`expect(result).toEqual({...})`

---

## describe
`describe` 的用途是提供一個群組的描述，以一開始的範例來說，我們可能會驗證酒保以下行為是正確的：
```javascript=
describe("orderDrink", () => {
    test("should order 1 beer successfully", () => {});
    test("should order 1 coffee successfully", () => {});
    test("should reject extremely large quantity", () => {});
    ...
});
```

測試的過程中會明確的標上「測試的目標描述 `test(...)`」，並且定義「測試的結果是否符合預期 `expect()...`」

---

# Jest 可以如何做 Unit test？
用 Jest 來看，這就是很典型的：
* 正常案例
* 邊界值測試
* 非法輸入測試
* 型別錯誤測試
* 狀態流程測試
* 權限測試
* 壓力/極端輸入測試
* 遺漏需求測試

## 酒吧點單函式
```javascript=
// pseudo code
function orderDrink({ item, quantity, customerType = "guest" }) {
  const menu = ["beer", "coffee", "tea"];
  return {
    success: true,
    total: quantity * 100,
    message: `Ordered ${quantity} ${item}`,
  };
}
```

## 正常案例
### 1. 正常情況
```javascript=
describe("orderDrink", () => {
  test("should order 1 beer successfully", () => {
    const result = orderDrink({ item: "beer", quantity: 1 });

    expect(result).toEqual({
      success: true,
      total: 100,
      message: "Ordered 1 beer",
    });
  });
});
```
> 要一杯啤酒

### 2. 測不同合法品項
```javascript=
test("should order coffee successfully", () => {
  const result = orderDrink({ item: "coffee", quantity: 1 });

  expect(result.success).toBe(true);
  expect(result.total).toBe(100);
});
```
> 要了一杯咖啡

---

## 邊界值測試
### 1. 超大數量
```javascript=
if (quantity > 100) {
    throw new Error("Quantity exceeds limit");
}
```
```javascript=
test("should reject extremely large quantity", () => {
  expect(() =>
    orderDrink({ item: "beer", quantity: 999999999 })
  ).toThrow("Quantity exceeds limit");
});
```
> 要了 999999999 杯啤酒

### 2. 0 杯
```javascript=
if (quantity <= 0) {
    throw new Error("Quantity must be greater than 0");
}
```
```javascript=
test("should reject zero quantity", () => {
  expect(() =>
    orderDrink({ item: "beer", quantity: 0 })
  ).toThrow("Quantity must be greater than 0");
});
```
> 要了 0 杯啤酒

### 3. 負數
```javascript=
test("should reject negative quantity", () => {
  expect(() =>
    orderDrink({ item: "beer", quantity: -1 })
  ).toThrow("Quantity must be greater than 0");
});
```
> 要了 -1 杯啤酒

### 小數
```javascript=
if (!Number.isInteger(quantity)) {
    throw new Error("Quantity must be an integer");
}
```
```javascript=
test("should reject decimal quantity", () => {
  expect(() =>
    orderDrink({ item: "beer", quantity: 0.7 })
  ).toThrow("Quantity must be an integer");
});
```
> 要了0.7杯啤酒

---

## 非法輸入 / 型別錯誤測試
### 1. 菜單沒有的東西
```javascript=
if (!menu.includes(item)) {
    throw new Error("Item not available");
}
```
```javascript=
test("should reject unavailable item", () => {
  expect(() =>
    orderDrink({ item: "lizard", quantity: 1 })
  ).toThrow("Item not available");
});
```
> 要了一杯蜥蜴

### 2. 亂碼字串
```javascript=
if (!Number.isInteger(quantity)) {
    throw new Error("Quantity must be an integer");
}
```
```javascript=
test("should reject gibberish item", () => {
  expect(() =>
    orderDrink({ item: "Qer@24dg!&*(@", quantity: 1 })
  ).toThrow("Item not available");
});
```
> 要了一份 Qer@24dg!&*(@

### 3. 什麼都沒要
```javascript=
if (!item) {
    throw new Error("Item is required");
}
```
```javascript=
test("should reject missing item", () => {
  expect(() =>
    orderDrink({ quantity: 1 })
  ).toThrow("Item is required");
});
```
> 什麼也沒要

### 4. NaN / null
```javascript=
if (typeof quantity !== "number" || Number.isNaN(quantity)) {
    throw new Error("Quantity must be a valid number");
}
```
```javascript=
test("should reject NaN quantity", () => {
  expect(() =>
    orderDrink({ item: "beer", quantity: NaN })
  ).toThrow("Quantity must be a valid number");
});

test("should reject null quantity", () => {
  expect(() =>
    orderDrink({ item: "beer", quantity: null })
  ).toThrow("Quantity must be a valid number");
});
```
> 要了 NaN 杯 Null

---

## 經過 Jest 改良後的程式
```javascript=
function orderDrink({ item, quantity, customerType = "guest" }) {
  const menu = ["beer", "coffee", "tea"];

  if (!item) {
    throw new Error("Item is required");
  }

  if (!menu.includes(item)) {
    throw new Error("Item not available");
  }

  if (typeof quantity !== "number" || Number.isNaN(quantity)) {
    throw new Error("Quantity must be a valid number");
  }

  if (!Number.isInteger(quantity)) {
    throw new Error("Quantity must be an integer");
  }

  if (quantity <= 0) {
    throw new Error("Quantity must be greater than 0");
  }

  if (quantity > 100) {
    throw new Error("Quantity exceeds limit");
  }

  if (customerType === "owner") {
    return {
      success: true,
      total: 0,
      message: "Owner privilege",
    };
  }

  return {
    success: true,
    total: quantity * 100,
    message: `Ordered ${quantity} ${item}`,
  };
}
```

## 在 Jest 裡可以怎麼更漂亮地寫
```javascript=
test.each([
  [0, "Quantity must be greater than 0"],
  [-1, "Quantity must be greater than 0"],
  [-10000, "Quantity must be greater than 0"],
  [0.7, "Quantity must be an integer"],
  [999999999, "Quantity exceeds limit"],
  [NaN, "Quantity must be a valid number"],
])("should reject invalid quantity: %p", (quantity, expectedMessage) => {
  expect(() =>
    orderDrink({ item: "beer", quantity })
  ).toThrow(expectedMessage);
});
```

## 其他
> 化妝成老闆走進酒吧，要了500杯啤酒並且不付錢

這是在測：
* 身分驗證
* 權限繞過
* 特權邏輯

但真正安全一點的系統不會只靠 `customerType: "owner"` 這種前端傳值，否則很好騙。

---

> 又走出去又從窗戶進來又從後門出去再從下水道鑽進來

這是在測：
* 狀態切換
* 重複進出
* 非正常流程

如果有 `enterBar()` / `leaveBar()`，可以測
```javascript=
test("customer cannot enter twice without leaving", () => {
  const bar = new Bar();
  bar.enter("kai");

  expect(() => bar.enter("kai")).toThrow("Customer already inside");
});
```

---

> 一萬個測試工程師在酒吧門外呼嘯而過

這比較像：
* 壓力測試
* 負載測試
* 並發測試

這通常不屬於 unit test 主戰場。
Jest 可以模擬一些迴圈資料測試，但真正的壓測通常會用：
* k6
* JMeter
* Artillery

---

> 點了一份炒飯

這是跨模組需求了，比如酒吧系統其實還連到廚房模組，但廚房沒處理「炒飯」這個品項。

這就不是單純 `orderDrink()` 的 unit test 了，而比較像：
* integration test
* end-to-end test
* requirement test

---

# 如果再加上 Test Double 呢？

如果酒吧點單還會通知庫存系統或付款系統：
```javascript=
async function orderDrink({ item, quantity }, deps) {
  const { paymentService, inventoryService } = deps;

  const stock = await inventoryService.getStock(item);
  if (stock < quantity) {
    throw new Error("Insufficient stock");
  }

  await paymentService.charge(quantity * 100);

  return { success: true };
}
```
Jest 就可以這樣測：
```javascript=
test("should charge customer when stock is enough", async () => {
  const inventoryService = {
    getStock: jest.fn().mockResolvedValue(10),
  };

  const paymentService = {
    charge: jest.fn().mockResolvedValue(true),
  };

  const result = await orderDrink(
    { item: "beer", quantity: 2 },
    { inventoryService, paymentService }
  );

  expect(result.success).toBe(true);
  expect(inventoryService.getStock).toHaveBeenCalledWith("beer");
  expect(paymentService.charge).toHaveBeenCalledWith(200);
});
```
* `inventoryService.getStock` 是 stub
* `paymentService.charge` 是 mock / spy