Skip to content

ADR-007: テストライブラリをVitest + React Testing Libraryに変更

ステータス

採用

背景

現在のテスト戦略では具体的なテストライブラリが明記されておらず、一般的なJest + Testing Library構成を想定していた。しかし、Viteベースの開発環境を採用しており、関数型プログラミングアプローチとの親和性、パフォーマンス、開発体験の観点から、より適切なテストライブラリの選定が必要である。

検討事項

テストライブラリの比較評価

Jest vs Vitest

項目 Jest Vitest
実行速度 中程度(Node.js基盤) 高速(Viteエコシステム)
ESM対応 複雑な設定が必要 ネイティブ対応
TypeScript 追加設定必要 標準対応
Hot Reload なし あり(開発中テスト実行)
Vite統合 別設定が必要 完全統合
モック機能 豊富だが複雑 シンプル・直感的
学習コスト 中程度 低(Jestライク)

関数型プログラミングとの親和性

// Jest(従来アプローチ)
describe('Field operations', () => {
  let field: Field;

  beforeEach(() => {
    field = createEmptyField(); // セットアップ
  });

  test('should place puyo correctly', () => {
    const puyo = createPuyo(PuyoColor.RED, createPosition(1, 1));
    const newField = placePuyo(puyo, field);

    expect(getPuyoAt(newField, puyo.position)).toEqual(puyo);
  });
});

// Vitest(関数型アプローチ)
describe('Field operations', () => {
  test('should place puyo correctly', () => {
    const field = createEmptyField();
    const puyo = createPuyo(PuyoColor.RED, createPosition(1, 1));
    const newField = placePuyo(puyo, field);

    expect(getPuyoAt(newField, puyo.position)).toEqual(puyo);
  });

  // Vitestの並列実行による高速化
  test.concurrent('should handle multiple operations', async () => {
    const operations = [
      () => placePuyo(createRandomPuyo(createPosition(0, 0)), createEmptyField()),
      () => applyGravity(createFieldWithPuyos()),
      () => detectErasableGroups(createChainField())
    ];

    const results = await Promise.all(operations.map(op => op()));
    expect(results).toHaveLength(3);
  });
});

React Testing Libraryの優位性

@testing-library/react vs Enzyme

項目 Enzyme React Testing Library
テスト思想 実装詳細に依存 ユーザー体験重視
保守性 実装変更で破綻 実装変更に強い
React 18+対応 限定的 完全対応
アクセシビリティ 基本機能のみ 標準で強力
学習コスト 高(API複雑) 低(直感的API)
// React Testing Library アプローチ
import { render, screen, fireEvent } from '@testing-library/react';
import { GameBoard } from './GameBoard';

describe('GameBoard コンポーネント', () => {
  test('ゲーム開始時に空のフィールドが表示される', () => {
    const emptyGame = createEmptyGame();
    render(<GameBoard game={emptyGame} />);

    // ユーザーの視点でテスト
    expect(screen.getByRole('grid')).toBeInTheDocument();
    expect(screen.getByLabelText('ゲームフィールド')).toBeEmptyDOMElement();
  });

  test('キーボード操作により適切な処理が呼ばれる', () => {
    const mockOnInput = vi.fn(); // Vitestのモック
    const game = createGameInProgress();

    render(<GameBoard game={game} onInput={mockOnInput} />);

    // 実際のユーザー操作をシミュレート
    fireEvent.keyDown(document, { key: 'ArrowLeft' });

    expect(mockOnInput).toHaveBeenCalledWith('moveLeft');
  });
});

決定

Vitest + React Testing Libraryの組み合わせを採用する

採用理由

  1. 開発体験の向上
  2. Viteとの完全統合による高速なテスト実行
  3. Hot Reloadによるテスト駆動開発の効率化
  4. ESM/TypeScriptのネイティブサポート

  5. 関数型プログラミングとの親和性

  6. 純粋関数テストの簡潔な記述
  7. 並列実行による性能向上
  8. イミュータブルテストデータの扱いやすさ

  9. React 18+との最適化

  10. Concurrent Featuresの完全サポート
  11. SuspenseやuseTransitionのテスト対応 -最新Reactパターンとの整合性

テストライブラリ構成

{
  "devDependencies": {
    "vitest": "^1.0.0",
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.0.0",
    "@testing-library/user-event": "^14.0.0",
    "jsdom": "^23.0.0",
    "happy-dom": "^12.0.0",
    "fast-check": "^3.15.0"
  }
}

実装戦略

Vitest設定

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom', // またはhappy-dom
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*'
      ],
      thresholds: {
        global: {
          branches: 75,
          functions: 85,
          lines: 80,
          statements: 80
        }
      }
    },
    // 関数型テストに最適化
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
        useAtomics: true
      }
    }
  }
});

テストセットアップ

// src/test/setup.ts
import '@testing-library/jest-dom';
import { expect, afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';

// 各テスト後のクリーンアップ
afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});

// カスタムマッチャーの追加
expect.extend({
  toBeValidGame(received: Game) {
    const pass = received.field !== null && received.score >= 0;
    return {
      message: () => `expected ${received} to be a valid game`,
      pass,
    };
  },
});

関数型テストパターン

import { describe, test, expect } from 'vitest';
import * as fc from 'fast-check';

