T-CREATOR

Vitest カバレッジ技術の全貌:閾値設定・除外ルール・レポート可視化

Vitest カバレッジ技術の全貌:閾値設定・除外ルール・レポート可視化

Vitest のカバレッジ機能を活用することで、テストコードの品質向上と効率的な開発フローの実現が可能になります。しかし、適切な閾値設定や除外ルール、レポートの活用方法を理解していないと、せっかくの機能を十分に活用できません。

本記事では、Vitest カバレッジの 3 つの核心技術(閾値設定・除外ルール・レポート可視化)について、基礎から実践まで段階的に解説いたします。これらの技術を習得することで、プロジェクトの品質管理が格段に向上し、チーム全体の開発効率も大幅に改善されるでしょう。

背景

カバレッジ測定とは何か

カバレッジ測定とは、テストコードがソースコードのどの部分をどの程度網羅しているかを数値で表す技術です。この測定により、テストが十分に行われていない箇所を特定し、潜在的なバグの発見につながります。

カバレッジには主に 4 つの種類があります。

typescript// カバレッジの種類と測定対象
interface CoverageTypes {
  lines: number; // 行カバレッジ:実行された行数の割合
  functions: number; // 関数カバレッジ:実行された関数の割合
  branches: number; // 分岐カバレッジ:実行された条件分岐の割合
  statements: number; // 文カバレッジ:実行された文の割合
}

以下の図は、カバレッジ測定の基本的な仕組みを示しています。

mermaidflowchart TB
  source[ソースコード] --> instrument[計装処理]
  instrument --> test[テスト実行]
  test --> collect[実行情報収集]
  collect --> calculate[カバレッジ計算]
  calculate --> report[レポート生成]

  subgraph coverage_types[カバレッジ種別]
    lines[行カバレッジ]
    functions[関数カバレッジ]
    branches[分岐カバレッジ]
    statements[文カバレッジ]
  end

  calculate --> coverage_types

補足:計装処理では、実行時にどの部分が通過されたかを記録するためのコードが自動的に挿入されます。

テストの質を測る指標としての意義

カバレッジ数値は、テストの網羅性を客観的に評価する重要な指標となります。しかし、数値が高いことが必ずしも高品質なテストを意味するわけではありません。

効果的なカバレッジ活用のポイントは以下の通りです。

#ポイント説明推奨アプローチ
1質的評価との組み合わせ数値だけでなくテスト内容も重視コードレビューでテストロジックも確認
2継続的な改善一度の測定で満足せず定期的に見直し週次でカバレッジトレンドを確認
3チーム全体での共有個人ではなくチーム全体で品質向上朝会でカバレッジ状況を共有

開発チームでの活用場面

実際の開発現場では、以下のような場面でカバレッジ測定が威力を発揮します。

プルリクエスト時の品質チェック

新機能追加やバグ修正の際に、影響範囲のテスト漏れを防ぐことができます。

typescript// GitHub Actions でのカバレッジチェック例
name: Coverage Check
on: [pull_request]

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests with coverage
        run: yarn test --coverage
      - name: Check coverage threshold
        run: yarn coverage:check

リリース前の最終確認

リリース直前に全体のカバレッジを確認し、重要な機能のテスト漏れがないかを検証します。

新メンバーのオンボーディング

新しい開発者がプロジェクトに参加する際の、コードベース理解とテスト文化の浸透に活用できます。

課題

カバレッジ目標の設定方法が不明

多くのプロジェクトで直面する最初の課題は、適切なカバレッジ目標値の設定です。業界標準や他社の事例を参考にしても、自社のプロジェクト特性に合った数値を見つけるのは容易ではありません。

特に以下のような状況で困惑することが多いです。

  • スタートアップで高速開発を重視する場合の適切な閾値
  • レガシーコードが混在するプロジェクトでの段階的導入
  • チームメンバーのスキルレベルにばらつきがある場合の目標設定

特定ファイルを除外したい場合の対処法

実際の開発では、すべてのファイルをカバレッジ測定の対象にすべきではない場合があります。

