結論先講

Unit test 的價值不在「覆蓋率 100%」,在「改 code 的時候敢按 Enter」。 Jest 穩定成熟、生態系大;Vitest 快、原生支援 ESM、跟 Vite 無縫整合。兩個都能用,選一個堅持寫下去比較重要。

這篇不講理論,直接寫一個工具函式庫的測試。每段程式碼都能跑。


Jest vs Vitest:選哪個

比較項目JestVitest
速度普通(transform 慢)快(用 Vite 的 transform)
ESM 支援需要額外設定原生支援
設定複雜度中等低(尤其已有 Vite)
生態系最大,文件最多快速成長中
TypeScript需要 ts-jest 或 @swc/jest原生支援
相容性Jest API幾乎完全相容 Jest API

簡單判斷

  • 專案已經用 Vite → Vitest
  • 專案用 Webpack / 無 bundler → Jest
  • 新專案、沒歷史包袱 → Vitest(速度差異在大專案裡很明顯)

從零開始:專案設定

Jest

mkdir my-utils && cd my-utils
npm init -y
npm install --save-dev jest

package.json 加上:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

如果要用 ESM,加一個 jest.config.js

export default {
  transform: {},
  extensionsToTreatAsEsm: ['.ts'],
};

Vitest

mkdir my-utils && cd my-utils
npm init -y
npm install --save-dev vitest

package.json

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

就這樣。不需要額外 config 檔。


第一個測試:基本 Assertion

先寫一個簡單的工具函式:

// src/string-utils.js
export function capitalize(str) {
  if (typeof str !== 'string') throw new TypeError('Expected a string');
  return str.charAt(0).toUpperCase() + str.slice(1);
}
 
export function slugify(str) {
  return str
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .trim();
}

測試:

// tests/string-utils.test.js
import { describe, it, expect } from 'vitest'; // Jest 不需要 import
 
import { capitalize, slugify } from '../src/string-utils.js';
 
describe('capitalize', () => {
  it('首字母大寫', () => {
    expect(capitalize('hello')).toBe('Hello');
  });
 
  it('空字串不炸', () => {
    expect(capitalize('')).toBe('');
  });
 
  it('非字串會丟錯誤', () => {
    expect(() => capitalize(123)).toThrow(TypeError);
    expect(() => capitalize(null)).toThrow('Expected a string');
  });
});
 
describe('slugify', () => {
  it('空白轉 dash', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });
 
  it('移除特殊字元', () => {
    expect(slugify('Hello, World!')).toBe('hello-world');
  });
 
  it('多個空白只留一個 dash', () => {
    expect(slugify('a   b   c')).toBe('a-b-c');
  });
});

常用 Assertion 速查

// 精確相等(primitive)
expect(1 + 1).toBe(2);
 
// 深層相等(object / array)
expect({ a: 1 }).toEqual({ a: 1 });
 
// 包含
expect([1, 2, 3]).toContain(2);
expect({ name: 'Terry', age: 30 }).toMatchObject({ name: 'Terry' });
 
// Truthy / Falsy
expect(1).toBeTruthy();
expect(0).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
 
// 錯誤
expect(() => boom()).toThrow();
expect(() => boom()).toThrow('expected message');

Mock:假裝外部世界

Mock 是 unit test 最容易用錯的部分。原則:mock 外部依賴,不要 mock 被測試的邏輯本身

基本 mock function

import { describe, it, expect, vi } from 'vitest'; // Jest 用 jest.fn()
 
it('mock function 記錄呼叫', () => {
  const callback = vi.fn(); // Jest: jest.fn()
 
  callback('hello');
  callback('world');
 
  expect(callback).toHaveBeenCalledTimes(2);
  expect(callback).toHaveBeenCalledWith('hello');
  expect(callback).toHaveBeenLastCalledWith('world');
});
 
it('mock function 可以指定回傳值', () => {
  const getPrice = vi.fn()
    .mockReturnValueOnce(100)
    .mockReturnValueOnce(200)
    .mockReturnValue(0); // 之後都回傳 0
 
  expect(getPrice()).toBe(100);
  expect(getPrice()).toBe(200);
  expect(getPrice()).toBe(0);
});

spyOn:監聽真實物件

import { vi } from 'vitest';
 
const user = {
  getName() { return 'Terry'; },
  getAge() { return 30; },
};
 
it('spyOn 可以攔截方法', () => {
  const spy = vi.spyOn(user, 'getName').mockReturnValue('Mock Terry');
 
  expect(user.getName()).toBe('Mock Terry');
  expect(spy).toHaveBeenCalled();
 
  spy.mockRestore(); // 還原
  expect(user.getName()).toBe('Terry');
});

Module Mock:整個模組換掉

假設你有一個 service 依賴 HTTP client:

// src/user-service.js
import { fetchUser } from './api-client.js';
 
export async function getUserDisplayName(id) {
  const user = await fetchUser(id);
  return `${user.firstName} ${user.lastName}`;
}

測試時把 api-client 整個 mock 掉:

// tests/user-service.test.js
import { describe, it, expect, vi } from 'vitest';
 
// Mock 整個模組
vi.mock('../src/api-client.js', () => ({
  fetchUser: vi.fn(),
}));
 
