vitest について
Vitest は Vite を基盤とする TypeScript・JavaScript 向けテストフレームワークです。ネイティブ ESM と TypeScript に対応し、Jest 互換 API に加えて、モック、スナップショット、カバレッジ、コンポーネントテスト連携を備えています。
主な機能
- 01Viteベースのテスト実行とHMR
- 02TypeScriptとESMのネイティブ対応
- 03Jest互換のテストAPI
- 04モック、スパイ、フェイクタイマー
- 05スナップショットとカバレッジレポート
- 06ReactとVueのコンポーネントテスト
活用シーン
- 01TypeScript・JavaScriptモジュールのテスト
- 02Jestからのテスト移行
- 03ReactまたはVueコンポーネントのテスト
- 04カバレッジ確認とUIモードでのデバッグ
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill vitestインストール後、Claude Code で/vitestを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
CODE_EXAMPLES_INDEX.mdIMPLEMENTATION_SUMMARY.mdmetadata.json詳細
Vitest - モダンな TypeScript テスト
概要
Vitest は Vite を基盤とする次世代のテストフレームワークで、モダンな TypeScript/JavaScript プロジェクト向けに設計されています。HMR ベースのテスト実行、ネイティブ ESM サポート、TypeScript との高度な統合により、非常に高速なテスト実行を実現します。
主な機能:
- ⚡ Vite ネイティブ:HMR ベースのテストを即座に実行(Jest より 10~100 倍高速)
- 🎯 TypeScript を優先:TypeScript のサポートを標準搭載し、設定は不要です
- 🔄 ESM ネイティブ:ネイティブ ES モジュール、async/await、トップレベル await
- 🧪 Jest 互換:移行しやすい互換 API
- 📸 スナップショットテスト:スナップショットを標準サポート
- 🎨 コンポーネントテスト:React Testing Library、Vue Test Utils と統合
- 📊 カバレッジ:v8/c8 カバレッジを標準搭載(Istanbul より高速)
- 🌐 UI モード:テストのデバッグに使える美しい Web UI
インストール:
npm install -D vitest
# TypeScript types (usually auto-detected)
npm install -D @vitest/ui # Optional: UI mode
基本設定
1. Vitest の設定
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // Use describe/it/expect globally
environment: 'node', // or 'jsdom' for DOM testing
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/*.spec.ts',
],
},
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
},
});
2. TypeScript の設定
tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals"] // For global describe/it/expect
}
}
代替案(globals を使用しない場合):
import { describe, it, expect } from 'vitest';
3. package.json のスクリプト
{
"scripts": {
"test": "vitest run", // CI mode (single run)
"test:watch": "vitest", // Watch mode (default)
"test:ui": "vitest --ui", // UI mode
"test:coverage": "vitest run --coverage"
}
}
テストの基本パターン
基本的なテスト構成
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
it('adds two numbers correctly', () => {
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
it('handles negative numbers', () => {
expect(calculator.add(-5, 3)).toBe(-2);
});
});
TypeScript の型テスト
import { describe, it, expectTypeOf, assertType } from 'vitest';
interface User {
id: number;
name: string;
email: string;
}
describe('Type Safety', () => {
it('ensures correct types', () => {
const user: User = {
id: 1,
name: 'Alice',
email: '[email protected]',
};
// Type assertions
expectTypeOf(user.id).toBeNumber();
expectTypeOf(user.name).toBeString();
expectTypeOf(user).toMatchTypeOf<User>();
// Assert type at compile time
assertType<User>(user);
});
it('checks function return types', () => {
function getUser(): User {
return { id: 1, name: 'Bob', email: '[email protected]' };
}
expectTypeOf(getUser).returns.toMatchTypeOf<User>();
});
});
モックとスパイ
モジュールのモックに vi.mock を使用する
import { describe, it, expect, vi } from 'vitest';
import { fetchUser } from './api';
import { UserService } from './UserService';
// Mock entire module
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}));
describe('UserService', () => {
it('fetches user data', async () => {
const mockUser = { id: 1, name: 'Alice' };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
const service = new UserService();
const user = await service.getUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(user).toEqual(mockUser);
});
});
メソッドのスパイに vi.spyOn を使用する
import { describe, it, expect, vi } from 'vitest';
class Logger {
log(message: string) {
console.log(message);
}
}
describe('Logger Spy', () => {
it('tracks method calls', () => {
const logger = new Logger();
const spy = vi.spyOn(logger, 'log');
logger.log('Hello');
logger.log('World');
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('Hello');
expect(spy).toHaveBeenLastCalledWith('World');
spy.mockRestore(); // Restore original implementation
});
});
モックの実装
import { describe, it, expect, vi } from 'vitest';
describe('Mock Implementation', () => {
it('provides custom mock implementation', () => {
const mockFn = vi.fn((x: number) => x * 2);
expect(mockFn(5)).toBe(10);
expect(mockFn).toHaveBeenCalledWith(5);
// Change implementation
mockFn.mockImplementation((x: number) => x + 10);
expect(mockFn(5)).toBe(15);
// One-time implementation
mockFn.mockImplementationOnce((x: number) => 100);
expect(mockFn(5)).toBe(100);
expect(mockFn(5)).toBe(15); // Back to default
});
});
タイマーのモック
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Timer Mocking', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fast-forwards time', () => {
const callback = vi.fn();
setTimeout(callback, 1000);
vi.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
});
it('runs all timers', async () => {
const callback = vi.fn();
setTimeout(callback, 1000);
setTimeout(callback, 2000);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledTimes(2);
});
});
React テストとの統合
React Testing Library の設定
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D jsdom # For DOM environment
vitest.config.ts(React):
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
src/test/setup.ts:
import '@testing-library/jest-dom';
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});
React コンポーネントのテスト
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('renders initial count', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments counter on button click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('calls onChange callback', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<Counter initialCount={0} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(onChange).toHaveBeenCalledWith(1);
});
});
フックのテスト
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets counter', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Vue テストとの統合
Vue Test Utils の設定
npm install -D @vue/test-utils @vitejs/plugin-vue
npm install -D happy-dom # Faster alternative to jsdom
vitest.config.ts(Vue):
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: './src/test/setup.ts',
},
});
Vue コンポーネントのテスト
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter.vue', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, {
props: { initialCount: 5 },
});
expect(wrapper.text()).toContain('Count: 5');
});
it('increments on button click', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 0 },
});
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('Count: 1');
});
it('emits update event', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 0 },
});
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('update')).toBeTruthy();
expect(wrapper.emitted('update')?.[0]).toEqual([1]);
});
});
非同期テスト
Promise のテスト
import { describe, it, expect } from 'vitest';
describe('Async Operations', () => {
it('resolves promises', async () => {
const result = await Promise.resolve(42);
expect(result).toBe(42);
});
it('rejects promises', async () => {
await expect(Promise.reject(new Error('Failed'))).rejects.toThrow('Failed');
});
it('uses resolves matcher', async () => {
await expect(Promise.resolve(42)).resolves.toBe(42);
});
});
非同期関数のテスト
import { describe, it, expect, vi } from 'vitest';
async function fetchData(id: number): Promise<string> {
const response = await fetch(`/api/data/${id}`);
return response.json();
}
describe('Async Functions', () => {
it('fetches data successfully', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve('data'),
} as Response)
);
const data = await fetchData(1);
expect(data).toBe('data');
expect(fetch).toHaveBeenCalledWith('/api/data/1');
});
it('handles fetch errors', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
await expect(fetchData(1)).rejects.toThrow('Network error');
});
});
スナップショットテスト
基本的なスナップショット
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard Snapshots', () => {
it('matches snapshot', () => {
const { container } = render(
<UserCard name="Alice" email="[email protected]" />
);
expect(container.firstChild).toMatchSnapshot();
});
it('matches inline snapshot', () => {
const user = { id: 1, name: 'Bob' };
expect(user).toMatchInlineSnapshot(`
{
"id": 1,
"name": "Bob",
}
`);
});
});
スナップショットシリアライザー
import { describe, it, expect } from 'vitest';
expect.addSnapshotSerializer({
test: (val) => val && typeof val.toISOString === 'function',
print: (val) => `Date(${(val as Date).toISOString()})`,
});
describe('Custom Serializers', () => {
it('serializes dates consistently', () => {
const data = {
timestamp: new Date('2024-01-01T00:00:00.000Z'),
user: 'Alice',
};
expect(data).toMatchSnapshot();
});
});
カバレッジの設定
高度なカバレッジ設定
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.config.ts',
'**/types/',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
all: true, // Include untested files in coverage report
},
},
});
カバレッジの実行
# Generate coverage
npx vitest run --coverage
# Coverage with UI
npx vitest --coverage --ui
# Specific threshold enforcement
npx vitest run --coverage --coverage.lines=90
Jest からの移行
API の互換性
Vitest は Jest 互換の API を提供します:
// Jest syntax works in Vitest
import { describe, it, expect, jest } from 'vitest';
// Note: Use 'vi' instead of 'jest' for new code
import { describe, it, expect, vi } from 'vitest';
// Both work, but vi is preferred
const mockFn = vi.fn(); // Preferred
const mockFn2 = jest.fn(); // Also works
移行チェックリスト
1. 依存関係を更新する:
npm uninstall jest @types/jest ts-jest
npm install -D vitest @vitest/ui
2. package.json を更新する:
{
"scripts": {
"test": "vitest run", // Was: jest
"test:watch": "vitest" // Was: jest --watch
}
}
3. jest.config.js を vitest.config.ts に置き換える:
// Old: jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
// New: vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
4. テストファイルを更新する:
// Change imports
- import { jest } from '@jest/globals';
+ import { vi } from 'vitest';
// Update mocks
- jest.fn()
+ vi.fn()
- jest.spyOn()
+ vi.spyOn()
- jest.mock()
+ vi.mock()
高度なパターン
並行テスト
import { describe, it, expect } from 'vitest';
describe.concurrent('Parallel Tests', () => {
it('test 1', async () => {
await slowOperation();
expect(true).toBe(true);
});
it('test 2', async () => {
await slowOperation();
expect(true).toBe(true);
});
// Both tests run in parallel
});
テストコンテキスト
import { describe, it, expect, beforeEach } from 'vitest';
interface TestContext {
user: { id: number; name: string };
api: ApiClient;
}
describe<TestContext>('With Context', () => {
beforeEach((context) => {
context.user = { id: 1, name: 'Alice' };
context.api = new ApiClient();
});
it<TestContext>('uses context', ({ user, api }) => {
expect(user.name).toBe('Alice');
expect(api).toBeDefined();
});
});
カスタムマッチャー
import { expect } from 'vitest';
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
};
},
});
// Usage
expect(100).toBeWithinRange(90, 110);
ベストプラクティス
- globals: true を使用する - インポートが簡潔になり、Jest と互換性があります
- jest より vi を優先する - 新しいコードには Vitest ネイティブの API を使用します
- v8 カバレッジを使用する - Istanbul より高速で、ネイティブ ESM に対応します
- テストを分離して実行する - 各テストは独立させます
- 外部依存関係をモックする - ネットワーク、ファイルシステム、タイマーをモックします
- TypeScript を使用する - テストで完全な型安全性を確保します
- CI モードでテストを実行する - CI では watch モードではなく
vitest runを使用します - UI モードを活用する - 失敗したテストを視覚的にデバッグします
- describe.concurrent を使用する - 独立したテストを並行して実行します
- テストの焦点を絞る - 可能な場合は、1 つのテストにつき 1 つのアサーションにします
よくある落とし穴
❌ CI/CD で CI モードを使用しない:
// WRONG - watch mode hangs in CI
"test": "vitest"
// CORRECT - single run
"test": "vitest run"
✅ 正しい方法:
{
"scripts": {
"test": "vitest run", // CI-safe
"test:watch": "vitest", // Development
"test:ui": "vitest --ui" // Debugging
}
}
❌ 非同期テストで await を忘れる:
// WRONG - test passes before assertion
it('fetches data', () => {
fetchData().then(data => {
expect(data).toBeDefined(); // Never runs!
});
});
// CORRECT
it('fetches data', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
❌ モックのクリーンアップを忘れる:
// WRONG - mocks leak between tests
it('test 1', () => {
vi.spyOn(console, 'log');
// No cleanup!
});
// CORRECT
import { afterEach } from 'vitest';
afterEach(() => {
vi.restoreAllMocks();
});
❌ 誤った環境を使用する:
// WRONG - testing DOM in node environment
test: {
environment: 'node', // Can't test React components!
}
// CORRECT
test: {
environment: 'jsdom', // For React/Vue components
}
リソース
- ドキュメント:https://vitest.dev
- API リファレンス:https://vitest.dev/api/
- 移行ガイド:https://vitest.dev/guide/migration.html
- サンプル:https://github.com/vitest-dev/vitest/tree/main/examples
- UI モード:https://vitest.dev/guide/ui.html
関連スキル
Vitest を使用する際は、次の補完的なスキルもご検討ください:
- typescript-core:高度な TypeScript の型パターン、tsconfig、実行時検証
- react:Testing Library との統合による React コンポーネントのテスト
- test-driven-development:TDD の完全なワークフロー(RED/GREEN/REFACTOR サイクル)
クイック TypeScript 型パターン(単独で使用できるようにインライン化)
// Type-safe test factories with generics
function createMockData<T extends Record<string, unknown>>(
defaults: T,
overrides?: Partial<T>
): T {
return { ...defaults, ...overrides };
}
const mockUser = createMockData(
{ id: 1, name: 'Test', email: '[email protected]' },
{ name: 'Alice' }
);
// Runtime validation with Zod in tests
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
test('API returns valid user', async () => {
const response = await fetch('/api/user/1');
const data = await response.json();
// Runtime validation + type inference
const user = UserSchema.parse(data);
expect(user.email).toContain('@');
});
// Const type parameters for literal inference
const createTestConfig = <const T extends Record<string, unknown>>(config: T): T => config;
const testEnv = createTestConfig({ mode: 'test', debug: false });
// Type: { mode: "test"; debug: false } (literals preserved)
クイック React テストパターン(単独で使用できるようにインライン化)
// React Testing Library with Vitest
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
// Component testing
describe('UserProfile', () => {
test('renders user information', () => {
const user = { id: 1, name: 'Alice', email: '[email protected]' };
render(<UserProfile user={user} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
test('handles form submission', async () => {
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.type(screen.getByLabelText('Name'), 'Bob');
await user.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ name: 'Bob' });
});
});
});
// Hook testing
import { renderHook, act } from '@testing-library/react';
test('useCounter hook increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
クイック TDD ワークフローのリファレンス(単独で使用できるようにインライン化)
RED → GREEN → REFACTOR サイクル:
-
RED フェーズ:失敗するテストを書く
test('should authenticate user with valid credentials', () => { const user = { username: 'alice', password: 'secret123' }; const result = authenticate(user); expect(result.isAuthenticated).toBe(true); // This fails because authenticate() doesn't exist yet }); -
GREEN フェーズ:テストを通す
function authenticate(user: User): AuthResult { // Minimum code to pass the test if (user.username === 'alice' && user.password === 'secret123') { return { isAuthenticated: true }; } return { isAuthenticated: false }; } -
REFACTOR フェーズ:コードを改善する
function authenticate(user: User): AuthResult { // Clean up while keeping tests green const hashed = hashPassword(user.password); const storedUser = database.getUser(user.username); return { isAuthenticated: storedUser?.passwordHash === hashed }; }
テスト構造:Arrange-Act-Assert(AAA)
test('creates user successfully', async () => {
// Arrange: Set up test data
const userData = { username: 'alice', email: '[email protected]' };
// Act: Perform the action
const user = await createUser(userData);
// Assert: Verify outcome
expect(user.username).toBe('alice');
expect(user.email).toBe('[email protected]');
});
Vitest 固有の TDD 機能:
// Watch mode with HMR (instant feedback)
// vitest --watch
// UI mode for visual debugging
// vitest --ui
// Run only changed tests
// vitest --changed
// Benchmark mode for performance testing
import { bench } from 'vitest';
bench('authenticate performance', () => {
authenticate({ username: 'alice', password: 'secret' });
});
[TypeScript、React、TDD のワークフロー全体は、それぞれのスキルを組み合わせて導入すると利用できます]
まとめ
- Vitest は TypeScript テストの最新標準です
- Vite ネイティブの HMR により、Jest より 10~100 倍高速です
- ネイティブモジュールをサポートする ESM ファースト
- 移行しやすい Jest 互換 API
- 型サポートを標準搭載した TypeScript ファースト
- React と Vue の コンポーネントテスト
- v8 カバレッジ は Istanbul より高速です
- 視覚的にテストをデバッグできる UI モード
- 最適な用途:最新の TypeScript プロジェクト、Vite ベースのアプリ、React/Vue コンポーネント