Documentation Next.js

はじめに

Next.jsアプリケーションの品質を保つには、自動テストが欠かせません。本記事では、Jest(JavaScriptのテストフレームワーク)とReact Testing Library(Reactコンポーネントをユーザー視点でテストするライブラリ)を使用したテスト環境の構築方法を解説します。これらのツールを組み合わせることで、コンポーネントの単体テストやUI動作の検証を効率的に行えます。

Jest + React Testing Libraryの導入

まず、JestとReact Testing Libraryのセットアップを行います。

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

以下のコマンドで必要なパッケージをインストールします。

npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

TypeScriptを使用している場合は、追加で次のパッケージもインストールします。

npm install --save-dev ts-jest @types/jest

Jestの設定(Next.js 14+推奨)

Next.js 14以降では、next/jestを使用した設定が推奨されています。

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

const createJestConfig = nextJest({
  // next.config.jsとテスト環境用の.envファイルの読み込み先
  dir: './',
});

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    // パスエイリアスの設定
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
  ],
};

module.exports = createJestConfig(customJestConfig);

セットアップファイルの作成

jest.setup.jsファイルを作成して、テスト用のグローバル設定を追加します。

// jest.setup.js
import '@testing-library/jest-dom';

// fetch APIのモック(必要に応じて)
global.fetch = jest.fn();

// ResizeObserverのモック(一部コンポーネントで必要)
global.ResizeObserver = jest.fn().mockImplementation(() => ({
  observe: jest.fn(),
  unobserve: jest.fn(),
  disconnect: jest.fn(),
}));

package.jsonの編集

テストを実行できるように、package.jsonscriptsに以下を追加します。

"scripts": {
  "test": "jest",
  "test:watch": "jest --watch",
  "test:coverage": "jest --coverage"
}

基本的なテストの書き方

コンポーネントのテスト

__tests__ディレクトリまたはコンポーネントと同階層に.test.tsxファイルを作成します。

// __tests__/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from '@/components/Button';

describe('Button', () => {
  it('ボタンが正しくレンダリングされる', () => {
    render(<Button>クリック</Button>);

    const button = screen.getByRole('button', { name: /クリック/i });
    expect(button).toBeInTheDocument();
  });

  it('クリックでonClickが呼ばれる', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>送信</Button>);

    await user.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('disabled状態ではクリックできない', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(
      <Button onClick={handleClick} disabled>
        送信
      </Button>
    );

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

    await user.click(button);
    expect(handleClick).not.toHaveBeenCalled();
  });
});

クエリの優先順位

React Testing Libraryでは、以下の優先順位でクエリを使用することが推奨されています。

優先度クエリ用途
1getByRoleアクセシビリティロールで取得
2getByLabelTextフォーム要素のラベルで取得
3getByPlaceholderTextプレースホルダーで取得
4getByTextテキスト内容で取得
5getByTestIddata-testid属性で取得(最終手段)
// 良い例:roleを使用
const button = screen.getByRole('button', { name: /送信/i });
const input = screen.getByRole('textbox', { name: /メールアドレス/i });

// 避けるべき例:testIdを安易に使用
const button = screen.getByTestId('submit-button'); // 最終手段として

フォームのテスト

ユーザー入力を含むフォームのテストにはuserEventを使用します。

// __tests__/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from '@/components/LoginForm';

describe('LoginForm', () => {
  it('フォーム送信が正しく動作する', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    // 入力フィールドに値を入力
    await user.type(
      screen.getByRole('textbox', { name: /メールアドレス/i }),
      'test@example.com'
    );
    await user.type(
      screen.getByLabelText(/パスワード/i),
      'password123'
    );

    // フォームを送信
    await user.click(screen.getByRole('button', { name: /ログイン/i }));

    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });

  it('バリデーションエラーが表示される', async () => {
    const user = userEvent.setup();

    render(<LoginForm onSubmit={jest.fn()} />);

    // 空のままフォームを送信
    await user.click(screen.getByRole('button', { name: /ログイン/i }));

    // エラーメッセージが表示されることを確認
    expect(await screen.findByText(/メールアドレスは必須です/i)).toBeInTheDocument();
  });
});

非同期コンポーネントのテスト

App Routerの非同期コンポーネントをテストする場合は、async/awaitfindByクエリを使用します。

// __tests__/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import UserProfile from '@/components/UserProfile';

// APIのモック
jest.mock('@/lib/api', () => ({
  fetchUser: jest.fn(),
}));

import { fetchUser } from '@/lib/api';