import { getUserDisplayName } from '../src/user-service.js';
import { fetchUser } from '../src/api-client.js';
 
describe('getUserDisplayName', () => {
  it('組合 firstName 和 lastName', async () => {
    fetchUser.mockResolvedValue({
      firstName: 'Terry',
      lastName: 'Yao',
    });
 
    const name = await getUserDisplayName(1);
 
    expect(name).toBe('Terry Yao');
    expect(fetchUser).toHaveBeenCalledWith(1);
  });
});

Async 測試

// src/api-client.js
export async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`User ${id} not found`);
  return res.json();
}
describe('fetchUser', () => {
  it('成功回傳 user(用 await)', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ id: 1, name: 'Terry' }),
    });
 
    const user = await fetchUser(1);
    expect(user).toEqual({ id: 1, name: 'Terry' });
  });
 
  it('失敗丟錯誤(用 rejects)', async () => {
    global.fetch = vi.fn().mockResolvedValue({ ok: false });
 
    await expect(fetchUser(999)).rejects.toThrow('User 999 not found');
  });
});

注意:async 測試一定要 await。忘了 await 的話,測試會永遠通過,因為 assertion 根本還沒跑。


Coverage:追求 80% 就好

設定

Vitest 用 c8(現在叫 @vitest/coverage-v8)或 istanbul

npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage

Jest 內建 coverage(基於 istanbul):

npx jest --coverage

Coverage 報表怎麼看

--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
string-utils.js     |   95.23 |    85.71 |     100 |   95.23 |
user-service.js     |     100 |      100 |     100 |     100 |
api-client.js       |   78.57 |    66.67 |     100 |   78.57 |
--------------------|---------|----------|---------|---------|
  • Statements:多少行程式碼被跑到
  • Branch:if/else、三元運算每個分支都跑到了嗎
  • Functions:每個函式都被呼叫了嗎
  • Lines:跟 Statements 類似但更精確

80% 就夠了

不要追 100%。理由:

  1. 最後 20% 通常是 edge case 和防禦性程式碼,測試寫起來成本高但價值低
  2. 100% coverage 不代表沒有 bug,只代表每行都跑過
  3. 強制 100% 會導致團隊寫「為了 coverage 而寫的測試」——那種測試毫無意義

建議:CI 裡設 80% 門檻,重要的 business logic 接近 100%,utility 和 glue code 有基本覆蓋就好。


常見錯誤

1. 測實作而不是測行為

// ❌ 不好:測內部實作
it('用 toUpperCase 做首字母大寫', () => {
  const spy = vi.spyOn(String.prototype, 'toUpperCase');
  capitalize('hello');
  expect(spy).toHaveBeenCalled(); // 誰在乎你用什麼方法
});
 
// ✅ 好:測行為
it('首字母大寫', () => {
  expect(capitalize('hello')).toBe('Hello');
});

2. Over-mocking

// ❌ 連純函式都 mock
vi.mock('../src/string-utils.js');
// 你 mock 了被測試的東西,那你到底在測什麼?
 
// ✅ 只 mock 外部依賴(HTTP、DB、第三方 API)
vi.mock('../src/api-client.js');

3. 沒有測試邊界值

// ❌ 只測 happy path
it('加法', () => {
  expect(add(1, 2)).toBe(3);
});
 
// ✅ 也測邊界
it('加法:零', () => { expect(add(0, 0)).toBe(0); });
it('加法:負數', () => { expect(add(-1, 1)).toBe(0); });
it('加法:大數', () => { expect(add(Number.MAX_SAFE_INTEGER, 1)).toBe(Number.MAX_SAFE_INTEGER + 1); });

4. 測試之間有依賴

// ❌ 測試 B 依賴測試 A 的結果
let counter = 0;
it('A', () => { counter++; expect(counter).toBe(1); });
it('B', () => { expect(counter).toBe(1); }); // 如果 A 沒跑就炸
 
// ✅ 每個測試獨立
describe('counter', () => {
  let counter;
  beforeEach(() => { counter = 0; });
  it('A', () => { counter++; expect(counter).toBe(1); });
  it('B', () => { counter++; expect(counter).toBe(1); });
});

下一步

Unit test 是基礎。寫完之後,下一個問題是:「使用者的完整操作流程,有沒有人在測?」

👉 下一篇 E2E 測試實戰:Playwright 從安裝到 CI 整合,講怎麼用 Playwright 寫出不容易壞的 E2E 測試。

如果對壓力測試有興趣,可以看 k6 腳本撰寫,用 k6 對 API 做效能測試。


本系列文章

  1. 測試策略(一):測試金字塔
  2. 測試策略(二):Unit vs Integration
  3. 測試策略(三):E2E + 壓力測試
  4. CD 整合
  5. API 契約測試
  6. 安全測試
  7. Smoke + 回歸測試
  8. 測試資料管理
  9. 視覺回歸測試
  10. AI 輔助測試
  11. ISO 29119)
  12. 測試相關憑證與學習路徑
  13. Unit Test 實戰:Jest 和 Vitest(本篇)
  14. E2E 測試實戰:Playwright