Documentation Next.js

はじめに

Next.jsプロジェクトの品質を担保するうえで、テストカバレッジは重要な指標です。テストカバレッジとは、テストによってコードのどの部分が実行されたかを示す数値であり、高いカバレッジを維持することでバグの早期発見やリグレッション(既存機能の意図しない破壊)の防止につながります。

この記事では、Next.jsプロジェクトにおけるテストカバレッジ向上のテクニックを、以下の観点から解説します。

  • Jestを使った単体テストのカバレッジ計測
  • Playwrightを使ったE2Eテストのカバレッジ取得
  • Storybookを活用したUIコンポーネントのテスト

テストカバレッジの基礎知識

カバレッジの種類

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

カバレッジの種類説明計測対象
ステートメントカバレッジコード内の各文が実行されたかを計測個々の文
ブランチカバレッジif/else などの分岐が両方テストされたかを計測条件分岐
関数カバレッジ各関数が呼び出されたかを計測関数定義
行カバレッジ各行が実行されたかを計測コードの行

理想的なカバレッジ目標

一般的に推奨されるカバレッジ目標は以下の通りです。

  • 70%以上: 最低限の品質を保つライン
  • 80%以上: 多くのプロジェクトで推奨される目標
  • 90%以上: 高品質なプロダクションコードの目標

ただし、100%を目指すことが必ずしも正しいわけではありません。テストの質(何をテストしているか)も重要な要素です。

Jestでのカバレッジ計測

必要なパッケージのインストール

まず、Next.jsプロジェクトにJestをセットアップします。

# 必要なパッケージをインストール
npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom

基本的なカバレッジ計測

Jestでカバレッジを取得するには、--coverageフラグを使用します。

# カバレッジレポートを生成
jest --coverage

このコマンドを実行すると、ターミナルにカバレッジのサマリーが表示され、coverageディレクトリにHTMLレポートが生成されます。

Jest設定ファイルの作成

プロジェクトルートにjest.config.jsを作成し、カバレッジの詳細設定を行います。

// jest.config.js
const nextJest = require('next/jest');

// Next.jsの設定を読み込むためのcreateJestConfig関数を作成
const createJestConfig = nextJest({
  // Next.jsアプリのルートディレクトリを指定
  dir: './',
});

const customJestConfig = {
  // テスト環境をjsdomに設定(ブラウザ環境のシミュレーション)
  testEnvironment: 'jest-environment-jsdom',

  // セットアップファイルの指定
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],

  // カバレッジ計測を有効化
  collectCoverage: true,

  // カバレッジ計測対象のファイルパターン
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',    // src配下のすべてのJavaScript/TypeScriptファイル
    '!src/**/*.d.ts',              // 型定義ファイルは除外
    '!src/**/*.stories.{js,jsx,ts,tsx}', // Storybookのストーリーファイルは除外
    '!src/**/index.{js,ts}',       // バレルファイルは除外(オプション)
  ],

  // カバレッジレポートの出力先
  coverageDirectory: 'coverage',

  // レポート形式の指定
  coverageReporters: [
    'text',           // ターミナルにテキスト形式で出力
    'lcov',           // HTMLレポート生成用(coverage/lcov-report/index.html)
    'json-summary',   // CI/CDツール連携用のJSON形式
  ],

  // モジュールパスのエイリアス設定
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

module.exports = createJestConfig(customJestConfig);

カバレッジ閾値の設定

プロジェクトの品質基準を維持するため、カバレッジの最低基準値を設定します。基準を下回るとテストが失敗するため、CI/CDパイプラインでの品質管理に役立ちます。

// jest.config.js(追加設定)
const customJestConfig = {
  // ... 前述の設定 ...

  // カバレッジの閾値設定
  coverageThreshold: {
    // グローバル設定(プロジェクト全体に適用)
    global: {
      statements: 80,   // ステートメントカバレッジ 80%以上必須
      branches: 70,     // ブランチカバレッジ 70%以上必須
      functions: 85,    // 関数カバレッジ 85%以上必須
      lines: 80,        // 行カバレッジ 80%以上必須
    },
    // 特定ディレクトリへの個別設定
    './src/utils/': {
      statements: 90,   // ユーティリティ関数は90%以上必須
      branches: 85,
      functions: 90,
      lines: 90,
    },
    // 重要なファイルへの個別設定
    './src/lib/api.ts': {
      statements: 95,   // APIクライアントは95%以上必須
    },
  },
};

