測試不是要測試每一行程式碼,而是要保護重要且容易出錯的行為。
本文會以購物車金額計算為例,帶你從商業規則找出需要驗證的情境,並用 Vitest 寫出可靠的測試案例。
單元測試不是測試每一行程式碼
首先我們要有一個基本的觀念,測試不是要測每一行程式碼。
重點是要測試重要的行為,比如下面這個簡單的函式:
function add(a: number, b: number) {
return a + b;
}你當然可以為它寫測試,但如果這個函式只在一個地方使用,而且沒有額外的商業規則,測試它的價值可能不高。
因為測試程式本身也需要維護,我們應該把時間優先放在容易出錯、經常變動,或錯誤代價較高的邏輯上。
通常值得優先測試的內容包括:
- 具有商業規則的邏輯,比如折扣、稅金、運費、權限...等等
- 較複雜,包含多個條件判斷的函式
- 需要處理邊界值的計算
- 使用者輸入不合法時的處理方式
- 產品需求明確定義,且未來可能持續修改的邏輯
相反地,以下內容通常不需要在單元測試中重複驗證:
- JavaScript 或 TypeScript 本身已經保證的行為
- 沒有條件判斷的簡單轉接函式
- React、Next.js 或第三方套件本身的功能
- 只為了讓覆蓋率數字變高而寫的測試
我們需要把資源安排在合理且重要的地方,才不是為了寫測試而寫測試。
講完基本觀念後,我們來看看要怎麼樣才算寫好一個測試
先把商業規則寫清楚
這裡我們要建立一個 calculateCartTotal 函式,計算購物車最後需要支付的金額。
在寫程式之前,我們要先把商業規則、邏輯先整理出來,以一個購物車來說,他可能會有這些規則:
| 規則 | 說明 |
|---|---|
| 商品小計 | 每項商品的 price × quantity 加總 |
| 運費 | 商品小計未滿 1,000 元時收取 100 元 |
| 免運 | 商品小計達到 1,000 元時免運 |
| 折扣碼 | SAVE100 在商品小計達到 500 元時折抵 100 元 |
| 無效折扣碼 | 不套用任何折扣 |
| 空購物車 | 回傳 0 |
| 不合法數量 | quantity 小於或等於 0 時拋出錯誤 |
這裡有一個需要特別說明的決定:運費門檻根據套用折扣前的商品小計判斷。
如果我們沒有先把需求和規則寫清楚,就很難寫出有價值的測試。
有了基本邏輯後,就可以建立 calculate-cart-total.ts。
建立購物車計算函式
接著我們建立 src/lib/cart/calculate-cart-total.ts:
export type CartItem = {
name: string;
price: number;
quantity: number;
};
export function calculateCartTotal({
items,
discountCode,
}: {
items: CartItem[];
discountCode?: string;
}) {
if (items.some((item) => item.quantity <= 0)) {
throw new Error('Quantity must be greater than zero');
}
if (items.length === 0) {
return 0;
}
const subtotal = items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
const discount = discountCode === 'SAVE100' && subtotal >= 500 ? 100 : 0;
const shipping = subtotal >= 1000 ? 0 : 100;
return subtotal - discount + shipping;
}這個函式會根據購物車內容和折扣碼,回傳最後金額。
測試不需要知道裡面使用了 reduce,也不需要分別測試 subtotal、discount 和 shipping 這些區域變數。
我們真正關心的是:給定某些商品和折扣碼後,使用者最後應該支付多少錢。
所以接著我們就可以根據這個觀念來寫測試。
寫出可靠的單元測試
前面已經把購物車的商業規則整理出來。開始撰寫測試前,先建立幾個判斷測試品質的原則。
好的測試通常會做到以下幾點:
1. 測試可觀察的行為
好的測試名稱可以在測試失敗時,直接告訴我們哪一條規則出了問題:
it('offers free shipping at the threshold', () => {
// ...
});相較之下,下面的名稱沒有提供足夠資訊:
it('works correctly', () => {
// ...
});測試名稱可以使用以下句型:
returns ... when ...throws ... when ...applies ... when ...does not ... when ...
換句話說,測試應該關注使用者或呼叫端看得到的行為,而不是函式內部的實作細節。
例如測試 calculateCartTotal 時,應該傳入商品和折扣碼,再確認最後金額;不需要確認函式內部是否使用 reduce。
只要輸入和輸出的關係沒有改變,內部實作就可以自由重構,而不需要修改測試。
這也是把測試寫好的優點之一,可以讓我們更大膽的重構。
2. 優先驗證狀態
測試一個函式時,最直接的做法是檢查它執行後產生的結果。例如傳入購物車資料後,確認回傳的總金額是否正確;如果輸入不合法,則確認函式是否拋出預期的錯誤。這些結果稱為執行後的狀態(State)。
const total = calculateCartTotal({ items });
expect(total).toBe(700);相較之下,互動測試(Interaction Testing)會檢查函式內部做了什麼,例如某個函式是否被呼叫、被呼叫幾次,或依照什麼順序呼叫。這類測試在整合外部服務時可能有用途,但對 calculateCartTotal 這種純函式來說通常沒有必要。
如果可以直接確認最後結果,就不要為了檢查內部流程而加入 Mock 或 Spy。測試會更接近真實使用方式,也比較不會因為內部重構而無謂失敗。
3. 使用 AAA 結構
前面有提到,每個測試都可以分成三個階段:
- Arrange:準備輸入資料與測試環境
- Act:執行一次目標行為
- Assert:驗證結果或錯誤
it('calculates the subtotal and shipping fee', () => {
// Arrange
const items = [{ name: 'Book', price: 300, quantity: 2 }];
// Act
const total = calculateCartTotal({ items });
// Assert
expect(total).toBe(700);
});4. 設計完整的測試案例
可以先把輸入依照規則分成幾個等價的類別:
- 正常資料:一般商品、正數數量、有效折扣碼
- 邊界資料:剛好達到折扣或免運門檻的金額
- 異常資料:空購物車、無效折扣碼、零或負數數量
這樣可以讓我們更完整的設計測試案例,也能把注意力放在不同規則的差異上。
不要只為了提高覆蓋率而增加測試。覆蓋率只能告訴我們哪些程式碼曾經被執行,不能證明測試真的驗證了重要行為。對純函式而言,也不需要為了測試每個內部步驟而加入 Mock。
5. 確認測試真的可靠
我們可以暫時故意把正式程式碼改錯,確認測試會失敗,之後再還原程式碼,以此來確保測試真的可靠。這個步驟稱為驗證測試的失敗能力(Watch It Fail)。
了解如何寫好一個測試案例後,讓我們來做一個實戰。
實戰:撰寫購物車測試
1. 先測試正常情境
先在同一個資料夾建立 src/lib/cart/calculate-cart-total.test.ts,測試一般購物車:
import { describe, expect, it } from 'vitest';
import { calculateCartTotal } from './calculate-cart-total';
describe('calculateCartTotal', () => {
it('charges 100 shipping when the subtotal is below 1,000', () => {
const items = [{ name: 'Book', price: 300, quantity: 2 }];
const total = calculateCartTotal({ items });
expect(total).toBe(700);
});
});這個案例的商品小計是 600 元,所以加上 100 元運費後,總金額是 700 元。
這是一個正常情境,也稱為 happy path。
它可以確認函式最基本的行為,但還不足以證明購物車邏輯完整正確。
我們還需要測試折扣規則。
2. 測試折扣規則
接著測試有效和無效的折扣碼:
it('applies SAVE100 and charges shipping when the subtotal reaches 500', () => {
const items = [{ name: 'Book', price: 600, quantity: 1 }];
const total = calculateCartTotal({
items,
discountCode: 'SAVE100',
});
expect(total).toBe(600);
});
it('does not apply a discount for an invalid code', () => {
const items = [{ name: 'Book', price: 600, quantity: 1 }];
const total = calculateCartTotal({
items,
discountCode: 'INVALID',
});
expect(total).toBe(700);
});第一個案例的計算方式是:600 元商品小計,減去 100 元折扣,再加上 100 元運費,最後得到 600 元。
第二個案例則確認未知的折扣碼不會意外產生折扣。這也是商業邏輯中常見的風險:開發者可能只測試「有效折扣碼」,卻忘記定義無效輸入應該怎麼處理。
3. 測試邊界條件
接著我們還要測試邊界條件,商業規則最容易在門檻附近出錯。假設免運門檻是商品小計 1,000 元,我們至少應該測試:
| 商品小計 | 預期運費 | 測試目的 |
|---|---|---|
| 999 | 100 | 門檻以下 |
| 1,000 | 0 | 剛好達到門檻 |
| 1,001 | 0 | 超過門檻 |
先用個別測試案例表達這三種情況:
it('charges shipping below the free-shipping threshold', () => {
const items = [{ name: 'Book', price: 999, quantity: 1 }];
expect(calculateCartTotal({ items })).toBe(1099);
});
it('offers free shipping at the threshold', () => {
const items = [{ name: 'Book', price: 1000, quantity: 1 }];
expect(calculateCartTotal({ items })).toBe(1000);
});
it('offers free shipping above the threshold', () => {
const items = [{ name: 'Book', price: 1001, quantity: 1 }];
expect(calculateCartTotal({ items })).toBe(1001);
});折扣門檻也要測試邊界:
it('does not apply SAVE100 below the discount threshold', () => {
const items = [{ name: 'Book', price: 499, quantity: 1 }];
const total = calculateCartTotal({
items,
discountCode: 'SAVE100',
});
expect(total).toBe(599);
});
it('applies SAVE100 at the discount threshold', () => {
const items = [{ name: 'Book', price: 500, quantity: 1 }];
const total = calculateCartTotal({
items,
discountCode: 'SAVE100',
});
expect(total).toBe(500);
});測試邊界時,可以先問自己三個問題:
- 門檻以下會發生什麼事?
- 剛好等於門檻時會發生什麼事?
- 超過門檻時會發生什麼事?
這三個測試看起來有些重複,但每條都是在測試重要的邊界情況。
我們會在後面利用 it.each 來簡化這種架構重複的測試邏輯。
4. 測試空資料和錯誤輸入
除了正常資料和門檻,也要確認函式收到空資料或不合法輸入時的行為。
空購物車
it('returns zero for an empty cart', () => {
expect(calculateCartTotal({ items: [] })).toBe(0);
});空陣列是一個常被忽略的輸入。若沒有明確處理,程式可能會錯誤地加上運費,或在讀取第一個商品時發生錯誤。
不合法的商品數量
it('throws an error when an item has zero quantity', () => {
const items = [{ name: 'Book', price: 300, quantity: 0 }];
expect(() => calculateCartTotal({ items })).toThrow(
'Quantity must be greater than zero'
);
});
it('throws an error when an item has a negative quantity', () => {
const items = [{ name: 'Book', price: 300, quantity: -1 }];
expect(() => calculateCartTotal({ items })).toThrow(
'Quantity must be greater than zero'
);
});當我們要測試「執行時會拋出錯誤」的行為時,必須把函式包在另一個函式中:
expect(() => calculateCartTotal({ items })).toThrow();這是因為 toThrow 必須接收一個函式,才能由 matcher 控制函式什麼時候執行。
如果直接呼叫 calculateCartTotal({ items }),錯誤會在 expect 執行前就拋出。
是否要檢查錯誤訊息,取決於錯誤訊息是不是產品或程式碼對外承諾的一部分。
如果訊息會顯示給使用者,或其他程式會根據它判斷錯誤,就可以考慮一起驗證;但如果只是給開發者除錯的文字,則可以只確認它會拋出錯誤即可。
使用 it.each 測試多組案例
前面說到邊界條件的測試,結構都相同,只是輸入和預期結果不同時。
這個時候就可以使用 it.each。它適合用來表達一組規則和多組資料之間的關係。
把剛才的免運測試整理成資料表:
it.each([
{
caseName: 'below the threshold',
price: 999,
expectedTotal: 1099,
},
{
caseName: 'at the threshold',
price: 1000,
expectedTotal: 1000,
},
{
caseName: 'above the threshold',
price: 1001,
expectedTotal: 1001,
},
])('calculates shipping correctly $caseName', ({ price, expectedTotal }) => {
const items = [{ name: 'Book', price, quantity: 1 }];
expect(calculateCartTotal({ items })).toBe(expectedTotal);
});使用 it.each 後,測試的共同結構只需要寫一次,資料表也可以清楚列出每個案例的輸入和預期結果。
我們也可以將折扣碼的測試邏輯,使用 it.each 重構:
it.each([
{
behavior: 'does not apply SAVE100',
condition: 'the subtotal is below 500',
price: 499,
expectedTotal: 599,
},
{
behavior: 'applies SAVE100',
condition: 'the subtotal is exactly 500',
price: 500,
expectedTotal: 500,
},
{
behavior: 'applies SAVE100',
condition: 'the subtotal is above 500',
price: 600,
expectedTotal: 600,
},
])('$behavior when $condition', ({ price, expectedTotal }) => {
const items = [{ name: 'Book', price, quantity: 1 }];
const total = calculateCartTotal({
items,
discountCode: 'SAVE100',
});
expect(total).toBe(expectedTotal);
});不過如果每個案例的前置條件、操作或驗證方式不同,個別的 it 測試通常會更容易閱讀。
測試的優先順序應該是清楚,再來才是減少重複。
用測試保護重構
有了測試後,我們就能大膽的重構函式,比如這邊我把把計算邏輯拆成幾個小函式:
function calculateSubtotal(items: CartItem[]) {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
function calculateDiscount(subtotal: number, discountCode?: string) {
if (discountCode === 'SAVE100' && subtotal >= 500) {
return 100;
}
return 0;
}
function calculateShipping(subtotal: number) {
return subtotal >= 1000 ? 0 : 100;
}接著讓 calculateCartTotal 使用這些函式。只要原本的測試全部通過,代表這個 calculateCartTotal 的行為仍然正確且一致:
export function calculateCartTotal({
items,
discountCode,
}: CalculateCartTotalOptions) {
if (items.some((item) => item.quantity <= 0)) {
throw new Error('Quantity must be greater than zero');
}
if (items.length === 0) {
return 0;
}
const subtotal = calculateSubtotal(items);
const discount = calculateDiscount(subtotal, discountCode);
const shipping = calculateShipping(subtotal);
return subtotal - discount + shipping;
}這就是測試作為重構安全網的例子,測試沒有保證新的實作一定沒有任何問題,但它可以快速確認我們沒有破壞已經定義好的重要行為。
總結
最後總結一下,單元測試的重點,不是測試每一行程式碼,而是保護重要且容易出錯的行為。
因此在開始寫測試前,我們先把商業規則整理清楚,再根據正常、邊界和錯誤情境設計案例。
另外測試應該描述呼叫端看得到的行為,優先驗證回傳值或錯誤,而不是綁定函式內部的實作。使用 AAA 結構也可以讓每個測試更容易閱讀;遇到結構相同但資料不同的案例,則可以使用 it.each 整理重複內容。
一組測試完成後,還要確認它真的能在錯誤實作下失敗,並檢查重要規則與邊界是否都有涵蓋。這樣測試才能成為重構時可靠的安全網。
