この完全ガイドでReact Testing Library (RTL)を習得。ベストプラクティスと実践例に焦点を当て、効果的で保守性の高いユーザー中心のテストを作成する方法を学びます。
React Testing Library:完全ガイド
今日のペースの速いウェブ開発環境において、Reactアプリケーションの品質と信頼性を確保することは最も重要です。React Testing Library (RTL) は、ユーザー視点に重点を置いたテストを作成するための、人気があり効果的なソリューションとして登場しました。このガイドは、基本的な概念から高度なテクニックまで、RTLの完全な概要を提供し、堅牢で保守性の高いReactアプリケーションを構築する力を与えます。
なぜReact Testing Libraryを選ぶのか?
従来のテストアプローチは、しばしば実装の詳細に依存するため、テストが脆弱になり、わずかなコード変更で壊れやすくなります。一方、RTLは、ユーザーがコンポーネントと対話するようにテストすることを奨励し、ユーザーが見て体験することに焦点を当てます。このアプローチには、いくつかの重要な利点があります:
- ユーザー中心のテスト: RTLは、ユーザーの視点を反映したテストの作成を促進し、アプリケーションがエンドユーザーの視点から期待どおりに機能することを保証します。
- テストの脆弱性の低減: 実装の詳細をテストすることを避けることで、RTLのテストはコードをリファクタリングしても壊れにくくなり、より保守性が高く堅牢なテストにつながります。
- コード設計の改善: RTLは、アクセシブルで使いやすいコンポーネントを作成することを奨励し、全体的なコード設計の向上につながります。
- アクセシビリティへの焦点: RTLは、コンポーネントのアクセシビリティをテストしやすくし、アプリケーションが誰にでも使えることを保証します。
- テストプロセスの簡素化: RTLはシンプルで直感的なAPIを提供し、テストの作成と保守を容易にします。
テスト環境のセットアップ
RTLを使い始める前に、テスト環境をセットアップする必要があります。これには通常、必要な依存関係のインストールとテストフレームワークの設定が含まれます。
前提条件
- Node.jsとnpm (またはyarn): Node.jsとnpm (またはyarn) がシステムにインストールされていることを確認してください。これらは公式Node.jsウェブサイトからダウンロードできます。
- Reactプロジェクト: 既存のReactプロジェクトがあるか、Create React Appなどのツールを使用して新しいプロジェクトを作成してください。
インストール
npmまたはyarnを使用して、以下のパッケージをインストールします:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react
または、yarnを使用する場合:
yarn add --dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react
パッケージの説明:
- @testing-library/react: Reactコンポーネントをテストするためのコアライブラリです。
- @testing-library/jest-dom: DOMノードに関するアサーションを行うためのカスタムJestマッチャーを提供します。
- Jest: 人気のあるJavaScriptテストフレームワークです。
- babel-jest: Babelを使用してコードをコンパイルするJestトランスフォーマーです。
- @babel/preset-env: ターゲット環境をサポートするために必要なBabelプラグインとプリセットを決定するBabelプリセットです。
- @babel/preset-react: React用のBabelプリセットです。
設定
プロジェクトのルートに、以下の内容で`babel.config.js`ファイルを作成します:
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react'],
};
`package.json`ファイルを更新して、テストスクリプトを含めます:
{
"scripts": {
"test": "jest"
}
}
プロジェクトのルートに`jest.config.js`ファイルを作成してJestを設定します。最小限の設定は次のようになります:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['/src/setupTests.js'],
};
`src/setupTests.js`ファイルを以下の内容で作成します。これにより、すべてのテストでJest DOMマッチャーが利用可能になります:
import '@testing-library/jest-dom/extend-expect';
最初のテストを書く
簡単な例から始めましょう。挨拶メッセージを表示するReactコンポーネントがあるとします:
// src/components/Greeting.js
import React from 'react';
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
次に、このコンポーネントのテストを書きましょう:
// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('renders a greeting message', () => {
render(<Greeting name="World" />);
const greetingElement = screen.getByText(/Hello, World!/i);
expect(greetingElement).toBeInTheDocument();
});
説明:
- `render`:この関数はコンポーネントをDOMにレンダリングします。
- `screen`:このオブジェクトはDOMをクエリするためのメソッドを提供します。
- `getByText`:このメソッドはテキストコンテンツによって要素を検索します。`/i`フラグは検索を大文字と小文字を区別しないようにします。
- `expect`:この関数はコンポーネントの振る舞いについてのアサーションを行うために使用されます。
- `toBeInTheDocument`:このマッチャーは要素がDOM内に存在することをアサートします。
テストを実行するには、ターミナルで次のコマンドを実行します:
npm test
すべてが正しく設定されていれば、テストはパスするはずです。
一般的なRTLクエリ
RTLは、DOM内の要素を見つけるための様々なクエリメソッドを提供します。これらのクエリは、ユーザーがアプリケーションとどのように対話するかを模倣するように設計されています。
`getByRole`
このクエリは、ARIAロールによって要素を見つけます。アクセシビリティを促進し、テストが基盤となるDOM構造の変更に強いことを保証するため、可能な限り`getByRole`を使用することが良い習慣です。
<button role="button">Click me</button>
const buttonElement = screen.getByRole('button');
expect(buttonElement).toBeInTheDocument();
`getByLabelText`
このクエリは、関連付けられたラベルのテキストによって要素を見つけます。フォーム要素のテストに便利です。
<label htmlFor="name">Name:</label>
<input type="text" id="name" />
const nameInputElement = screen.getByLabelText('Name:');
expect(nameInputElement).toBeInTheDocument();
`getByPlaceholderText`
このクエリは、プレースホルダーテキストによって要素を見つけます。
<input type="text" placeholder="Enter your email" />
const emailInputElement = screen.getByPlaceholderText('Enter your email');
expect(emailInputElement).toBeInTheDocument();
`getByAltText`
このクエリは、altテキストによって画像要素を見つけます。アクセシビリティを確保するために、すべての画像に意味のあるaltテキストを提供することが重要です。
<img src="logo.png" alt="Company Logo" />
const logoImageElement = screen.getByAltText('Company Logo');
expect(logoImageElement).toBeInTheDocument();
`getByTitle`
このクエリは、title属性によって要素を見つけます。
<span title="Close">X</span>
const closeElement = screen.getByTitle('Close');
expect(closeElement).toBeInTheDocument();
`getByDisplayValue`
このクエリは、表示値によって要素を見つけます。これは、事前に値が入力されたフォーム入力をテストするのに便利です。
<input type="text" value="Initial Value" />
const inputElement = screen.getByDisplayValue('Initial Value');
expect(inputElement).toBeInTheDocument();
`getAllBy*` クエリ
`getBy*` クエリに加えて、RTLは`getAllBy*` クエリも提供します。これらは一致する要素の配列を返します。同じ特性を持つ複数の要素がDOMに存在することをアサートする必要がある場合に便利です。
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(3);
`queryBy*` クエリ
`queryBy*` クエリは`getBy*` クエリに似ていますが、一致する要素が見つからない場合にエラーをスローする代わりに`null`を返します。これは、要素がDOMに存在*しない*ことをアサートしたい場合に便利です。
const missingElement = screen.queryByText('Non-existent text');
expect(missingElement).toBeNull();
`findBy*` クエリ
`findBy*` クエリは`getBy*` クエリの非同期バージョンです。これらは、一致する要素が見つかったときに解決されるPromiseを返します。APIからデータをフェッチするなど、非同期操作をテストするのに便利です。
// 非同期データフェッチのシミュレーション
const fetchData = () => new Promise(resolve => {
setTimeout(() => resolve('Data Loaded!'), 1000);
});
function MyComponent() {
const [data, setData] = React.useState(null);
React.useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}
test('loads data asynchronously', async () => {
render(<MyComponent />);
const dataElement = await screen.findByText('Data Loaded!');
expect(dataElement).toBeInTheDocument();
});
ユーザーインタラクションのシミュレーション
RTLは、ボタンのクリック、入力フィールドへのタイピング、フォームの送信などのユーザーインタラクションをシミュレートするための`fireEvent`および`userEvent` APIを提供します。
`fireEvent`
`fireEvent`を使用すると、DOMイベントをプログラムでトリガーできます。これは、発生するイベントをきめ細かく制御できる低レベルAPIです。
<button onClick={() => alert('Button clicked!')}>Click me</button>
import { fireEvent } from '@testing-library/react';
test('simulates a button click', () => {
const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
render(<button onClick={() => alert('Button clicked!')}>Click me</button>);
const buttonElement = screen.getByRole('button');
fireEvent.click(buttonElement);
expect(alertMock).toHaveBeenCalledTimes(1);
alertMock.mockRestore();
});
`userEvent`
`userEvent`は、ユーザーインタラクションをより現実的にシミュレートする高レベルAPIです。フォーカス管理やイベントの順序などの詳細を処理するため、テストがより堅牢で壊れにくくなります。
<input type="text" onChange={e => console.log(e.target.value)} />
import userEvent from '@testing-library/user-event';
test('simulates typing in an input field', () => {
const inputElement = screen.getByRole('textbox');
userEvent.type(inputElement, 'Hello, world!');
expect(inputElement).toHaveValue('Hello, world!');
});
非同期コードのテスト
多くのReactアプリケーションには、APIからのデータフェッチなどの非同期操作が含まれます。RTLは、非同期コードをテストするためのいくつかのツールを提供します。
`waitFor`
`waitFor`を使用すると、アサーションを行う前に条件が真になるのを待つことができます。完了までに時間がかかる非同期操作をテストするのに便利です。
function MyComponent() {
const [data, setData] = React.useState(null);
React.useEffect(() => {
setTimeout(() => {
setData('Data loaded!');
}, 1000);
}, []);
return <div>{data}</div>;
}
import { waitFor } from '@testing-library/react';
test('waits for data to load', async () => {
render(<MyComponent />);
await waitFor(() => screen.getByText('Data loaded!'));
const dataElement = screen.getByText('Data loaded!');
expect(dataElement).toBeInTheDocument();
});
`findBy*` クエリ
前述のように、`findBy*`クエリは非同期であり、一致する要素が見つかったときに解決されるPromiseを返します。これらは、DOMの変更をもたらす非同期操作をテストするのに便利です。
フックのテスト
Reactフックは、ステートフルなロジックをカプセル化する再利用可能な関数です。RTLは、カスタムフックを分離してテストするために、`@testing-library/react-hooks`の`renderHook`ユーティリティを提供します(v17以降は非推奨となり、`@testing-library/react`に直接統合されました)。
// src/hooks/useCounter.js
import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => {
setCount(prevCount => prevCount + 1);
};
const decrement = () => {
setCount(prevCount => prevCount - 1);
};
return { count, increment, decrement };
}
export default useCounter;
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('increments the counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
説明:
- `renderHook`:この関数はフックをレンダリングし、フックの結果を含むオブジェクトを返します。
- `act`:この関数は、状態の更新を引き起こすコードをラップするために使用されます。これにより、Reactが更新を適切にバッチ処理およびプロセスできるようになります。
高度なテストテクニック
RTLの基本を習得したら、テストの品質と保守性を向上させるためのより高度なテストテクニックを探求できます。
モジュールのモック化
コンポーネントを分離し、テスト中の動作を制御するために、外部モジュールや依存関係をモック化する必要がある場合があります。Jestはこの目的のために強力なモックAPIを提供します。
// src/api/dataService.js
export const fetchData = async () => {
const response = await fetch('/api/data');
const data = await response.json();
return data;
};
// src/components/MyComponent.js
import React, { useState, useEffect } from 'react';
import { fetchData } from '../api/dataService';
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}
// src/components/MyComponent.test.js
import { render, screen, waitFor } from '@testing-library/react';
import MyComponent from './MyComponent';
import * as dataService from '../api/dataService';
jest.mock('../api/dataService');
test('fetches data from the API', async () => {
dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' });
render(<MyComponent />);
await waitFor(() => screen.getByText('Mocked data!'));
expect(screen.getByText('Mocked data!')).toBeInTheDocument();
expect(dataService.fetchData).toHaveBeenCalledTimes(1);
});
説明:
- `jest.mock('../api/dataService')`:この行は`dataService`モジュールをモックします。
- `dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' })`:この行は、モックされた`fetchData`関数が指定されたデータで解決されるPromiseを返すように設定します。
- `expect(dataService.fetchData).toHaveBeenCalledTimes(1)`:この行は、モックされた`fetchData`関数が1回呼び出されたことをアサートします。
Contextプロバイダー
コンポーネントがContextプロバイダーに依存している場合、テスト中にコンポーネントをプロバイダーでラップする必要があります。これにより、コンポーネントがコンテキスト値にアクセスできるようになります。
// src/contexts/ThemeContext.js
import React, { createContext, useState } from 'react';
export const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// src/components/MyComponent.js
import React, { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div style={{ backgroundColor: theme === 'light' ? '#fff' : '#000', color: theme === 'light' ? '#000' : '#fff' }}>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// src/components/MyComponent.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import MyComponent from './MyComponent';
import { ThemeProvider } from '../contexts/ThemeContext';
test('toggles the theme', () => {
render(
<ThemeProvider>
<MyComponent />
</ThemeProvider>
);
const themeParagraph = screen.getByText(/Current theme: light/i);
const toggleButton = screen.getByRole('button', { name: /Toggle Theme/i });
expect(themeParagraph).toBeInTheDocument();
fireEvent.click(toggleButton);
expect(screen.getByText(/Current theme: dark/i)).toBeInTheDocument();
});
説明:
- テスト中に必要なコンテキストを提供するために、`MyComponent`を`ThemeProvider`でラップします。
ルーターを使ったテスト
React Routerを使用するコンポーネントをテストする場合、モックのルーターコンテキストを提供する必要があります。これは`react-router-dom`の`MemoryRouter`コンポーネントを使用して実現できます。
// src/components/MyComponent.js
import React from 'react';
import { Link } from 'react-router-dom';
function MyComponent() {
return (
<div>
<Link to="/about">Go to About Page</Link>
</div>
);
}
// src/components/MyComponent.test.js
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import MyComponent from './MyComponent';
test('renders a link to the about page', () => {
render(
<MemoryRouter>
<MyComponent />
</MemoryRouter>
);
const linkElement = screen.getByRole('link', { name: /Go to About Page/i });
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', '/about');
});
説明:
- モックのルーターコンテキストを提供するために、`MyComponent`を`MemoryRouter`でラップします。
- リンク要素が正しい`href`属性を持っていることをアサートします。
効果的なテストを書くためのベストプラクティス
RTLでテストを書く際に従うべきベストプラクティスをいくつか紹介します:
- ユーザーインタラクションに焦点を当てる: ユーザーがアプリケーションとどのように対話するかをシミュレートするテストを作成します。
- 実装の詳細のテストを避ける: コンポーネントの内部の仕組みをテストしないでください。代わりに、観察可能な振る舞いに焦点を当てます。
- 明確で簡潔なテストを書く: テストを理解しやすく、保守しやすくします。
- 意味のあるテスト名を使用する: テストされる振る舞いを正確に説明するテスト名を選択します。
- テストを分離する: テスト間の依存関係を避けます。各テストは独立していて自己完結型であるべきです。
- エッジケースをテストする: ハッピーパスだけをテストしないでください。エッジケースやエラー条件もテストするようにしてください。
- コードを書く前にテストを書く: コードを書く前にテストを書くテスト駆動開発(TDD)の使用を検討してください。
- 「AAA」パターンに従う: Arrange、Act、Assert。このパターンは、テストを構造化し、読みやすくするのに役立ちます。
- テストを高速に保つ: 遅いテストは、開発者が頻繁に実行するのを妨げる可能性があります。ネットワークリクエストをモックし、DOM操作の量を最小限に抑えることで、テストを高速化します。
- 説明的なエラーメッセージを使用する: アサーションが失敗した場合、エラーメッセージは失敗の原因を迅速に特定するのに十分な情報を提供する必要があります。
結論
React Testing Libraryは、Reactアプリケーションのための効果的で保守性が高く、ユーザー中心のテストを作成するための強力なツールです。このガイドで概説した原則とテクニックに従うことで、ユーザーのニーズを満たす堅牢で信頼性の高いアプリケーションを構築できます。ユーザーの視点からテストすることに焦点を当て、実装の詳細のテストを避け、明確で簡潔なテストを書くことを忘れないでください。RTLを採用し、ベストプラクティスを取り入れることで、場所やグローバルな視聴者の特定の要件に関係なく、Reactプロジェクトの品質と保守性を大幅に向上させることができます。