実践的なテスト例

カバレッジを向上させるための具体的なテスト例を見てみましょう。

// src/utils/validator.ts - テスト対象のコード
export function validateEmail(email: string): boolean {
  // メールアドレスの形式を検証
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

export function validatePassword(password: string): {
  isValid: boolean;
  errors: string[];
} {
  const errors: string[] = [];

  // 最小文字数チェック
  if (password.length < 8) {
    errors.push('パスワードは8文字以上である必要があります');
  }

  // 大文字を含むかチェック
  if (!/[A-Z]/.test(password)) {
    errors.push('パスワードは大文字を含む必要があります');
  }

  // 数字を含むかチェック
  if (!/[0-9]/.test(password)) {
    errors.push('パスワードは数字を含む必要があります');
  }

  return {
    isValid: errors.length === 0,
    errors,
  };
}
// src/utils/validator.test.ts - テストコード
import { validateEmail, validatePassword } from './validator';

describe('validateEmail', () => {
  // 正常系のテスト
  it('有効なメールアドレスの場合、trueを返す', () => {
    expect(validateEmail('test@example.com')).toBe(true);
    expect(validateEmail('user.name@domain.co.jp')).toBe(true);
  });

  // 異常系のテスト(ブランチカバレッジ向上)
  it('無効なメールアドレスの場合、falseを返す', () => {
    expect(validateEmail('')).toBe(false);              // 空文字
    expect(validateEmail('invalid')).toBe(false);       // @なし
    expect(validateEmail('test@')).toBe(false);         // ドメインなし
    expect(validateEmail('@example.com')).toBe(false);  // ユーザー名なし
  });
});

describe('validatePassword', () => {
  // すべての条件を満たす場合
  it('有効なパスワードの場合、isValidがtrueになる', () => {
    const result = validatePassword('Password123');
    expect(result.isValid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });

  // 各条件を個別にテスト(ブランチカバレッジ向上)
  it('8文字未満の場合、エラーメッセージが含まれる', () => {
    const result = validatePassword('Pass1');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('パスワードは8文字以上である必要があります');
  });

  it('大文字がない場合、エラーメッセージが含まれる', () => {
    const result = validatePassword('password123');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('パスワードは大文字を含む必要があります');
  });

  it('数字がない場合、エラーメッセージが含まれる', () => {
    const result = validatePassword('PasswordABC');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('パスワードは数字を含む必要があります');
  });

  // 複数のエラーが同時に発生する場合
  it('複数の条件を満たさない場合、すべてのエラーが含まれる', () => {
    const result = validatePassword('abc');
    expect(result.isValid).toBe(false);
    expect(result.errors).toHaveLength(3);
  });
});

Playwrightでのカバレッジ計測

E2Eテストでもカバレッジを計測することで、実際のユーザーフローでどの程度のコードが実行されているかを把握できます。

必要なパッケージのインストール

# Playwrightとカバレッジ関連パッケージをインストール
npm install --save-dev @playwright/test playwright-test-coverage babel-plugin-istanbul nyc

Next.js設定ファイルの修正

カバレッジモードでNext.jsを動作させるため、next.config.jsを修正します。

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = () => {
  // COVERAGE環境変数が設定されている場合のみカバレッジを有効化
  if (process.env.COVERAGE === 'true') {
    return {
      webpack: (config, { isServer }) => {
        // クライアントサイドのみカバレッジを計測
        if (!isServer) {
          config.module.rules.push({
            test: /\.(js|jsx|ts|tsx)$/,
            // node_modulesと.next以外のファイルを対象
            exclude: /node_modules|\.next/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['next/babel'],
                plugins: [
                  // Istanbulプラグインでコードをインストルメント
                  ['istanbul', {
                    // カバレッジ計測対象のファイルパターン
                    include: ['src/**/*.{js,jsx,ts,tsx}'],
                    // 除外するファイルパターン
                    exclude: [
                      'src/**/*.test.{js,jsx,ts,tsx}',
                      'src/**/*.stories.{js,jsx,ts,tsx}',
                    ],
                  }],
                ],
              },
            },
          });
        }
        return config;
      },
    };
  }

  // 通常モードの設定
  return {
    reactStrictMode: true,
  };
};

