はじめに
コンポーネントの内部実装を少し変えただけなのに、テストが軒並み落ちる——そんな経験はありませんか。stateの名前を変えた、propsの受け渡し方をリファクタした、それだけでテストコードまで書き直す羽目になる。これは、テストが「実装の詳細」に依存しすぎているサインです。
React Testing Libraryは、この問題に正面から向き合ったライブラリです。コンポーネントの内部状態やメソッドを直接覗くのではなく、実際のユーザーが画面を見て、クリックして、入力するのと同じやり方でテストを書かせます。ボタンのテキストで要素を探し、クリックして、画面に何が表示されるかを確認する。それだけです。実装をどう書き換えても、画面の振る舞いが変わらなければテストは通り続けます。
とはいえ、読むより触った方が早いと思います。ボタン操作を自動でシミュレートして結果を確認するサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
React Testing Libraryとは
React Testing Libraryは、Kent C. Doddsが作成したReact向けのテストユーティリティです。DOM操作の共通基盤である@testing-library/domをベースにしており、Jest・Vitestといったテストランナーと組み合わせて使います。
最大の特徴は「Guiding Principle(指導原則)」と呼ばれる思想です。「テストがソフトウェアの使われ方に近いほど、そのテストは信頼できる」という考え方に基づき、stateやprops、コンポーネントのインスタンスに直接アクセスするAPIをあえて提供していません。
主な特徴
- 実装の詳細をテストしない - コンポーネント内部のstateやメソッドではなく、画面に表示されるテキストやロールを基準に要素を探す
- アクセシビリティを意識したクエリ -
getByRoleやgetByLabelTextなど、スクリーンリーダーが認識できる情報を優先してテストを書くよう促す - 自動クリーンアップ - テストごとにDOMを自動でアンマウント・破棄するため、テスト間の状態漏れを防げる
- user-eventとの高い親和性 -
@testing-library/user-eventと組み合わせることで、実際のブラウザ操作に近いクリック・入力・タブ移動をシミュレートできる
インストール
Jest・Vitestなどのテストランナーと併用するのが前提です。以下のパッケージを合わせて入れておくと便利です。
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
yarn add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
React-Testing-Libraryのサンプルを動かす
以下は、renderでコンポーネントをDOMに描画し、fireEvent.clickでボタン操作をシミュレートしたうえで、screen(およびwithin)で画面上のテキストを検証するサンプルです。上半分はカウンターを実際にクリックして遊べるようにしてあり、下半分の「テストを実行」ボタンを押すと、+1ボタンを3回自動でクリックして「カウント: 3」と表示されているかをReact Testing LibraryのAPIだけで検証します。
要点を抜き出すと次のようになります。
import { render, fireEvent, within } from '@testing-library/react'
const container = document.createElement('div')
render(<Counter />, { container })
const button = within(container).getByText('+1')
fireEvent.click(button)
fireEvent.click(button)
const label = within(container).getByTestId('count')
label.textContent === 'カウント: 2' // true なら成功
実際に動かせるものが下です。「テストを実行」を押すとPASS/FAILが表示され、コンソールを開くとfireEventが発火したイベントのログも確認できます。
+1ボタンをクリックする回数をfireEvent.click(button)の行数で増減させると、getByTestId('count')が拾うテキストも変わり、期待値の'カウント: 3'と一致しなくなってFAILに変わるはずです。テスト対象のコンポーネント(Counter)を書き換えても、+1という文言とdata-testidさえ保てばテストが通り続ける点も確認してみてください。
基本的な使い方
実際のプロジェクトでは、Jest・Vitestのテストファイルに次のように書きます。renderでコンポーネントを描画し、screenのクエリで要素を取得、expect(Vitestの場合は自動、Jestは@testing-library/jest-domのマッチャーを利用)で検証するのが基本形です。
import { render, screen, fireEvent } from '@testing-library/react'
import { expect, test } from 'vitest'
import Counter from './Counter'
test('ボタンを押すとカウントが増える', () => {
render(<Counter />)
const button = screen.getByText('+1')
fireEvent.click(button)
expect(screen.getByText('カウント: 1')).toBeInTheDocument()
})
getByTextは、指定したテキストを持つ要素が1つだけ見つかることを期待するクエリです。見つからない場合やテキストが複数箇所にある場合はエラーになるため、テストを書いている段階で「ユーザーから見て要素が一意に識別できるか」を自然にチェックできます。
実践的なユースケース
ユーザー操作のシミュレーション(user-event)
fireEventはDOMイベントを直接発火させるのに対し、@testing-library/user-eventはキー入力やクリックをブラウザの実際の操作に近い形で連続実行してくれます。フォーム入力のように複数のイベントが連鎖する操作は、user-eventを使う方が実態に近いテストになります。
import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
const input = screen.getByLabelText('todo-input')
await user.type(input, '牛乳を買う')
await user.click(screen.getByText('追加'))
screen.getByText('牛乳を買う') // 見つかればテスト成功
user.type(input, '牛乳を買う')の文字列を別の値に変えると、リストに追加される項目もテストの期待値と一致しなくなり、FAILに変わります。実際の入力欄に手でタイプしても同じ挙動になることも確認できるはずです。
非同期処理のテスト(findBy / waitFor)
API呼び出しなど非同期でデータが表示されるコンポーネントは、getBy系のクエリでは即座に要素が見つからずエラーになります。React Testing Libraryには、要素が現れるまで自動でリトライしながら待つfindBy系クエリと、任意の条件を待つwaitForが用意されています。
// findByTextは要素が現れるまで自動でリトライしながら待つ
const result = await screen.findByText('ユーザー: 田中太郎')
// 任意の条件を待ちたい場合はwaitForを使う(Jest/Vitestのexpectと組み合わせるのが一般的)
await waitFor(() => {
expect(screen.queryByText('読み込み中...')).toBeNull()
})
fakeFetchUser内のsetTimeoutの待ち時間を1000msなど長くしても、findByTextが自動でリトライして待つのでテストは変わらずPASSします。逆にfindByTextをgetByTextに置き換えると、データがまだ届いていない時点で即座にエラーになる違いも試してみてください。
カスタムrenderでProviderをラップする
Context APIを使ったコンポーネントは、テスト時にも対応するProviderでラップしてあげる必要があります。毎回同じラップ処理を書くのは面倒なので、renderをラップした専用の関数を用意しておくのが定石です。
function renderWithTheme(ui, theme) {
return render(<ThemeContext.Provider value={theme}>{ui}</ThemeContext.Provider>)
}
renderWithTheme(<ThemedButton />, 'dark')
screen.getByText('現在のテーマ: dark') // Providerありでレンダーできる
renderWithThemeの第2引数を'light'に変えてrunAutoTestを実行すると、期待値の'現在のテーマ: dark'と一致しなくなりFAILになります。Providerが複数必要になった場合も、renderWithThemeのような専用関数の中でネストしてラップすれば同じパターンで対応できます。
まとめ
React Testing Libraryは、コンポーネントの内部実装ではなく「画面に何が表示され、ユーザーが何をできるか」を基準にテストを書かせてくれるライブラリです。getByRoleやgetByTextで要素を探し、fireEventやuser-eventで操作をシミュレートし、findByやwaitForで非同期処理を待つ。この一貫した書き方に慣れると、リファクタのたびにテストを書き直すストレスからかなり解放されます。
まずは手元の小さなコンポーネントに、renderとscreen.getByTextだけのテストを1本書いてみることから始めてみてください。
