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