用一個 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