結論先講
Unit test 的價值不在「覆蓋率 100%」,在「改 code 的時候敢按 Enter」。 Jest 穩定成熟、生態系大;Vitest 快、原生支援 ESM、跟 Vite 無縫整合。兩個都能用,選一個堅持寫下去比較重要。
這篇不講理論,直接寫一個工具函式庫的測試。每段程式碼都能跑。
Jest vs Vitest:選哪個
| 比較項目 | Jest | Vitest |
|---|---|---|
| 速度 | 普通(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 jestpackage.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 vitestpackage.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-v8npx vitest run --coverageJest 內建 coverage(基於 istanbul):
npx jest --coverageCoverage 報表怎麼看
--------------------|---------|----------|---------|---------|
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%。理由:
- 最後 20% 通常是 edge case 和防禦性程式碼,測試寫起來成本高但價值低
- 100% coverage 不代表沒有 bug,只代表每行都跑過
- 強制 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 做效能測試。
本系列文章
- 測試策略(一):測試金字塔
- 測試策略(二):Unit vs Integration
- 測試策略(三):E2E + 壓力測試
- CD 整合
- API 契約測試
- 安全測試
- Smoke + 回歸測試
- 測試資料管理
- 視覺回歸測試
- AI 輔助測試
- ISO 29119)
- 測試相關憑證與學習路徑
- Unit Test 實戰:Jest 和 Vitest(本篇)
- E2E 測試實戰:Playwright