module.exports = nextConfig;

Playwright設定ファイルの作成

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // テストディレクトリの指定
  testDir: './e2e',

  // テストのタイムアウト設定
  timeout: 30000,

  // グローバルセットアップ(カバレッジ初期化など)
  globalSetup: './e2e/global-setup.ts',
  globalTeardown: './e2e/global-teardown.ts',

  // テストの並列実行設定
  fullyParallel: true,

  // CI環境ではリトライを有効化
  retries: process.env.CI ? 2 : 0,

  // レポーター設定
  reporter: [
    ['html', { open: 'never' }],
    ['json', { outputFile: 'playwright-report/results.json' }],
  ],

  // 共通設定
  use: {
    // ベースURL
    baseURL: 'http://localhost:3000',
    // スクリーンショットの設定
    screenshot: 'only-on-failure',
    // トレースの設定
    trace: 'on-first-retry',
  },

  // ブラウザ設定
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],

  // 開発サーバーの設定
  webServer: {
    // カバレッジモードでNext.jsを起動
    command: 'COVERAGE=true npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
});

E2Eテストでのカバレッジ収集

// e2e/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  // カバレッジデータを初期化
  console.log('Starting coverage collection...');
}

export default globalSetup;
// e2e/global-teardown.ts
import fs from 'fs';
import path from 'path';

async function globalTeardown() {
  // カバレッジデータを保存
  const coverageDir = path.join(process.cwd(), 'coverage-e2e');
  if (!fs.existsSync(coverageDir)) {
    fs.mkdirSync(coverageDir, { recursive: true });
  }
  console.log('Coverage data saved to coverage-e2e/');
}

export default globalTeardown;
// e2e/auth.spec.ts - E2Eテストの例
import { test, expect } from '@playwright/test';

test.describe('認証フロー', () => {
  test('ログインページが正しく表示される', async ({ page }) => {
    // ログインページに遷移
    await page.goto('/login');

    // ページタイトルを検証
    await expect(page).toHaveTitle(/ログイン/);

    // フォーム要素の存在を確認
    await expect(page.getByLabel('メールアドレス')).toBeVisible();
    await expect(page.getByLabel('パスワード')).toBeVisible();
    await expect(page.getByRole('button', { name: 'ログイン' })).toBeVisible();
  });

  test('有効な認証情報でログインできる', async ({ page }) => {
    await page.goto('/login');

    // フォームに入力
    await page.getByLabel('メールアドレス').fill('test@example.com');
    await page.getByLabel('パスワード').fill('Password123');

    // ログインボタンをクリック
    await page.getByRole('button', { name: 'ログイン' }).click();

    // ダッシュボードにリダイレクトされることを確認
    await expect(page).toHaveURL('/dashboard');

    // ウェルカムメッセージが表示されることを確認
    await expect(page.getByText('ようこそ')).toBeVisible();
  });

  test('無効な認証情報ではエラーが表示される', async ({ page }) => {
    await page.goto('/login');

    // 無効な認証情報を入力
    await page.getByLabel('メールアドレス').fill('invalid@example.com');
    await page.getByLabel('パスワード').fill('wrongpassword');

    // ログインボタンをクリック
    await page.getByRole('button', { name: 'ログイン' }).click();

    // エラーメッセージが表示されることを確認
    await expect(page.getByRole('alert')).toContainText('認証に失敗しました');

    // ログインページにとどまることを確認
    await expect(page).toHaveURL('/login');
  });
});

Storybookを活用したUIテスト

StorybookとStoryshotsを組み合わせることで、UIコンポーネントのビジュアルテストと同時にカバレッジを計測できます。