mermaidflowchart LR
  all_files[すべてのファイル] --> should_include{テスト対象?}
  should_include -->|Yes| test_target[テスト対象ファイル]
  should_include -->|No| exclude_files[除外対象ファイル]

  exclude_files --> config[設定ファイル]
  exclude_files --> utils[ユーティリティ]
  exclude_files --> third_party[サードパーティ]
  exclude_files --> generated[自動生成ファイル]

  test_target --> coverage_calc[カバレッジ計算]

除外したいファイルの典型例には以下があります。

  • 設定ファイル(webpack.config.js、next.config.js など)
  • 型定義ファイル(*.d.ts)
  • モックデータやテスト用ユーティリティ
  • サードパーティライブラリのラッパー

しかし、これらの除外設定を適切に行う方法や、将来的なメンテナンスを考慮した設定方法については十分な情報が不足しているのが現状です。

レポートが見づらく活用しきれない

Vitest が生成するカバレッジレポートは情報量が豊富ですが、その分複雑で読み解くのに時間がかかります。

主な問題点は以下の通りです。

  • HTML レポートの情報密度が高すぎて要点がつかめない
  • JSON レポートを活用した自動化の方法が分からない
  • 複数人での確認・共有に適した形式が不明
  • 時系列でのカバレッジ変化を追跡できない

CI/CD 環境での自動化が困難

ローカル開発環境でのカバレッジ測定は比較的簡単ですが、CI/CD 環境での自動化には多くの課題があります。

mermaidsequenceDiagram
    participant dev as 開発者
    participant repo as リポジトリ
    participant ci as CI/CD
    participant report as レポートサーバー

    dev->>repo: コードpush
    repo->>ci: ビルド開始
    ci->>ci: テスト実行
    ci->>ci: カバレッジ測定
    ci->>report: レポートアップロード
    ci-->>dev: 結果通知(失敗時)
    report-->>dev: レポート確認

特に以下の点で困難を感じることが多いです。

  • 異なる CI 環境(GitHub Actions、GitLab CI、Jenkins など)での設定方法の違い
  • カバレッジデータの永続化と履歴管理
  • 閾値を下回った場合のビルド失敗設定
  • Slack やメールでの結果通知の実装

解決策

カバレッジ閾値の設定

vitest.config.ts での設定方法

Vitest でのカバレッジ閾値設定は、vitest.config.tsファイルで行います。以下が基本的な設定方法です。

typescript// vitest.config.ts - 基本的な閾値設定
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      // カバレッジプロバイダーの選択
      provider: 'v8', // または 'c8', 'istanbul'

      // レポート形式の指定
      reporter: ['text', 'json', 'html'],

      // 出力ディレクトリ
      reportsDirectory: './coverage',
    },
  },
});

次に、具体的な閾値を設定します。閾値は全体レベルと個別ファイルレベルで設定可能です。

typescript// vitest.config.ts - 詳細な閾値設定
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',

      // 全体的な閾値設定
      thresholds: {
        lines: 80, // 行カバレッジ 80%以上
        functions: 75, // 関数カバレッジ 75%以上
        branches: 70, // 分岐カバレッジ 70%以上
        statements: 80, // 文カバレッジ 80%以上
      },
    },
  },
});

個別ファイルやディレクトリごとに異なる閾値を設定することも可能です。

typescript// vitest.config.ts - ファイル別閾値設定
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',

      // ディレクトリ別の閾値設定
      thresholds: {
        // デフォルト閾値
        lines: 80,
        functions: 75,
        branches: 70,
        statements: 80,

        // 特定ディレクトリの閾値
        './src/utils/**': {
          lines: 90, // ユーティリティは高い閾値
          functions: 85,
          branches: 80,
          statements: 90,
        },

        // 実験的機能は低い閾値
        './src/experimental/**': {
          lines: 60,
          functions: 50,
          branches: 50,
          statements: 60,
        },
      },
    },
  },
});

段階的な閾値設定戦略

既存プロジェクトにカバレッジ閾値を導入する場合は、段階的なアプローチが効果的です。

以下の表は、3 ヶ月間での段階的導入計画の例です。

