mcpField
V
vitest-testing-patterns

vitest-testing-patterns

github.com/curiositechテスト・品質257361 回インストール2 日前に更新
リポジトリ

vitest-testing-patterns について

VitestとReact Testing Libraryを使う開発者向けのテストパターンスキルです。ユニット、コンポーネント、統合テストの例を示し、モック、非同期処理、カバレッジも扱います。

主な機能

  1. 01APIルートのテスト
  2. 02Reactコンポーネントのテスト
  3. 03ユーティリティ関数のテスト
  4. 04モジュールと関数のモック
  5. 05非同期処理とタイマーの扱い

活用シーン

  1. 01ユーティリティのユニットテスト作成
  2. 02コンポーネントの描画と操作の検証
  3. 03APIや外部サービスのモック
  4. 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);
  });
});

クエリの優先順位

次の順(優先度の高いものから低いもの)でクエリを使用します。

  1. getByRole - アクセシブルなクエリ(ボタン、リンク、見出し)
  2. getByLabelText - ラベル付きのフォームフィールド
  3. getByPlaceholderText - プレースホルダー付きの入力欄
  4. getByText - インタラクティブではない要素
  5. 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');

参考資料

コメント