はじめに
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.jsonのscriptsに以下を追加します。
"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では、以下の優先順位でクエリを使用することが推奨されています。
| 優先度 | クエリ | 用途 |
|---|---|---|
| 1 | getByRole | アクセシビリティロールで取得 |
| 2 | getByLabelText | フォーム要素のラベルで取得 |
| 3 | getByPlaceholderText | プレースホルダーで取得 |
| 4 | getByText | テキスト内容で取得 |
| 5 | getByTestId | data-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/awaitとfindByクエリを使用します。
// __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のuseRouterやusePathnameをモックする方法です。
// __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/reactのrenderHookを使用します。
// 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テストを効率的に行うことができ、コードの品質を保ちながら安心して開発を進めることが可能です。テスト環境をしっかりと整え、プロジェクトのスケールに応じてテスト範囲を拡大していくことが重要です。