#期間行カバレッジ目標関数カバレッジ目標重点ポイント
11 ヶ月目50%40%現状把握とベースライン確立
22 ヶ月目65%55%重要機能のテスト追加
33 ヶ月目80%70%エッジケースとエラーハンドリング

実装例として、環境変数を使った段階的設定を示します。

typescript// vitest.config.ts - 段階的閾値設定
const getCoverageThresholds = () => {
  const phase = process.env.COVERAGE_PHASE || '1';

  const thresholds = {
    '1': {
      lines: 50,
      functions: 40,
      branches: 35,
      statements: 50,
    },
    '2': {
      lines: 65,
      functions: 55,
      branches: 50,
      statements: 65,
    },
    '3': {
      lines: 80,
      functions: 70,
      branches: 65,
      statements: 80,
    },
  };

  return thresholds[phase] || thresholds['1'];
};

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      thresholds: getCoverageThresholds(),
    },
  },
});

この設定により、COVERAGE_PHASE=2 yarn test --coverageのように実行して段階を切り替えられます。

プロジェクトサイズ別推奨値

プロジェクトの規模や特性に応じた推奨閾値は以下の通りです。

小規模プロジェクト(~1 万行)の推奨設定

typescript// 小規模プロジェクト向け設定
export const smallProjectThresholds = {
  lines: 85, // 高い閾値で品質を担保
  functions: 80,
  branches: 75,
  statements: 85,
};

中規模プロジェクト(1 万~10 万行)の推奨設定

typescript// 中規模プロジェクト向け設定
export const mediumProjectThresholds = {
  lines: 75, // バランスの取れた閾値
  functions: 70,
  branches: 65,
  statements: 75,
};

大規模プロジェクト(10 万行以上)の推奨設定

typescript// 大規模プロジェクト向け設定
export const largeProjectThresholds = {
  lines: 65, // 現実的な閾値設定
  functions: 60,
  branches: 55,
  statements: 65,
};

除外ルールの活用

テストファイルの除外

テストファイル自体はカバレッジ測定の対象外にするのが一般的です。以下の設定でテストファイルを除外できます。

typescript// vitest.config.ts - テストファイル除外設定
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',

      // 除外パターンの設定
      exclude: [
        // デフォルトの除外パターン
        'coverage/**',
        'dist/**',
        'packages/*/test/**',
        '**/*.d.ts',

        // テストファイルの除外
        '**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
        '**/tests/**',
        '**/__tests__/**',

        // テストユーティリティの除外
        '**/test-utils/**',
        '**/testing/**',
        '**/*.mock.{js,ts}',
        '**/fixtures/**',
      ],
    },
  },
});

ユーティリティ・設定ファイルの除外

プロジェクト固有のファイルも適切に除外する必要があります。

typescript// vitest.config.ts - ユーティリティ・設定ファイル除外
export default defineConfig({
  test: {
    coverage: {
      exclude: [
        // 基本の除外パターン
        ...configDefaults.coverage.exclude,

        // 設定ファイル
        '*.config.{js,ts}',
        '**/.{eslint,prettier}rc.{js,cjs,yml,yaml,json}',
        '**/tailwind.config.js',
        '**/postcss.config.js',

        // ユーティリティファイル
        '**/constants.{js,ts}',
        '**/enums.{js,ts}',
        '**/types/**',

        // ビルド関連
        '**/scripts/**',
        '**/.next/**',
        '**/public/**',

        // 開発ツール
        '**/storybook-static/**',
        '**/.storybook/**',
      ],
    },
  },
});

サードパーティライブラリの除外

外部ライブラリや node_modules は除外するのが適切です。

typescript// vitest.config.ts - サードパーティ除外設定
export default defineConfig({
  test: {
    coverage: {
      exclude: [
        // デフォルト除外パターン
        ...configDefaults.coverage.exclude,

        // Node modules
        'node_modules/**',

        // サードパーティコード
        '**/vendor/**',
        '**/lib/**',

        // 自動生成ファイル
        '**/generated/**',
        '**/.generated/**',
        '**/schema.ts', // GraphQLスキーマなど

        // CDN や外部スクリプト
        '**/public/js/**',
        '**/assets/js/**',
      ],
    },
  },
});

