Vitest 測試框架入門:現代前端的高速測試體驗
6 分鐘閱讀 1,000 字
Vitest 測試框架入門:現代前端的高速測試體驗
前端測試長期被 Jest 主導,但 Jest 有一個痛點:它有自己的模組解析邏輯,與 Vite 的設定不相容,需要額外的轉換設定。Vitest 直接在 Vite 上運行,自然繼承了 Vite 的所有設定,讓測試速度和設定複雜度都有質的提升。
為什麼選擇 Vitest?
- 極速:利用 Vite 的 HMR 機制,測試重跑時只重新執行受影響的測試
- 零設定:如果你的專案已有
vite.config.ts,幾乎不需要額外設定 - API 相容 Jest:從 Jest 遷移幾乎是無痛的
- 原生 TypeScript:不需要
ts-jest或 Babel 轉換 - 內建 UI:有視覺化的測試結果介面
安裝與設定
npm install --save-dev vitest @vitest/ui jsdom// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
// 使用 jsdom 模擬瀏覽器環境
environment: 'jsdom',
// 類似 Jest 的全域 API(describe, it, expect)
globals: true,
// 在每個測試前執行的設定檔
setupFiles: ['./src/test/setup.ts'],
// 覆蓋率報告
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'src/test/']
}
}
});// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}基本測試語法
// src/utils/format.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { formatCurrency, formatDate, slugify } from './format';
describe('formatCurrency', () => {
it('格式化台幣金額', () => {
expect(formatCurrency(1234567, 'TWD')).toBe('NT$1,234,567');
});
it('處理小數點', () => {
expect(formatCurrency(99.9, 'USD')).toBe('$99.90');
});
it('負數金額', () => {
expect(formatCurrency(-500, 'TWD')).toBe('-NT$500');
});
});
describe('slugify', () => {
it.each([
['Hello World', 'hello-world'],
['Vue 3 教學', 'vue-3'],
[' spaces ', 'spaces'],
])('"%s" 應轉換為 "%s"', (input, expected) => {
expect(slugify(input)).toBe(expected);
});
});測試非同步程式碼
import { describe, it, expect, vi } from 'vitest';
import { fetchUser, createUser } from './api';
describe('User API', () => {
it('成功取得使用者', async () => {
const user = await fetchUser('user-123');
expect(user).toMatchObject({
id: 'user-123',
name: expect.any(String)
});
});
it('使用者不存在時拋出錯誤', async () => {
await expect(fetchUser('nonexistent')).rejects.toThrow('User not found');
});
it('Promise resolve 的值', () => {
return expect(Promise.resolve(42)).resolves.toBe(42);
});
});Mock 與 Spy
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock 整個模組
vi.mock('../lib/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ messageId: 'mock-id' })
}));
import { sendEmail } from '../lib/email';
import { notifyUser } from './notification';
describe('notifyUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('成功發送通知時呼叫 sendEmail', async () => {
await notifyUser('user-1', '您有一則新訊息');
expect(sendEmail).toHaveBeenCalledOnce();
expect(sendEmail).toHaveBeenCalledWith({
to: expect.stringContaining('@'),
subject: expect.any(String),
body: '您有一則新訊息'
});
});
it('email 服務失敗時記錄錯誤', async () => {
vi.mocked(sendEmail).mockRejectedValueOnce(new Error('SMTP error'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await notifyUser('user-1', '測試');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to send'),
expect.any(Error)
);
});
});測試 Vue 元件(搭配 @vue/test-utils)
npm install --save-dev @vue/test-utils// src/components/Counter.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter 元件', () => {
it('初始值為 0', () => {
const wrapper = mount(Counter);
expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
});
it('點擊 + 按鈕增加計數', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 5 }
});
await wrapper.find('[data-testid="increment"]').trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('6');
});
it('emit update:count 事件', async () => {
const wrapper = mount(Counter);
await wrapper.find('[data-testid="increment"]').trigger('click');
expect(wrapper.emitted('update:count')).toBeTruthy();
expect(wrapper.emitted('update:count')![0]).toEqual([1]);
});
});時間相關測試
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './utils';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('在等待時間內只執行一次', () => {
const fn = vi.fn();
const debouncedFn = debounce(fn, 300);
debouncedFn(1);
debouncedFn(2);
debouncedFn(3);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith(3);
});
});視覺化 UI
執行 npm run test:ui 會開啟瀏覽器介面,讓你看到每個測試的狀態、執行時間、覆蓋率,以及點擊測試名稱直接跳到原始碼。比 terminal 輸出直觀許多。
從 Jest 遷移
大多數情況下,只需要:
- 替換
jest.config.js為vite.config.ts中的test欄位 - 把
jest.fn()換成vi.fn(),jest.mock()換成vi.mock() - 移除
@types/jest,改用 Vitest 的型別
// 全域型別設定
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}小結
Vitest 讓「寫測試」這件事變得更愉快。啟動快、設定少、與 Vite 生態完美整合。如果你的前端專案還沒有測試,或者正在用 Jest 並且覺得設定很麻煩,Vitest 是非常值得嘗試的替代方案。
分享這篇文章