describe('純粋関数テスト', () => {
  // Property-Based Testing with fast-check
  test('Position creation properties', () => {
    fc.assert(fc.property(
      fc.integer(0, 10),
      fc.integer(0, 10),
      (x, y) => {
        const pos = createPosition(x, y);
        expect(pos.x).toBe(x);
        expect(pos.y).toBe(y);
        expect(isValidPosition(6, 12, pos)).toBe(x < 6 && y < 12);
      }
    ));
  });

  // 関数合成のテスト
  test('Field operations composition', () => {
    const field = createEmptyField();
    const puyo1 = createPuyo(PuyoColor.RED, createPosition(0, 11));
    const puyo2 = createPuyo(PuyoColor.RED, createPosition(1, 11));

    const result = fp.flow(
      placePuyo(puyo1),
      placePuyo(puyo2),
      applyGravity,
      detectErasableGroups
    )(field);

    expect(result.erasableGroups).toHaveLength(0); // 2個では消えない
  });
});

React Testing Libraryパターン

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('GameBoardコンポーネント', () => {
  test('アクセシブルなゲーム体験を提供する', async () => {
    const user = userEvent.setup();
    render(<GameBoard initialGame={createEmptyGame()} />);

    // アクセシビリティ重視のテスト
    const gameField = screen.getByRole('grid', { name: 'ぷよぷよゲームフィールド' });
    expect(gameField).toBeInTheDocument();

    const startButton = screen.getByRole('button', { name: 'ゲーム開始' });
    await user.click(startButton);

    // ゲーム開始後の状態確認
    await waitFor(() => {
      expect(screen.getByText('ゲーム中')).toBeInTheDocument();
    });
  });

  test('キーボード操作の完全サポート', async () => {
    const user = userEvent.setup();
    render(<GameBoard initialGame={createGameInProgress()} />);

    // 実際のユーザー操作をシミュレート
    await user.keyboard('{ArrowLeft}');
    await user.keyboard(' '); // スペースで回転
    await user.keyboard('{ArrowDown}');

    // UIの変化を確認(実装詳細ではなくユーザー体験)
    expect(screen.getByLabelText('現在のぷよの位置')).toHaveTextContent('(0, 1)');
  });
});

パフォーマンス最適化

並列実行戦略

// vitest.config.ts での並列化設定
export default defineConfig({
  test: {
    // 純粋関数テストは並列実行
    pool: 'threads',
    poolOptions: {
      threads: {
        maxThreads: 4,
        minThreads: 2
      }
    },
    // UIテストは順次実行(DOM競合回避)
    sequence: {
      concurrent: true,
      shuffle: true
    }
  }
});

// テストファイルでの並列制御
describe('Pure function tests', () => {
  test.concurrent('Position operations', async () => { /* ... */ });
  test.concurrent('Puyo transformations', async () => { /* ... */ });
});

describe('UI integration tests', () => {
  test('Component rendering', () => { /* 順次実行 */ });
  test('User interactions', () => { /* 順次実行 */ });
});

CI/CD統合

GitHub Actions設定更新

# .github/workflows/test.yml
name: Test Suite with Vitest

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

      - name: Generate coverage report
        run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/coverage-final.json

package.json スクリプト

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:unit": "vitest run --reporter=verbose src/domain src/application",
    "test:integration": "vitest run --reporter=verbose src/infrastructure src/ui",
    "test:watch": "vitest --watch",
    "test:related": "vitest related"
  }
}

移行計画

Phase 1: 環境構築(1週間)

  • Vitestとテスティングライブラリの導入
  • 基本設定とCI/CD統合
  • サンプルテストの作成・実行確認

Phase 2: コアテスト移行(2週間)

  • ドメインロジックテストの変換
  • 関数型テストパターンの適用
  • Property-Based Testingの導入

Phase 3: UIテスト移行(1週間)

  • React Testing Libraryへの移行
  • アクセシビリティテストの強化
  • ユーザー体験重視テストの導入

期待される効果

開発体験の改善

  1. 高速テスト実行: 現在の2-3倍の実行速度
  2. 開発効率向上: Hot Reloadによるテスト駆動開発
  3. 型安全性: TypeScriptとの完全統合

テスト品質の向上

  1. 実装詳細からの脱却: ユーザー体験重視のテスト
  2. アクセシビリティ向上: Testing Libraryの標準機能
  3. 保守性の改善: 実装変更に強いテスト設計

関数型プログラミングとの最適化

  1. 純粋関数テストの簡潔性
  2. Property-Based Testingによる網羅性
  3. 並列実行による性能向上

リスク・制約

潜在的リスク

  1. 学習コスト: 新しいAPIとパターンの習得
  2. 移行期間: 既存テストの変換作業
  3. デバッグ体験: 新しいツールチェーンへの慣れ

対策

  1. 段階的移行: 新しいテストはVitest、既存は必要に応じて移行
  2. ドキュメント整備: ベストプラクティスとパターン集の作成
  3. チーム教育: Vitestとモダンテスト手法の勉強会実施

関連ADR

  • ADR-002: フロントエンド技術スタック選定(Vite採用)
  • ADR-004: TDD開発手法採用
  • ADR-006: 関数型プログラミング採用

日付: 2025-08-12
作成者: Claude Code
レビュー者: 開発チーム・技術リード
次回見直し: 2025-11-12(3ヶ月後)