除外ルールは複雑になりがちなので、保守性を考慮した設定方法を示します。

typescript// vitest.config.ts - 保守性の高い除外設定
const createExcludePatterns = () => {
  const baseExcludes = configDefaults.coverage.exclude;

  const testExcludes = [
    '**/*.{test,spec}.{js,ts,tsx}',
    '**/tests/**',
    '**/__tests__/**',
  ];

  const configExcludes = [
    '*.config.{js,ts}',
    '**/.{eslint,prettier}rc.*',
    '**/tailwind.config.js',
  ];

  const buildExcludes = [
    '**/dist/**',
    '**/.next/**',
    '**/public/**',
  ];

  return [
    ...baseExcludes,
    ...testExcludes,
    ...configExcludes,
    ...buildExcludes,
  ];
};

export default defineConfig({
  test: {
    coverage: {
      exclude: createExcludePatterns(),
    },
  },
});

レポート可視化の実装

HTML 形式での詳細レポート

Vitest はデフォルトで HTML 形式の詳細レポートを生成できます。以下の設定で最適化された HTML レポートを作成しましょう。

typescript// vitest.config.ts - HTML レポート設定
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',

      // 複数のレポート形式を同時生成
      reporter: ['text', 'json', 'html', 'lcov'],

      // レポート出力先の設定
      reportsDirectory: './coverage',

      // HTML レポートの詳細設定
      reportOnFailure: true, // テスト失敗時もレポート生成

      // より詳細な情報を含める
      all: true, // テストされていないファイルも含める
      skipFull: false, // 100%カバレッジのファイルも表示

      // パフォーマンスの最適化
      clean: true, // 実行前に以前のレポートを削除
      cleanOnRerun: true, // 再実行時にクリーンアップ
    },
  },
});

HTML レポートを見やすくするためのカスタマイゼーション例です。

typescript// scripts/coverage-report.ts - レポートカスタマイズ
import fs from 'fs';
import path from 'path';

interface CoverageCustomizer {
  addCustomStyles(): void;
  generateSummaryPage(): void;
  addNavigationEnhancement(): void;
}

