ThisWeb Logo
This.Web
所有文章React 效能優化實戰課
  1. 首頁
  2. 所有文章
  3. 前端
  4. 如何寫出好的單元測試?從測試觀念到實戰

如何寫出好的單元測試?從測試觀念到實戰

前端

Kun | ThisWeb

資深前端工程師

發佈/更新於

2026年7月31日

免費訂閱電子報!

和 2000+ 工程師一起學習軟體、AI 開發技巧,每週一收穫 1 篇技術內容、1 段職涯分享、1 個最新資訊!

免費訂閱電子報!

和 2000+ 工程師一起學習軟體、AI 開發技巧,每週一收穫 1 篇技術內容、1 段職涯分享、1 個最新資訊!

測試不是要測試每一行程式碼,而是要保護重要且容易出錯的行為。

本文會以購物車金額計算為例,帶你從商業規則找出需要驗證的情境,並用 Vitest 寫出可靠的測試案例。

單元測試不是測試每一行程式碼

首先我們要有一個基本的觀念,測試不是要測每一行程式碼。

重點是要測試重要的行為,比如下面這個簡單的函式:

ts
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:

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. 測試可觀察的行為

好的測試名稱可以在測試失敗時,直接告訴我們哪一條規則出了問題:

ts
it('offers free shipping at the threshold', () => {
  // ...
});

相較之下,下面的名稱沒有提供足夠資訊:

ts
it('works correctly', () => {
  // ...
});

測試名稱可以使用以下句型:

  • returns ... when ...
  • throws ... when ...
  • applies ... when ...
  • does not ... when ...

換句話說,測試應該關注使用者或呼叫端看得到的行為,而不是函式內部的實作細節。

例如測試 calculateCartTotal 時,應該傳入商品和折扣碼,再確認最後金額;不需要確認函式內部是否使用 reduce。

只要輸入和輸出的關係沒有改變,內部實作就可以自由重構,而不需要修改測試。

這也是把測試寫好的優點之一,可以讓我們更大膽的重構。

2. 優先驗證狀態

測試一個函式時,最直接的做法是檢查它執行後產生的結果。例如傳入購物車資料後,確認回傳的總金額是否正確;如果輸入不合法,則確認函式是否拋出預期的錯誤。這些結果稱為執行後的狀態(State)。

ts
const total = calculateCartTotal({ items });

expect(total).toBe(700);

相較之下,互動測試(Interaction Testing)會檢查函式內部做了什麼,例如某個函式是否被呼叫、被呼叫幾次,或依照什麼順序呼叫。這類測試在整合外部服務時可能有用途,但對 calculateCartTotal 這種純函式來說通常沒有必要。

如果可以直接確認最後結果,就不要為了檢查內部流程而加入 Mock 或 Spy。測試會更接近真實使用方式,也比較不會因為內部重構而無謂失敗。

3. 使用 AAA 結構

前面有提到,每個測試都可以分成三個階段:

  • Arrange:準備輸入資料與測試環境
  • Act:執行一次目標行為
  • Assert:驗證結果或錯誤
ts
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,測試一般購物車:

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. 測試折扣規則

接著測試有效和無效的折扣碼:

ts
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 元,我們至少應該測試:

商品小計預期運費測試目的
999100門檻以下
1,0000剛好達到門檻
1,0010超過門檻

先用個別測試案例表達這三種情況:

ts
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);
});

折扣門檻也要測試邊界:

ts
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);
});

測試邊界時,可以先問自己三個問題:

  1. 門檻以下會發生什麼事?
  2. 剛好等於門檻時會發生什麼事?
  3. 超過門檻時會發生什麼事?

這三個測試看起來有些重複,但每條都是在測試重要的邊界情況。

我們會在後面利用 it.each 來簡化這種架構重複的測試邏輯。

4. 測試空資料和錯誤輸入

除了正常資料和門檻,也要確認函式收到空資料或不合法輸入時的行為。

空購物車

ts
it('returns zero for an empty cart', () => {
  expect(calculateCartTotal({ items: [] })).toBe(0);
});

空陣列是一個常被忽略的輸入。若沒有明確處理,程式可能會錯誤地加上運費,或在讀取第一個商品時發生錯誤。

不合法的商品數量

ts
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'
  );
});

當我們要測試「執行時會拋出錯誤」的行為時,必須把函式包在另一個函式中:

ts
expect(() => calculateCartTotal({ items })).toThrow();

這是因為 toThrow 必須接收一個函式,才能由 matcher 控制函式什麼時候執行。

如果直接呼叫 calculateCartTotal({ items }),錯誤會在 expect 執行前就拋出。

是否要檢查錯誤訊息,取決於錯誤訊息是不是產品或程式碼對外承諾的一部分。

如果訊息會顯示給使用者,或其他程式會根據它判斷錯誤,就可以考慮一起驗證;但如果只是給開發者除錯的文字,則可以只確認它會拋出錯誤即可。

使用 it.each 測試多組案例

前面說到邊界條件的測試,結構都相同,只是輸入和預期結果不同時。

這個時候就可以使用 it.each。它適合用來表達一組規則和多組資料之間的關係。

筆記

也可以使用 test.each,和 it.each 功能一樣。

把剛才的免運測試整理成資料表:

ts
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 重構:

ts
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 測試通常會更容易閱讀。

測試的優先順序應該是清楚,再來才是減少重複。

用測試保護重構

有了測試後,我們就能大膽的重構函式,比如這邊我把把計算邏輯拆成幾個小函式:

ts
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 的行為仍然正確且一致:

ts
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 整理重複內容。

一組測試完成後,還要確認它真的能在錯誤實作下失敗,並檢查重要規則與邊界是否都有涵蓋。這樣測試才能成為重構時可靠的安全網。

下一篇看什麼?

01.

前端測試架構完整指南:單元測試、整合測試、E2E 測試與測試金字塔

02.

Next.js Page Transition 實作:整合 View Transition API 與 JavaScript 頁面轉場

03.

10 大設計網站提升你的審美!讓你的網站更有質感

文章目錄

  1. 單元測試不是測試每一行程式碼
  2. 先把商業規則寫清楚
  3. 建立購物車計算函式
  4. 寫出可靠的單元測試
  5. 1. 測試可觀察的行為
  6. 2. 優先驗證狀態
  7. 3. 使用 AAA 結構
  8. 4. 設計完整的測試案例
  9. 5. 確認測試真的可靠
  10. 實戰:撰寫購物車測試
  11. 1. 先測試正常情境
  12. 2. 測試折扣規則
  13. 3. 測試邊界條件
  14. 4. 測試空資料和錯誤輸入
  15. 使用 it.each 測試多組案例
  16. 用測試保護重構
  17. 總結

訂閱電子報!

和 2000+ 人一起學習 AI、軟體與網站實作資訊。

或來信合作:kun@thisweb.dev

頁面導覽

  • 首頁
  • 所有文章

聯絡資訊

THISWEB