Storybookのセットアップ

# Storybookをインストール
npx storybook@latest init

# インタラクションテスト用のアドオンをインストール
npm install --save-dev @storybook/addon-interactions @storybook/testing-library

Play関数を使ったインタラクションテスト

// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof Button>;

// 基本的なストーリー
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'ボタン',
  },
};

// Play関数を使ったインタラクションテスト
export const WithClickHandler: Story = {
  args: {
    variant: 'primary',
    children: 'クリックしてください',
  },
  play: async ({ canvasElement, args }) => {
    // Canvas内の要素を取得
    const canvas = within(canvasElement);

    // ボタンを取得
    const button = canvas.getByRole('button');

    // ボタンが表示されていることを確認
    await expect(button).toBeVisible();

    // ボタンをクリック
    await userEvent.click(button);

    // クリックイベントが発火したことを確認(モックが設定されている場合)
    // await expect(args.onClick).toHaveBeenCalled();
  },
};

// ローディング状態のテスト
export const Loading: Story = {
  args: {
    variant: 'primary',
    children: '送信中...',
    isLoading: true,
    disabled: true,
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');

    // ボタンが無効化されていることを確認
    await expect(button).toBeDisabled();

    // ローディングスピナーが表示されていることを確認
    const spinner = canvas.getByTestId('loading-spinner');
    await expect(spinner).toBeVisible();
  },
};

StorybookでのカバレッジとCIの連携

// .storybook/main.js
/** @type { import('@storybook/nextjs').StorybookConfig } */
const config = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-coverage', // カバレッジアドオン
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
};

export default config;

カバレッジレポートの活用

レポートの読み方

Jestが生成するカバレッジレポートは、coverage/lcov-report/index.htmlをブラウザで開くことで確認できます。

レポートには以下の情報が含まれます。

  • ファイル一覧: 各ファイルのカバレッジ率
  • 行ごとの実行状況: 緑(実行済み)、赤(未実行)、黄(部分的に実行)
  • 分岐の実行状況: if/elseなどの条件分岐の網羅状況

CI/CDでのカバレッジ監視

GitHub Actionsを使ってカバレッジを自動チェックする例を紹介します。

# .github/workflows/test.yml
name: Test and Coverage

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

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 tests with coverage
        run: npm test -- --coverage --coverageReporters="json-summary"

      - name: Check coverage threshold
        run: |
          # カバレッジが基準を満たしているか確認
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          echo "Line coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage is below 80%"
            exit 1
          fi

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: true

カバレッジ向上のベストプラクティス

1. テスタブルなコードを書く

カバレッジを向上させるには、まずテストしやすいコードを書くことが重要です。

// Bad: テストしにくいコード
function processData() {
  const data = fetch('/api/data'); // 外部依存が直接埋め込まれている
  return data;
}

// Good: テストしやすいコード(依存性注入)
function processData(fetcher: () => Promise<Data>) {
  return fetcher();
}

2. エッジケースを意識する

カバレッジ向上には、境界値や例外ケースのテストが効果的です。

  • 空の配列や文字列
  • null/undefined
  • 最大値/最小値
  • エラーケース

3. 段階的に改善する

一度にすべてのカバレッジを上げようとせず、段階的に改善しましょう。

  1. まず現状のカバレッジを計測
  2. 重要なビジネスロジックから優先的にテスト
  3. 週ごとや月ごとに目標を設定

まとめ

Next.jsプロジェクトでテストカバレッジを向上させるためには、以下のポイントが重要です。

  1. Jestの活用: 単体テストのカバレッジ計測と閾値設定
  2. Playwrightの活用: E2Eテストでの実際のユーザーフローのカバレッジ計測
  3. Storybookの活用: UIコンポーネントのインタラクションテスト
  4. CI/CDとの連携: 自動化されたカバレッジ監視

テストカバレッジは品質を測る重要な指標ですが、数値だけを追い求めるのではなく、テストの質と合わせて考えることが大切です。適切なテスト戦略を立て、継続的にカバレッジを改善していくことで、信頼性の高いNext.jsアプリケーションを構築できます。

参考文献

円