class CoverageReportCustomizer
  implements CoverageCustomizer
{
  private readonly reportDir = './coverage';

  addCustomStyles(): void {
    const customCSS = `
      .coverage-summary {
        margin-bottom: 20px;
        padding: 15px;
        background: #f8f9fa;
        border-radius: 5px;
      }
      
      .low-coverage {
        background-color: #ffebee !important;
      }
      
      .high-coverage {
        background-color: #e8f5e8 !important;
      }
    `;

    fs.writeFileSync(
      path.join(this.reportDir, 'custom.css'),
      customCSS
    );
  }

  generateSummaryPage(): void {
    // カスタムサマリーページの生成
    const summaryHTML = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>カバレッジサマリー</title>
        <link rel="stylesheet" href="./base.css">
        <link rel="stylesheet" href="./custom.css">
      </head>
      <body>
        <h1>プロジェクトカバレッジサマリー</h1>
        <!-- カスタムサマリー内容 -->
      </body>
      </html>
    `;

    fs.writeFileSync(
      path.join(this.reportDir, 'summary.html'),
      summaryHTML
    );
  }

  addNavigationEnhancement(): void {
    // ナビゲーション強化のスクリプト追加
    const enhanceScript = `
      document.addEventListener('DOMContentLoaded', function() {
        // カバレッジ率による色分け
        const cells = document.querySelectorAll('.coverage-cell');
        cells.forEach(cell => {
          const coverage = parseFloat(cell.textContent);
          if (coverage < 50) {
            cell.classList.add('low-coverage');
          } else if (coverage > 80) {
            cell.classList.add('high-coverage');
          }
        });
      });
    `;

    fs.writeFileSync(
      path.join(this.reportDir, 'enhance.js'),
      enhanceScript
    );
  }
}

JSON 形式でのデータ活用

JSON 形式のレポートを活用することで、カバレッジデータの自動分析や他システムとの連携が可能になります。

typescript// scripts/coverage-analyzer.ts - JSON レポート解析
import fs from 'fs';
import type { CoverageReport, FileCoverage } from './types';

class CoverageAnalyzer {
  private reportPath = './coverage/coverage-final.json';

  async analyzeCoverage(): Promise<CoverageAnalysis> {
    const reportData: CoverageReport = JSON.parse(
      fs.readFileSync(this.reportPath, 'utf8')
    );

    return {
      overall: this.calculateOverallCoverage(reportData),
      fileBreakdown: this.analyzeFileBreakdown(reportData),
      trends: await this.calculateTrends(reportData),
      recommendations:
        this.generateRecommendations(reportData),
    };
  }

  private calculateOverallCoverage(report: CoverageReport) {
    const totals = Object.values(report).reduce(
      (acc, file) => ({
        lines: acc.lines + file.lines.covered,
        lineTotal: acc.lineTotal + file.lines.total,
        functions: acc.functions + file.functions.covered,
        functionTotal:
          acc.functionTotal + file.functions.total,
      }),
      {
        lines: 0,
        lineTotal: 0,
        functions: 0,
        functionTotal: 0,
      }
    );

    return {
      lines: (totals.lines / totals.lineTotal) * 100,
      functions:
        (totals.functions / totals.functionTotal) * 100,
    };
  }

  private analyzeFileBreakdown(report: CoverageReport) {
    return Object.entries(report)
      .map(([filepath, coverage]) => ({
        file: filepath,
        coverage:
          (coverage.lines.covered / coverage.lines.total) *
          100,
        uncoveredLines:
          coverage.lines.total - coverage.lines.covered,
      }))
      .sort((a, b) => a.coverage - b.coverage); // 低カバレッジ順
  }
}

interface CoverageAnalysis {
  overall: {
    lines: number;
    functions: number;
  };
  fileBreakdown: Array<{
    file: string;
    coverage: number;
    uncoveredLines: number;
  }>;
  trends: CoverageTrend[];
  recommendations: string[];
}

データの可視化例も示します。

typescript// scripts/coverage-visualizer.ts - データ可視化
import { ChartConfiguration } from 'chart.js';

class CoverageVisualizer {
  generateCoverageChart(
    analysis: CoverageAnalysis
  ): ChartConfiguration {
    const lowCoverageFiles = analysis.fileBreakdown
      .filter((file) => file.coverage < 70)
      .slice(0, 10); // 上位10ファイル

    return {
      type: 'bar',
      data: {
        labels: lowCoverageFiles.map(
          (f) => f.file.split('/').pop() // ファイル名のみ表示
        ),
        datasets: [
          {
            label: 'カバレッジ率 (%)',
            data: lowCoverageFiles.map((f) => f.coverage),
            backgroundColor: lowCoverageFiles.map((f) =>
              f.coverage < 50 ? '#ff4444' : '#ffaa44'
            ),
          },
        ],
      },
      options: {
        responsive: true,
        plugins: {
          title: {
            display: true,
            text: 'カバレッジが低いファイル Top 10',
          },
        },
        scales: {
          y: {
            beginAtZero: true,
            max: 100,
            ticks: {
              callback: (value) => value + '%',
            },
          },
        },
      },
    };
  }
}

CI 環境での可視化

CI 環境でのカバレッジ可視化は、チーム全体でのカバレッジ状況共有に重要な役割を果たします。

yaml# .github/workflows/coverage.yml - GitHub Actions での可視化
name: Coverage Report

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  coverage:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run tests with coverage
        run: yarn test --coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          directory: ./coverage
          files: ./coverage/lcov.info
          flags: unittests
          name: codecov-umbrella
          fail_ci_if_error: true
          verbose: true

      - name: Coverage Report as Comment
        uses: romeovs/lcov-reporter-action@v0.3.1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          lcov-file: ./coverage/lcov.info

      - name: Archive coverage artifacts
        uses: actions/upload-artifact@v3
        with:
          name: coverage-report
          path: coverage/
          retention-days: 30

Slack 通知の実装例です。

typescript// scripts/slack-coverage-notifier.ts - Slack 通知
interface SlackNotificationPayload {
  channel: string;
  blocks: SlackBlock[];
}

class SlackCoverageNotifier {
  private webhookUrl = process.env.SLACK_WEBHOOK_URL!;

  async notifyCoverageResults(
    analysis: CoverageAnalysis
  ): Promise<void> {
    const payload: SlackNotificationPayload = {
      channel: '#development',
      blocks: [
        {
          type: 'header',
          text: {
            type: 'plain_text',
            text: '📊 カバレッジレポート',
          },
        },
        {
          type: 'section',
          fields: [
            {
              type: 'mrkdwn',
              text: `*行カバレッジ:* ${analysis.overall.lines.toFixed(
                1
              )}%`,
            },
            {
              type: 'mrkdwn',
              text: `*関数カバレッジ:* ${analysis.overall.functions.toFixed(
                1
              )}%`,
            },
          ],
        },
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: this.generateRecommendationText(
              analysis.recommendations
            ),
          },
        },
      ],
    };

    await fetch(this.webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
  }

  private generateRecommendationText(
    recommendations: string[]
  ): string {
    if (recommendations.length === 0) {
      return '✅ すべてのカバレッジ目標を達成しています!';
    }

    return (
      '*改善提案:*\n' +
      recommendations.map((rec) => `• ${rec}`).join('\n')
    );
  }
}

具体例

実際のプロジェクト設定

Next.js + Vitest での実装例

実際の Next.js プロジェクトで Vitest カバレッジを導入する完全な例をご紹介します。まずプロジェクトの構造から確認しましょう。

textmy-nextjs-app/
├── src/
│   ├── components/
│   │   ├── Button/
│   │   │   ├── Button.tsx
│   │   │   └── Button.test.tsx
│   │   └── Header/
│   │       ├── Header.tsx
│   │       └── Header.test.tsx
│   ├── pages/
│   │   ├── api/
│   │   │   └── users.ts
│   │   └── index.tsx
│   ├── utils/
│   │   ├── formatDate.ts
│   │   └── api.ts
│   └── types/
│       └── user.ts
├── tests/
│   ├── setup.ts
│   └── utils/
│       └── test-helpers.ts
├── vitest.config.ts
└── package.json

まず、必要な依存関係をインストールします。

json{
  "devDependencies": {
    "@vitejs/plugin-react": "^4.0.0",
    "vitest": "^0.34.0",
    "@vitest/coverage-v8": "^0.34.0",
    "jsdom": "^22.0.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/jest-dom": "^6.0.0"
  },
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest watch --coverage"
  }
}

Next.js 特有の設定を含む vitest.config.ts です。

typescript// vitest.config.ts - Next.js 最適化設定
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    globals: true,

    // Next.js パス解決の設定
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@/components': path.resolve(
        __dirname,
        './src/components'
      ),
      '@/utils': path.resolve(__dirname, './src/utils'),
      '@/types': path.resolve(__dirname, './src/types'),
    },
  },

  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },

  // カバレッジ設定
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      reportsDirectory: './coverage',

      // Next.js プロジェクト向け閾値
      thresholds: {
        lines: 75,
        functions: 70,
        branches: 65,
        statements: 75,

        // コンポーネントは高い閾値
        './src/components/**': {
          lines: 85,
          functions: 80,
          branches: 75,
          statements: 85,
        },

        // API ルートも重要
        './src/pages/api/**': {
          lines: 80,
          functions: 75,
          branches: 70,
          statements: 80,
        },
      },

      // Next.js 特有の除外設定
      exclude: [
        'coverage/**',
        'dist/**',
        '.next/**',
        'out/**',

        // テストファイル
        '**/*.{test,spec}.{js,ts,tsx}',
        '**/tests/**',
        '**/__tests__/**',

        // Next.js 設定
        'next.config.js',
        'next.config.mjs',
        'next-env.d.ts',

        // 設定・型定義
        '**/*.d.ts',
        '**/types/**',

        // ページファイル(ルーティング専用)
        'src/pages/_app.tsx',
        'src/pages/_document.tsx',
        'src/pages/_error.tsx',
        'src/pages/404.tsx',
        'src/pages/500.tsx',

        // 静的ファイル
        'public/**',
      ],
    },
  },
});

テストセットアップファイルです。

typescript// tests/setup.ts - テスト環境セットアップ
import '@testing-library/jest-dom';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { cleanup } from '@testing-library/react';

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

// Next.js の環境変数設定
beforeAll(() => {
  process.env.NODE_ENV = 'test';
  process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3000';
});

// モック設定
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: () => {},
    removeListener: () => {},
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => {},
  }),
});

// Next.js router のモック
const mockRouter = {
  push: vi.fn(),
  replace: vi.fn(),
  prefetch: vi.fn(),
  back: vi.fn(),
  forward: vi.fn(),
  reload: vi.fn(),
  pathname: '/',
  route: '/',
  asPath: '/',
  query: {},
  isReady: true,
  basePath: '',
  isFallback: false,
};

vi.mock('next/router', () => ({
  useRouter: () => mockRouter,
}));

実際のコンポーネントテスト例です。

typescript// src/components/Button/Button.test.tsx - コンポーネントテスト
import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';

describe('Button Component', () => {
  it('正常にレンダリングされること', () => {
    render(<Button>Click me</Button>);

    const button = screen.getByRole('button', {
      name: /click me/i,
    });
    expect(button).toBeInTheDocument();
  });

  it('クリックイベントが正常に動作すること', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    const button = screen.getByRole('button');
    fireEvent.click(button);

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('disabled状態が正常に動作すること', () => {
    const handleClick = vi.fn();
    render(
      <Button disabled onClick={handleClick}>
        Disabled
      </Button>
    );

    const button = screen.getByRole('button');
    expect(button).toBeDisabled();

    fireEvent.click(button);
    expect(handleClick).not.toHaveBeenCalled();
  });

  it('異なるvariantが正常に適用されること', () => {
    const { rerender } = render(
      <Button variant='primary'>Primary</Button>
    );
    expect(screen.getByRole('button')).toHaveClass(
      'btn-primary'
    );

    rerender(
      <Button variant='secondary'>Secondary</Button>
    );
    expect(screen.getByRole('button')).toHaveClass(
      'btn-secondary'
    );
  });
});

段階的導入手順

実際のプロジェクトでカバレッジを段階的に導入する手順をご紹介します。

Week 1: 環境構築とベースライン測定

最初の週は基本的な設定とベースライン測定に集中します。

bash# Step 1: 必要パッケージのインストール
yarn add -D vitest @vitest/coverage-v8 @vitejs/plugin-react jsdom

# Step 2: 基本設定ファイルの作成
# vitest.config.ts を作成(最小限の設定)

# Step 3: 初回カバレッジ測定
yarn test --coverage

# Step 4: 現状把握レポートの作成
yarn coverage:analyze  # カスタムスクリプト

この段階での設定例です。

typescript// vitest.config.ts - Week 1 設定(ベースライン測定用)
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],

      // 最初は除外のみ設定(閾値なし)
      exclude: [
        '**/*.{test,spec}.{js,ts,tsx}',
        '**/node_modules/**',
        '**/dist/**',
      ],

      // すべてのファイルをレポートに含める
      all: true,

      // 詳細レポートを生成
      reportOnFailure: true,
    },
  },
});

Week 2-3: テスト追加と閾値設定

既存コードにテストを追加し、現実的な閾値を設定します。

typescript// scripts/coverage-improvement.ts - 改善計画作成
import fs from 'fs';

class CoverageImprovement {
  async createImprovementPlan(): Promise<void> {
    const coverageData = this.loadCoverageData();
    const prioritizedFiles =
      this.prioritizeFiles(coverageData);

    const plan = {
      week2Goals: {
        targetFiles: prioritizedFiles.slice(0, 10),
        expectedIncrease: '15%',
      },
      week3Goals: {
        targetFiles: prioritizedFiles.slice(10, 25),
        expectedIncrease: '25%',
      },
    };

    fs.writeFileSync(
      './coverage-plan.json',
      JSON.stringify(plan, null, 2)
    );
    console.log(
      '📋 改善計画を作成しました: coverage-plan.json'
    );
  }

  private prioritizeFiles(coverageData: any): string[] {
    // ビジネスロジックの重要度 × 現在のカバレッジの低さ で優先度決定
    return Object.entries(coverageData)
      .filter(([path]) => path.includes('src/'))
      .sort(([, a], [, b]) => {
        const priorityA = this.calculatePriority(a);
        const priorityB = this.calculatePriority(b);
        return priorityB - priorityA;
      })
      .map(([path]) => path);
  }

  private calculatePriority(coverage: any): number {
    const businessWeight = this.getBusinessWeight(
      coverage.path
    );
    const coverageGap = 100 - coverage.lines.pct;
    return businessWeight * coverageGap;
  }
}

Week 4: 閾値設定と自動化

最終週では閾値を設定し、CI/CD に統合します。

typescript// vitest.config.ts - Week 4 設定(本格運用版)
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',

      // 段階的閾値設定
      thresholds: {
        lines: 60, // 現実的なスタート地点
        functions: 55,
        branches: 50,
        statements: 60,
      },

      // 重要ディレクトリの個別設定
      './src/utils/**': {
        lines: 80, // ユーティリティは高めに
        functions: 75,
        branches: 70,
        statements: 80,
      },
    },
  },
});

トラブルシューティング

実際の導入で遭遇しやすい問題と対処法をまとめました。

問題 1: Next.js の動的インポートでエラーが発生

typescript// 問題のあるコード
const DynamicComponent = dynamic(
  () => import('./HeavyComponent')
);

// 解決策: テスト環境での動的インポートモック
// tests/setup.ts に追加
vi.mock('next/dynamic', () => ({
  __esModule: true,
  default: (fn: () => Promise<any>) => {
    const Component = (props: any) => {
      const [C, setC] = useState(null);

      useEffect(() => {
        fn().then((mod) => setC(() => mod.default));
      }, []);

      return C ? <C {...props} /> : <div>Loading...</div>;
    };

    Component.displayName = 'DynamicComponent';
    return Component;
  },
}));

問題 2: styled-components のスナップショットテストで差分

typescript// styled-components のテーマプロバイダーをモック
// tests/setup.ts に追加
vi.mock('styled-components', async () => {
  const actual = await vi.importActual('styled-components');
  return {
    ...actual,
    ThemeProvider: ({
      children,
    }: {
      children: React.ReactNode;
    }) => children,
  };
});

問題 3: API ルートのテストが困難

typescript// src/pages/api/users.test.ts - API ルートテスト例
import { createMocks } from 'node-mocks-http';
import handler from './users';

describe('/api/users', () => {
  it('GET リクエストで正常にユーザーリストを返す', async () => {
    const { req, res } = createMocks({
      method: 'GET',
    });

    await handler(req, res);

    expect(res._getStatusCode()).toBe(200);
    const data = JSON.parse(res._getData());
    expect(Array.isArray(data.users)).toBe(true);
  });
});

まとめ

Vitest カバレッジの 3 つの核心技術(閾値設定・除外ルール・レポート可視化)を習得することで、プロジェクトの品質管理を大幅に向上させることができます。

特に重要なポイントは以下の通りです。

閾値設定について

  • プロジェクトの規模と特性に応じた現実的な目標値の設定
  • 段階的な導入による無理のない品質向上
  • ディレクトリ別の柔軟な閾値管理

除外ルールについて

  • テストファイルや設定ファイルの適切な除外
  • 保守性を考慮した設定ファイルの構造化
  • プロジェクト特性に応じたカスタマイズ

レポート可視化について

  • HTML レポートと JSON データの効果的な活用
  • CI/CD 環境での自動化と継続的な監視
  • チーム全体での情報共有とコミュニケーション促進

これらの技術を適切に組み合わせることで、単なる数値管理を超えた、真の品質向上につながるカバレッジ運用が実現できます。継続的な改善と定期的な見直しを心がけ、チーム全体でより良いソフトウェア開発を目指していきましょう。

関連リンク