跳至主要內容

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 遷移

大多數情況下,只需要:

  1. 替換 jest.config.jsvite.config.ts 中的 test 欄位
  2. jest.fn() 換成 vi.fn()jest.mock() 換成 vi.mock()
  3. 移除 @types/jest,改用 Vitest 的型別
// 全域型別設定
// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

小結

Vitest 讓「寫測試」這件事變得更愉快。啟動快、設定少、與 Vite 生態完美整合。如果你的前端專案還沒有測試,或者正在用 Jest 並且覺得設定很麻煩,Vitest 是非常值得嘗試的替代方案。

分享這篇文章