describe('UserProfile', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('ユーザー情報を正しく表示する', async () => {
    (fetchUser as jest.Mock).mockResolvedValue({
      name: '山田太郎',
      email: 'yamada@example.com',
    });

    render(<UserProfile userId="1" />);

    // ローディング状態を確認
    expect(screen.getByText(/読み込み中/i)).toBeInTheDocument();

    // データ表示を待つ
    expect(await screen.findByText('山田太郎')).toBeInTheDocument();
    expect(screen.getByText('yamada@example.com')).toBeInTheDocument();
  });

  it('エラー時にエラーメッセージを表示する', async () => {
    (fetchUser as jest.Mock).mockRejectedValue(new Error('取得に失敗しました'));

    render(<UserProfile userId="1" />);

    expect(await screen.findByText(/エラーが発生しました/i)).toBeInTheDocument();
  });
});

モックパターン

next/navigationのモック

App RouterのuseRouterusePathnameをモックする方法です。

// __tests__/Navigation.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Navigation from '@/components/Navigation';

// next/navigationのモック
const mockPush = jest.fn();
const mockPathname = '/home';

jest.mock('next/navigation', () => ({
  useRouter: () => ({
    push: mockPush,
    replace: jest.fn(),
    back: jest.fn(),
  }),
  usePathname: () => mockPathname,
  useSearchParams: () => new URLSearchParams(),
}));

describe('Navigation', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('リンクをクリックするとルーターが呼ばれる', async () => {
    const user = userEvent.setup();

    render(<Navigation />);

    await user.click(screen.getByRole('link', { name: /ダッシュボード/i }));

    expect(mockPush).toHaveBeenCalledWith('/dashboard');
  });

  it('現在のパスのリンクがアクティブになる', () => {
    render(<Navigation />);

    const homeLink = screen.getByRole('link', { name: /ホーム/i });
    expect(homeLink).toHaveClass('active');
  });
});

fetch APIのモック

// __tests__/api.test.tsx
describe('API呼び出しのテスト', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('データを正しく取得する', async () => {
    const mockData = { id: 1, name: 'テスト' };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockData,
    });

    const response = await fetch('/api/data');
    const data = await response.json();

    expect(data).toEqual(mockData);
    expect(fetch).toHaveBeenCalledWith('/api/data');
  });
});

MSWによるAPIモック(推奨)

より本格的なAPIモックには、Mock Service Worker(MSW)を使用します。

npm install --save-dev msw
// mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: '山田太郎' },
        { id: 2, name: '鈴木花子' },
      ])
    );
  }),

  rest.post('/api/users', async (req, res, ctx) => {
    const body = await req.json();
    return res(
      ctx.status(201),
      ctx.json({ id: 3, ...body })
    );
  }),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// jest.setup.js に追加
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

スナップショットテスト

コンポーネントのUIが意図せず変更されていないかを検証します。

// __tests__/Card.test.tsx
import { render } from '@testing-library/react';
import Card from '@/components/Card';

describe('Card', () => {
  it('スナップショットと一致する', () => {
    const { container } = render(
      <Card title="テスト" description="これはテストです" />
    );

    expect(container).toMatchSnapshot();
  });
});

スナップショットを更新する場合は、以下のコマンドを実行します。

npm test -- -u

カスタムフックのテスト

@testing-library/reactrenderHookを使用します。

// hooks/useCounter.ts
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
// __tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '@/hooks/useCounter';

describe('useCounter', () => {
  it('初期値が正しく設定される', () => {
    const { result } = renderHook(() => useCounter(10));

    expect(result.current.count).toBe(10);
  });

  it('incrementでカウントが増える', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('resetで初期値に戻る', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(5);
  });
});

テストカバレッジの確認

カバレッジレポートを生成して、テストの網羅率を確認します。

npm run test:coverage

jest.config.jsにカバレッジの閾値を設定できます。

const customJestConfig = {
  // ... 他の設定
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

テストの実行

# すべてのテストを実行
npm test

# ウォッチモードで実行(ファイル変更時に自動再実行)
npm run test:watch

# 特定のファイルのみテスト
npm test -- Button.test.tsx

# カバレッジレポートを生成
npm run test:coverage

まとめ

JestとReact Testing Libraryを使ったNext.jsのテスト環境は、比較的簡単に構築できます。これにより、コンポーネントの単体テストやUIテストを効率的に行うことができ、コードの品質を保ちながら安心して開発を進めることが可能です。テスト環境をしっかりと整え、プロジェクトのスケールに応じてテスト範囲を拡大していくことが重要です。

参考文献

円