jest-react-testing について
React 開発者向けの Jest と React Testing Library のテストガイドです。コンポーネント、フック、統合テストを扱い、ユーザーの操作やアクセシビリティを重視します。
主な機能
- 01コンポーネントと操作のテスト
- 02アクセシビリティ重視のクエリ
- 03非同期処理とタイマーのテスト
- 04モジュール、API、依存関係のモック
- 05カスタムフックと統合テスト
- 06Jest の設定とアサーション
活用シーン
- 01フォームとコンポーネント操作の検証
- 02非同期データ読み込みのテスト
- 03カスタムフックの動作確認
- 04コンポーネントツリーの統合テスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ manutej/ luxor-claude-marketplace --skill jest-react-testingインストール後、Claude Code で/jest-react-testingを入力して読み込まれたか確認します。
同梱ファイルEXAMPLES.mdREADME.md
詳細
Jest React テスト
Jest と React Testing Library を使用して React アプリケーションをテストするための包括的なスキルです。基本的なコンポーネントテストから、モック、非同期テスト、カスタムフックのテスト、統合テスト戦略などの高度なパターンまで、幅広く扱います。
このスキルを使う場面
次の場合にこのスキルを使用してください。
- Jest と React Testing Library で React コンポーネントをテストする
- React プロジェクト向けに Jest の設定を行う
- コンポーネント、フック、ユーティリティの単体テストを作成する
- ユーザー操作とコンポーネントの動作をテストする
- モジュール、関数、API 呼び出し、外部依存関係をモックする
- 非同期処理(API 呼び出し、タイマー、プロミス)をテストする
- カスタム React フックをテストする
- 複雑なコンポーネントツリーの統合テストを作成する
- 失敗するテストをデバッグする、またはテストカバレッジを改善する
- テストのベストプラクティスとパターンに従う
基本概念
テストの理念
React Testing Library は、次の指針に基づいています。
- 実装ではなくユーザーの振る舞いをテストする:ユーザーがアプリを操作する方法に近いテストを作成します
- アクセシビリティを優先する:アクセシブルなコンポーネントを促すクエリ(getByRole、getByLabelText)を使用します
- 実装の詳細のテストを避ける:状態、プロパティ、内部メソッドを直接テストしないでください
- 保守しやすいテスト:コードのリファクタリング時ではなく、動作が変わったときに失敗するテストを作成します
- カバレッジより信頼性を重視する:100%のカバレッジではなく、信頼性を高めるテストに注力します
主なテスト概念
- クエリ:要素を見つけるメソッド(getBy、queryBy、findBy)
- ユーザーイベント:ユーザー操作をシミュレートすること(クリック、入力、選択)
- 非同期テスト:非同期処理を含むコンポーネントをテストすること
- モック:依存関係を制御されたテストダブルに置き換えること
- アサーション:マッチャーを使って期待される結果を検証すること
Jest の設定
Jest の基本設定
jest.config.js(JavaScript プロジェクト):
/** @type {import('jest').Config} */
const config = {
// Test environment for DOM testing
testEnvironment: 'jsdom',
// Setup files after environment
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Module paths
moduleDirectories: ['node_modules', 'src'],
// Transform files with babel-jest
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
// Module name mapper for static assets and CSS
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/**/*.test.{js,jsx}',
'!src/**/__tests__/**',
],
// Coverage thresholds
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
module.exports = config;
jest.config.js(TypeScript プロジェクト):
import type {Config} from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleDirectories: ['node_modules', 'src'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.ts',
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/index.tsx',
'!src/**/*.test.{ts,tsx}',
'!src/**/__tests__/**',
'!src/**/*.d.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;
セットアップファイル
src/setupTests.js:
// Add custom jest matchers from jest-dom
import '@testing-library/jest-dom';
// Extend expect with jest-extended matchers (optional)
import * as matchers from 'jest-extended';
expect.extend(matchers);
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
// Suppress console errors in tests (optional)
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render')
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
// Reset mocks after each test
afterEach(() => {
jest.clearAllMocks();
});
ファイルのモック
mocks/fileMock.js:
module.exports = 'test-file-stub';
mocks/styleMock.js:
module.exports = {};
React Testing Library のクエリ
クエリの種類
React Testing Library には、次の 3 種類のクエリがあります。
- getBy:要素を返すか、エラーをスローします(存在するはずの要素に使用します)
- queryBy:要素または null を返します(存在しない場合もある要素に使用します)
- findBy:要素に解決されるプロミスを返します(非同期で表示される要素に使用します)
クエリの優先順位
推奨されるクエリの順序(アクセシビリティ重視):
-
getByRole:最もアクセシブルなクエリ
getByRole('button', { name: /submit/i }) getByRole('heading', { level: 1 }) getByRole('textbox', { name: /username/i }) -
getByLabelText:ラベルが付いたフォームフィールドに使用します
getByLabelText(/email address/i) getByLabelText('Password') -
getByPlaceholderText:プレースホルダーがある入力欄に使用します
getByPlaceholderText(/search/i) -
getByText:テキストのある非インタラクティブ要素に使用します
getByText(/welcome/i) getByText('Error: Invalid credentials') -
getByDisplayValue:値を持つフォーム要素に使用します
getByDisplayValue('John Doe') -
getByAltText:代替テキストのある画像に使用します
getByAltText(/profile picture/i) -
getByTitle:title 属性のある要素に使用します
getByTitle(/close/i) -
getByTestId:ほかのクエリが使えない場合の最終手段です
getByTestId('custom-element')
クエリのバリエーション
// Single element queries
screen.getByRole('button') // Throws if not found or multiple found
screen.queryByRole('button') // Returns null if not found
await screen.findByRole('button') // Async, waits up to 1000ms
// Multiple element queries
screen.getAllByRole('listitem') // Throws if none found
screen.queryAllByRole('listitem') // Returns [] if none found
await screen.findAllByRole('listitem') // Async version
コンポーネントのテスト戦略
基本的なコンポーネントテスト
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting Component', () => {
it('renders greeting message', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText(/hello, alice/i)).toBeInTheDocument();
});
it('renders default greeting when no name provided', () => {
render(<Greeting />);
expect(screen.getByText(/hello, guest/i)).toBeInTheDocument();
});
});
ユーザー操作のテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('increments counter on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
const count = screen.getByText(/count: 0/i);
expect(count).toBeInTheDocument();
await user.click(button);
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
it('decrements counter on button click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
const decrementBtn = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementBtn);
expect(screen.getByText(/count: 4/i)).toBeInTheDocument();
});
});
フォームのテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm Component', () => {
it('submits form with username and password', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
const usernameInput = screen.getByLabelText(/username/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.type(usernameInput, 'testuser');
await user.type(passwordInput, 'password123');
await user.click(submitButton);
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123',
});
});
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});
条件付きレンダリングのテスト
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile Component', () => {
it('shows loading state when loading', () => {
render(<UserProfile loading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('shows user data when loaded', () => {
const user = {
name: 'John Doe',
email: '[email protected]',
};
render(<UserProfile loading={false} user={user} />);
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: /john doe/i })).toBeInTheDocument();
expect(screen.getByText(/[email protected]/i)).toBeInTheDocument();
});
it('shows error message when error occurs', () => {
render(<UserProfile loading={false} error="Failed to load user" />);
expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
});
モックのパターン
モジュールのモック
自動モック:
// __mocks__/axios.js
export default {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
};
テストでの使用方法:
import axios from 'axios';
import { UserService } from './UserService';
jest.mock('axios');
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches users successfully', async () => {
const mockUsers = [{ id: 1, name: 'Alice' }];
axios.get.mockResolvedValue({ data: mockUsers });
const users = await UserService.getUsers();
expect(axios.get).toHaveBeenCalledWith('/api/users');
expect(users).toEqual(mockUsers);
});
it('handles fetch error', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(UserService.getUsers()).rejects.toThrow('Network Error');
});
});
関数のモック
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button Component', () => {
it('calls onClick handler when clicked', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('calls onClick with event object', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
})
);
});
});
MSW(Mock Service Worker)を使った API 呼び出しのモック
MSW のセットアップ:
// src/mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
);
}),
rest.post('/api/users', async (req, res, ctx) => {
const { name, email } = await req.json();
return res(
ctx.status(201),
ctx.json({
id: 3,
name,
email,
})
);
}),
];
サーバーのセットアップ:
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
setupTests.js での設定:
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
テストでの使用方法:
import { render, screen, waitFor } from '@testing-library/react';
import { server } from './mocks/server';
import { rest } from 'msw';
import { UserList } from './UserList';
describe('UserList Component', () => {
it('fetches and displays users', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/alice/i)).toBeInTheDocument();
expect(screen.getByText(/bob/i)).toBeInTheDocument();
});
});
it('handles server error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal Server Error' })
);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
});
});
});
コンテキストのモック
import { render, screen } from '@testing-library/react';
import { AuthContext } from './AuthContext';
import { ProtectedComponent } from './ProtectedComponent';
const mockAuthContext = (overrides = {}) => ({
user: { id: 1, name: 'Test User' },
isAuthenticated: true,
login: jest.fn(),
logout: jest.fn(),
...overrides,
});
describe('ProtectedComponent', () => {
it('renders content for authenticated user', () => {
const contextValue = mockAuthContext();
render(
<AuthContext.Provider value={contextValue}>
<ProtectedComponent />
</AuthContext.Provider>
);
expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
});
it('renders login prompt for unauthenticated user', () => {
const contextValue = mockAuthContext({
user: null,
isAuthenticated: false,
});
render(
<AuthContext.Provider value={contextValue}>
<ProtectedComponent />
</AuthContext.Provider>
);
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
});
子コンポーネントのモック
import { render, screen } from '@testing-library/react';
import { ParentComponent } from './ParentComponent';
// Mock the child component
jest.mock('./ChildComponent', () => ({
ChildComponent: ({ title, onAction }) => (
<div>
<h2>{title}</h2>
<button onClick={onAction}>Mocked Action</button>
</div>
),
}));
describe('ParentComponent', () => {
it('renders with mocked child', () => {
render(<ParentComponent />);
expect(screen.getByText(/mocked action/i)).toBeInTheDocument();
});
});
非同期テストのパターン
waitFor を使ったテスト
import { render, screen, waitFor } from '@testing-library/react';
import { AsyncComponent } from './AsyncComponent';
describe('AsyncComponent', () => {
it('loads and displays data', async () => {
render(<AsyncComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
});
it('waits for specific condition', async () => {
render(<AsyncComponent />);
await waitFor(
() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
},
{ timeout: 3000 }
);
});
});
findBy クエリを使ったテスト
import { render, screen } from '@testing-library/react';
import { DataFetcher } from './DataFetcher';
describe('DataFetcher Component', () => {
it('displays fetched data', async () => {
render(<DataFetcher />);
// findBy automatically waits for element to appear
const heading = await screen.findByRole('heading', { name: /data/i });
expect(heading).toBeInTheDocument();
});
it('handles timeout for missing elements', async () => {
render(<DataFetcher url="/api/missing" />);
await expect(
screen.findByText(/success/i, {}, { timeout: 500 })
).rejects.toThrow();
});
});
プロミスのテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncForm } from './AsyncForm';
describe('AsyncForm Component', () => {
it('submits form and shows success message', async () => {
const user = userEvent.setup();
render(<AsyncForm />);
const input = screen.getByLabelText(/name/i);
const submitBtn = screen.getByRole('button', { name: /submit/i });
await user.type(input, 'John Doe');
await user.click(submitBtn);
const successMsg = await screen.findByText(/submitted successfully/i);
expect(successMsg).toBeInTheDocument();
});
it('shows error message on failure', async () => {
const user = userEvent.setup();
render(<AsyncForm shouldFail={true} />);
const submitBtn = screen.getByRole('button', { name: /submit/i });
await user.click(submitBtn);
const errorMsg = await screen.findByRole('alert');
expect(errorMsg).toHaveTextContent(/submission failed/i);
});
});
フェイクタイマーを使ったテスト
import { render, screen, act } from '@testing-library/react';
import { Timer } from './Timer';
describe('Timer Component', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('updates timer every second', () => {
render(<Timer />);
expect(screen.getByText(/0 seconds/i)).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText(/1 second/i)).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText(/4 seconds/i)).toBeInTheDocument();
});
it('cleans up timer on unmount', () => {
const { unmount } = render(<Timer />);
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
カスタムフックのテスト
基本的なフックのテスト
import { renderHook } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
プロパティを使うフックのテスト
import { renderHook } from '@testing-library/react';
import { useFetch } from './useFetch';
describe('useFetch Hook', () => {
it('fetches data for given URL', async () => {
const { result } = renderHook(() => useFetch('/api/users'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
expect(result.current.error).toBeNull();
});
it('refetches when URL changes', async () => {
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/users' } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const firstData = result.current.data;
rerender({ url: '/api/posts' });
await waitFor(() => {
expect(result.current.data).not.toEqual(firstData);
});
});
});
コンテキストを使うフックのテスト
import { renderHook } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { useTheme } from './useTheme';
describe('useTheme Hook', () => {
const wrapper = ({ children }) => (
<ThemeProvider initialTheme="light">
{children}
</ThemeProvider>
);
it('returns current theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
});
it('toggles theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('dark');
});
});
非同期フックのテスト
import { renderHook, waitFor } from '@testing-library/react';
import { useAsyncData } from './useAsyncData';
describe('useAsyncData Hook', () => {
it('loads data asynchronously', async () => {
const { result } = renderHook(() => useAsyncData('/api/data'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
it('handles errors', async () => {
const { result } = renderHook(() => useAsyncData('/api/error'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeNull();
});
});
統合テストのパターン
コンポーネント統合のテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from './App';
describe('App Integration', () => {
it('navigates between pages', async () => {
const user = userEvent.setup();
render(<App />);
expect(screen.getByText(/home page/i)).toBeInTheDocument();
const aboutLink = screen.getByRole('link', { name: /about/i });
await user.click(aboutLink);
expect(screen.getByText(/about page/i)).toBeInTheDocument();
});
it('completes full user flow', async () => {
const user = userEvent.setup();
render(<App />);
// Navigate to signup
await user.click(screen.getByRole('link', { name: /sign up/i }));
// Fill out form
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit form
await user.click(screen.getByRole('button', { name: /submit/i }));
// Verify success
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
});
});
Router を使ったテスト
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { AppRoutes } from './AppRoutes';
const renderWithRouter = (ui, { initialEntries = ['/'] } = {}) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
);
};
describe('AppRoutes Integration', () => {
it('renders home page by default', () => {
renderWithRouter(<AppRoutes />);
expect(screen.getByText(/home/i)).toBeInTheDocument();
});
it('renders user page at /users/:id', () => {
renderWithRouter(<AppRoutes />, { initialEntries: ['/users/123'] });
expect(screen.getByText(/user profile/i)).toBeInTheDocument();
});
it('navigates programmatically', async () => {
const user = userEvent.setup();
renderWithRouter(<AppRoutes />);
const navButton = screen.getByRole('button', { name: /go to profile/i });
await user.click(navButton);
expect(screen.getByText(/profile page/i)).toBeInTheDocument();
});
});
Redux を使ったテスト
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';
import { TodoList } from './TodoList';
import todosReducer from './todosSlice';
const createMockStore = (initialState = {}) => {
return configureStore({
reducer: {
todos: todosReducer,
},
preloadedState: initialState,
});
};
const renderWithStore = (ui, { store = createMockStore() } = {}) => {
return render(<Provider store={store}>{ui}</Provider>);
};
describe('TodoList Integration', () => {
it('adds new todo', async () => {
const user = userEvent.setup();
renderWithStore(<TodoList />);
const input = screen.getByPlaceholderText(/new todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Buy groceries');
await user.click(addButton);
expect(screen.getByText(/buy groceries/i)).toBeInTheDocument();
});
it('renders initial todos from store', () => {
const initialState = {
todos: {
items: [
{ id: 1, text: 'Existing todo', completed: false },
],
},
};
renderWithStore(<TodoList />, { store: createMockStore(initialState) });
expect(screen.getByText(/existing todo/i)).toBeInTheDocument();
});
});
Jest DOM マッチャー
よく使うマッチャー
// Element presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();
// Visibility
expect(element).toBeVisible();
expect(element).not.toBeVisible();
// Text content
expect(element).toHaveTextContent('Hello World');
expect(element).toHaveTextContent(/hello/i);
// Attributes
expect(element).toHaveAttribute('type', 'submit');
expect(element).toHaveAttribute('disabled');
// Classes
expect(element).toHaveClass('active');
expect(element).toHaveClass('btn', 'btn-primary');
// Styles
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle('display: none');
// Forms
expect(input).toHaveValue('test');
expect(input).toHaveDisplayValue('Test');
expect(checkbox).toBeChecked();
expect(checkbox).not.toBeChecked();
expect(input).toBeDisabled();
expect(input).toBeEnabled();
expect(input).toBeRequired();
expect(input).toBeInvalid();
expect(input).toBeValid();
// Focus
expect(element).toHaveFocus();
// Accessibility
expect(element).toHaveAccessibleName('Submit button');
expect(element).toHaveAccessibleDescription('Click to submit form');
// Contains
expect(container).toContainElement(child);
expect(container).toContainHTML('<span>Text</span>');
ベストプラクティス
テストの整理
-
関連するテストをグループ化する:
describeブロックを使ってテストを整理しますdescribe('UserProfile', () => { describe('when loading', () => { it('shows loading spinner', () => {}); }); describe('when loaded', () => { it('displays user information', () => {}); it('shows profile picture', () => {}); }); }); -
わかりやすいテスト名を付ける:テスト名には動作を記述します
// Good it('displays error message when login fails', () => {}); // Bad it('test login', () => {}); -
AAA パターンに従う:準備、実行、検証
it('increments counter', async () => { // Arrange const user = userEvent.setup(); render(<Counter />); // Act await user.click(screen.getByRole('button', { name: /increment/i })); // Assert expect(screen.getByText(/count: 1/i)).toBeInTheDocument(); });
クエリのベストプラクティス
- アクセシビリティに配慮したクエリを優先する:getByRole、getByLabelText を使います
- screen のクエリを使う:render の戻り値から分割代入するのではなく、screen からインポートします
- getByTestId を避ける:最後の手段としてのみ使います
- 正規表現を使う:完全一致の文字列より柔軟に指定できます
非同期テストのベストプラクティス
- 非同期処理には findBy を使う:getBy と waitFor の組み合わせより findBy を優先します
- 適切なタイムアウトを設定する:時間のかかる処理には waitFor のタイムアウトを設定します
- act() の警告を避ける:userEvent、waitFor、findBy を適切に使います
- タイマーをクリーンアップする:jest.useFakeTimers() を使い、適切にクリーンアップします
モックのベストプラクティス
- 適切なレベルでモックする:内部ロジックではなく、外部の依存関係をモックします
- モックをリセットする:テスト間でモックをクリアします
- API には MSW を使う:axios/fetch を直接モックするより MSW を優先します
- 過剰なモックを避ける:テスト対象をモックしないでください
カバレッジのベストプラクティス
- 動作に焦点を当てる:実装ではなく、ユーザー向けの動作をテストします
- 100% を追い求めない:重要なパスに焦点を当てます
- エラー状態をテストする:エラー処理のテストを含めます
- 境界ケースをテストする:境界条件を含めます
よく使うテストパターン
リストと反復処理のテスト
it('renders list of items', () => {
const items = ['Apple', 'Banana', 'Cherry'];
render(<ItemList items={items} />);
items.forEach(item => {
expect(screen.getByText(item)).toBeInTheDocument();
});
});
アクセシビリティのテスト
it('has accessible form', () => {
render(<ContactForm />);
const nameInput = screen.getByLabelText(/name/i);
expect(nameInput).toHaveAccessibleName('Name');
expect(nameInput).toBeRequired();
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toHaveAttribute('type', 'submit');
});
エラーバウンダリのテスト
it('catches errors and displays fallback', () => {
const ThrowError = () => {
throw new Error('Test error');
};
// Suppress console.error for this test
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowError />
</ErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
spy.mockRestore();
});
ポータルのテスト
it('renders modal in portal', () => {
render(<Modal isOpen={true}>Modal Content</Modal>);
const modal = screen.getByText(/modal content/i);
expect(modal).toBeInTheDocument();
// Modal should be in document.body, not in the component tree
expect(modal.parentElement).toBe(document.body);
});
トラブルシューティング
よくある問題
問題:「Unable to find element」
- 解決策:screen.debug() を使って DOM を確認し、クエリの種類を確認して、非同期更新を待ちます
問題:「Act warnings」
- 解決策:fireEvent の代わりに userEvent を使い、状態更新を act() で囲み、waitFor/findBy を使います
問題:「Jest timeout」
- 解決策:タイムアウトを延長し、無限ループがないか確認して、非同期処理が完了することを確認します
問題:「Cannot read property of undefined」
- 解決策:モックが正しく設定されているか確認し、コンポーネントが必要な props を受け取っていることを確認します
問題:「Multiple elements found」
- 解決策:クエリをより具体的にし、複数の要素には getAllBy を使います
リソース
- Jest のドキュメント:https://jestjs.io/
- React Testing Library:https://testing-library.com/react
- Testing Library のクエリ:https://testing-library.com/docs/queries/about
- Jest DOM マッチャー:https://github.com/testing-library/jest-dom
- MSW のドキュメント:https://mswjs.io/
- よくある間違い:https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
スキルのバージョン:1.0.0 最終更新日:2025年10月 スキルのカテゴリ:テスト、React、品質保証 互換性:Jest 29+、React Testing Library 13+、React 16.8+