vitest-testing-patterns について
VitestとReact Testing Libraryを使う開発者向けのテストパターンスキルです。ユニット、コンポーネント、統合テストの例を示し、モック、非同期処理、カバレッジも扱います。
主な機能
- 01APIルートのテスト
- 02Reactコンポーネントのテスト
- 03ユーティリティ関数のテスト
- 04モジュールと関数のモック
- 05非同期処理とタイマーの扱い
活用シーン
- 01ユーティリティのユニットテスト作成
- 02コンポーネントの描画と操作の検証
- 03APIや外部サービスのモック
- 04カバレッジとCI設定の確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ curiositech/ some_claude_skills --skill vitest-testing-patternsインストール後、Claude Code で/vitest-testing-patternsを入力して読み込まれたか確認します。
同梱ファイル.claude-plugin/plugin.json
詳細
Vitest のテストパターン
このスキルは、プロジェクトの規約に従って Vitest と React Testing Library を使用した効果的なテストの作成に役立ちます。
使用するタイミング
✅ このスキルを使用する場合:
- ユーティリティや関数のユニットテストを作成する
- React Testing Library を使用してコンポーネントテストを作成する
- API 呼び出し、データベース、外部サービスのモックを設定する
- 統合テストのパターンを確認する
- テストカバレッジと CI の設定を理解する
❌ 使用しない場合:
- Jest 固有のパターン → 似ていますが、違いについては Jest のドキュメントを確認してください
- エンドツーエンドテスト → Playwright または Cypress のスキルを使用してください
- パフォーマンステスト → 専用のパフォーマンスツールを使用してください
- API コントラクトテスト → OpenAPI/Pact のパターンを使用してください
テストインフラストラクチャ
設定:vitest.config.ts
- 環境:jsdom
- セットアップファイル:
src/test/setup.ts - カバレッジ:v8 プロバイダー
コマンド:
npm test # Watch mode
npm run test:run # Single run
npm run test:coverage # With coverage
ファイル構成
src/
├── app/api/__tests__/ # API route tests
├── components/__tests__/ # Component tests
├── lib/__tests__/ # Library/utility tests
└── lib/{feature}/__tests__/ # Feature-specific tests
テストの名前は {name}.test.ts または {name}.test.tsx にします。
基本的なテストパターン
1. API ルートのテスト
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET, POST } from '../route';
import { NextRequest } from 'next/server';
// Mock dependencies
vi.mock('@/lib/auth', () => ({
getSession: vi.fn(),
}));
vi.mock('@/db', () => ({
db: {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
},
}));
describe('GET /api/feature', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 401 when not authenticated', async () => {
vi.mocked(getSession).mockResolvedValue(null);
const request = new NextRequest('http://localhost/api/feature');
const response = await GET(request);
expect(response.status).toBe(401);
});
it('returns data when authenticated', async () => {
vi.mocked(getSession).mockResolvedValue({ userId: 'user-123' });
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: '1', name: 'Test' }]),
}),
});
const request = new NextRequest('http://localhost/api/feature');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
});
});
2. コンポーネントテスト
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FeatureComponent } from '../FeatureComponent';
// Mock hooks
vi.mock('@/hooks/useAuth', () => ({
useAuth: vi.fn().mockReturnValue({
user: { id: 'user-123', name: 'Test User' },
isLoading: false,
}),
}));
describe('FeatureComponent', () => {
it('renders loading state', () => {
vi.mocked(useAuth).mockReturnValueOnce({
user: null,
isLoading: true,
});
render(<FeatureComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('handles user interaction', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<FeatureComponent onSubmit={onSubmit} />);
await user.type(screen.getByRole('textbox'), 'Test input');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith('Test input');
});
it('displays error state', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
render(<FeatureComponent />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/error/i);
});
});
});
3. ライブラリ/ユーティリティのテスト
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { processData, formatDate } from '../utils';
describe('processData', () => {
it('transforms input correctly', () => {
const input = { raw: 'data' };
const result = processData(input);
expect(result).toEqual({
processed: true,
data: 'DATA',
});
});
it('throws on invalid input', () => {
expect(() => processData(null)).toThrow('Invalid input');
});
});
describe('formatDate', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('formats relative dates', () => {
const yesterday = new Date('2025-01-14T10:00:00Z');
expect(formatDate(yesterday)).toBe('yesterday');
});
});
モックのパターン
モジュールのモック
// Mock entire module
vi.mock('@/lib/auth', () => ({
getSession: vi.fn(),
requireAuth: vi.fn(),
}));
// Mock with partial implementation
vi.mock('date-fns', async () => {
const actual = await vi.importActual('date-fns');
return {
...actual,
format: vi.fn(() => '2025-01-15'),
};
});
// Mock default export (like Anthropic SDK)
vi.mock('@anthropic-ai/sdk', () => ({
default: class MockAnthropic {
messages = {
create: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Mock response' }],
usage: { input_tokens: 10, output_tokens: 20 },
}),
};
},
}));
関数のモック
// Create mock function
const mockFn = vi.fn();
// Set return values
mockFn.mockReturnValue('sync value');
mockFn.mockResolvedValue('async value');
mockFn.mockRejectedValue(new Error('Failed'));
// One-time behavior
mockFn.mockReturnValueOnce('first call only');
// Custom implementation
mockFn.mockImplementation((arg) => arg.toUpperCase());
// Verify calls
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('expected', 'args');
チェーンモックのパターン(Drizzle ORM)
vi.mock('@/db', () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: '1' }]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: 'new-1' }]),
}),
}),
},
}));
タイマーのモック
describe('debounced function', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('debounces calls', async () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
debounced();
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(1);
});
});
クエリの優先順位
次の順(優先度の高いものから低いもの)でクエリを使用します。
- getByRole - アクセシブルなクエリ(ボタン、リンク、見出し)
- getByLabelText - ラベル付きのフォームフィールド
- getByPlaceholderText - プレースホルダー付きの入力欄
- getByText - インタラクティブではない要素
- getByTestId - 最終手段(data-testid)
// Preferred
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { level: 1 });
screen.getByLabelText(/email/i);
// Avoid unless necessary
screen.getByTestId('submit-button');
非同期処理のパターン
// Wait for element to appear
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
// Find (built-in waitFor)
const element = await screen.findByText('Loaded');
// Wait for element to disappear
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
テストのクリーンアップ
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup(); // React cleanup (automatic with setup.ts)
vi.clearAllMocks(); // Reset mock call counts
vi.resetAllMocks(); // Reset mocks to initial state
vi.restoreAllMocks(); // Restore original implementations
});
アクセシビリティテスト
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Component />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
よく使うマッチャー
// jest-dom matchers (from setup.ts)
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('text');
expect(element).toHaveAttribute('href', '/path');
expect(element).toHaveClass('active');
expect(input).toHaveValue('input value');
参考資料
コメント
Markdown 対応