React Testing Libraryのuser-eventとクエリ実践|getByRole・findBy・fireEventの使い分け
React Testing Library(RTL)でテストが落ちる原因の多くは、ライブラリの思想ではなくuser-eventの呼び出し方とクエリの選び方に集約されます。user-event は v14 で setup() と await が前提になり、v13 までの書き方をそのまま持ち込むと非同期処理が噛み合わずに失敗するのです。クエリ側も、getByRole('textbox') が拾える要素と拾えない要素の境目を知らないまま使うと、実装は正しいのにテストだけが落ちます。
この記事では user-event の API とクエリメソッドの選び分けだけを、公式ドキュメントと npm の実バージョンに当たって整理します。RTLの設計思想・インストール・ベストプラクティス全般はReact Testing Libraryとは?使い方・クエリ・ベストプラクティスを実務目線で解説にまとめてあります。
まとめ
user-event は v14 系で書き方が変わりました。const user = userEvent.setup() を各テストの先頭に置き、すべての操作に await を付けます。直接呼び出しは v14 でも動きますが、公式ドキュメントは v13 からの移行を楽にするための互換手段だと明記しています。
クエリは getByRole が第一候補です。ただしロールは見た目ではなく W3C の対応表で決まり、input type="search" は searchbox、list 属性が付けば combobox、input type="password" にはロールがありません。ロールで取れない入力欄は getByLabelText の担当です。非同期は findBy で待ち、要素が「無いこと」を確かめるときだけ queryBy を使います。
jest-dom は 2026年7月20日に 7.0.0 が出て @testing-library/dom が必須の peer dependency(>=10 <11)になり、Node.js 22 未満が切られました。toBeInTheDocument が動かないときは、まずこの2点を疑ってください。
user-event v14のセットアップ|setup()とawaitが前提になった書き方
user-event は RTL 本体(@testing-library/react)とは別パッケージです。npm レジストリで確認した最新は 14.6.1(2026年8月時点)、peer dependency は @testing-library/dom >=7.21.4 です。RTL 16.3.2 は @testing-library/dom ^10.0.0 を peer に持つため、RTL と併用する限りこの条件は自然に満たされます。
npm install --save-dev @testing-library/user-event
setup()を先に呼ぶ理由|キーボードとポインタの状態保持
v14 では userEvent.setup() が返すインスタンス経由で操作します。setup したインスタンスはキーボードやポインタの状態(押しっぱなしの修飾キー、クリップボードの内容)をテスト内で保持するため、複数の操作をまたぐシナリオが正しく再現されます。
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
test('メールとパスワードを入力して送信できる', async () => {
const user = userEvent.setup()
render(<LoginForm />)
await user.type(screen.getByLabelText('メールアドレス'), '[email protected]')
await user.type(screen.getByLabelText('パスワード'), 'secret')
await user.click(screen.getByRole('button', { name: '送信' }))
expect(await screen.findByText('ログインしました')).toBeInTheDocument()
})
v13スタイルからの移行手順|置換すべき3点
公式ドキュメントは userEvent.click() のような直接呼び出しについて「v14 でも引き続きサポートされるが、これは v13 から v14 への移行を楽にするためと、単純なテストのための選択肢である」と述べています。動くには動きますが、v14 の API はすべて Promise を返すため、await を落とすと失敗の原因が追えなくなります。
移行作業そのものは機械的です。テスト冒頭に const user = userEvent.setup() を1行足す。userEvent.xxx( を await user.xxx( に置換する。テスト関数を async にする。この3点で v13 スタイルは解消できます。
setup()の主なオプション|フェイクタイマー併用時の必須設定
フェイクタイマーとの併用は事故が起きやすい箇所です。Jest の jest.useFakeTimers() や Vitest の vi.useFakeTimers() を有効にしたまま user-event を動かすと、内部の待機がタイマーに食われて進まなくなります。公式ドキュメントは advanceTimers について「フェイクタイマーを使う場合、このオプションにテストランナーの時間を進める関数を設定する必要がある」と明記しています。
| オプション | 既定値 | 用途 |
|---|---|---|
| delay | 0 | キー入力ごとの待ち時間(ms) |
| advanceTimers | () => Promise.resolve() | フェイクタイマー使用時に時間を進める関数 |
| pointerEventsCheck | PointerEventsCheckLevel.EachApiCall | pointer-events: none の検査頻度 |
| skipHover | false | click前のhover動作を省く |
| applyAccept | true | upload時にaccept属性でファイルを絞る |
| writeToClipboard | 直接呼び出しはfalse/setup経由はtrue | コピー操作をクリップボードへ反映 |
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
ここで踏んではいけない回避策があります。公式ドキュメントは「フェイクタイマー使用時のテストのタイムアウトを防ぐために delay: null を設定するという提案を見かけるかもしれないが、予期しない挙動を引き起こす可能性があるため推奨されない」と警告しています。タイムアウトが出たら delay を潰すのではなく、advanceTimers を正しく渡してください。
pointerEventsCheckの調整|遅いテストの切り分け
pointerEventsCheck は、ポインタ API が対象要素の pointer-events: none を継承していないかを検査する設定です。深くネストしたツリーでは検査コストが無視できないと公式に注意書きがあり、テストが遅いときは Never/EachTarget/EachApiCall/EachTrigger の4段階で下げられます。
検査対象はあくまで CSS の pointer-events: none であり、要素同士の重なりではありません。jsdom はレイアウトを持たないため「モーダルの下に隠れている」ことは原理的に検知されず、クリックはそのまま成功します。この検査に引っかかるのは、モーダルライブラリが背景要素に pointer-events: none を当てている構成だけです。
fireEventとuser-eventの使い分け|1回のクリックで起きることの差
迷ったら user-event を既定にします。理由は再現するイベント数の差です。fireEvent.click(el) は click イベントを1つ発火するだけですが、user-event の click() は内部的に pointer([{target: element}, {keys: '[MouseLeft]', target: element}]) と等価で、ポインタの移動から左ボタンの押下までを順に再現します(最初の hover は skipHover で省略可能)。
この差が表面化するのは、hover でメニューが開く、focus で検証メッセージが出る、pointerdown でドラッグ開始を判定するといった実装です。fireEvent で書いたテストはこれらを素通りするため、ブラウザでは壊れているのにテストは緑という状態を作ります。
fireEventを選ぶ場面|ユーザー操作で起こせないイベント
fireEvent が適するのは、人間の操作としては自然に起こせないイベントを直接投げたいときです。window の resize、scroll、animationend、あるいはサードパーティ製ウィジェットが独自に発火するカスタムイベントが該当します。
import { fireEvent, screen } from '@testing-library/react'
fireEvent(window, new Event('resize'))
fireEvent.animationEnd(screen.getByTestId('toast'))
逆に、クリック・入力・選択・タブ移動といったマウスとキーボードで行う操作を fireEvent で書くのは避けてください。速度を理由に fireEvent へ寄せると、上に挙げた hover・focus・pointerdown 起因の不具合をすべて取りこぼします。
クエリの選び分け|getByRoleを第一候補にする実務基準
公式ドキュメントの優先順位は、getByRole → getByLabelText → getByPlaceholderText → getByText → getByDisplayValue → getByAltText → getByTitle → getByTestId の順です。上位ほど「支援技術を含む実際の利用者が要素を見つける方法」に近く、getByTestId はロールでもテキストでも一致させられない場合の最後の手段と位置づけられています。
input type別のロール対応表|textboxで取れない要素
getByRole('textbox') で入力欄が見つからない原因の多くは、この対応表にあります。ロールは W3C の「ARIA in HTML」仕様で要素と属性の組み合わせごとに決まっており、見た目が同じテキスト入力でも type と list 属性で別のロールになります。
| 要素 | 暗黙のロール |
|---|---|
| input type=”text” / “email” / “tel” / “url”(list属性なし) | textbox |
| input type=”search”(list属性なし) | searchbox |
| input type=”text” / “search” / “tel” / “url” / “email”(list属性あり) | combobox |
| input type=”password” | 対応するロールなし |
| textarea | textbox |
実務上のインパクトが大きいのは下2行です。パスワード入力欄には暗黙のロールが割り当てられないため、getByRole ではどう書いても取得できません。ここは getByLabelText を使う一手です。オートコンプリート候補を出すために list 属性を付けた検索欄も、searchbox ではなく combobox になる点に注意してください。
screen.getByLabelText('パスワード')
screen.getByRole('searchbox', { name: 'サイト内検索' })
screen.getByRole('combobox', { name: 'キーワード' })
nameオプションの絞り込み|文字列は完全一致・正規表現は部分一致
同じロールの要素が複数あると getByRole はエラーになります。name オプションにはアクセシブルな名前(ラベル、aria-label、ボタンの表示テキスト)を渡します。文字列を渡すと完全一致、正規表現を渡すと部分一致になるため、末尾に空白や記号が入りうる表示テキストには正規表現が安全です。
screen.getByRole('button', { name: '保存' })
screen.getByRole('button', { name: /保存/i })
screen.getByRole('heading', { name: '請求先', level: 2 })
within()によるスコープ限定|getQueriesForElementのエイリアス
一覧画面のように「削除」ボタンが行数ぶん並ぶ画面では、name だけでは絞り込めません。within() は公式ドキュメントで getQueriesForElement のエイリアスと説明されており、渡した DOM 要素にクエリを束縛して、その配下だけを検索対象にします。Storybook の play 関数で within(canvasElement) を canvas という名前で受けてから canvas.getByRole('textbox') と書くのも同じ仕組みです。
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('該当行の削除ボタンだけを押す', async () => {
const user = userEvent.setup()
const row = screen.getByRole('row', { name: /田中太郎/ })
await user.click(within(row).getByRole('button', { name: '削除' }))
})
getBy・queryBy・findByの使い分け|同期と非同期の境目
接頭辞は3種類あり、「見つからなかったとき」の挙動で選びます。取り違えると、待つべき場面で待たずに落ちたり、無いことを確かめたい場面で例外になったりします。
| 接頭辞 | 0件 | 1件 | 複数件 | 非同期 |
|---|---|---|---|---|
| getBy… | 例外 | 要素を返す | 例外 | いいえ |
| queryBy… | nullを返す | 要素を返す | 例外 | いいえ |
| findBy… | 例外 | 要素を返す | 例外 | はい |
| getAllBy… | 例外 | 配列を返す | 配列を返す | いいえ |
| queryAllBy… | 空配列を返す | 配列を返す | 配列を返す | いいえ |
| findAllBy… | 例外 | 配列を返す | 配列を返す | はい |
findByの待ち方|既定タイムアウトは1000ms
findBy 系は getBy と waitFor を組み合わせたもので、公式ドキュメントの既定タイムアウトは 1000ms です。API 応答を待つ表示の検証はこれで足ります。
expect(await screen.findByText('保存しました')).toBeInTheDocument()
expect(await screen.findByRole('alert')).toHaveTextContent('通信に失敗しました')
1000ms で足りない処理を待つ場合は、タイムアウトを延ばす前に何を待っているかを見直してください。ネットワークを直接叩いているならモックへ置き換えるのが先です(モックとは?スタブとの違い・テストダブル5分類と単体テストでの使い分けを実装者向けに解説で分類を整理しています)。それでも延ばす必要があれば第3引数のオプションで指定します。
await screen.findByText('集計完了', {}, { timeout: 3000 })
「表示されないこと」の検証|queryByと消滅待ち
要素が存在しないことを確かめたいときに getBy を使うと、0件で例外になるためテスト自体が落ちます。queryBy は null を返すので、こちらを使います。
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
消えるまで待つ場合は waitForElementToBeRemoved を使います。findBy には「無くなるまで待つ」用途がないため、ここだけは別APIになる点に注意してください。
user-eventの主要API|入力・選択・ファイル操作
クリックと入力以外の操作は、専用APIを知っているかどうかで書きやすさが変わります。この章で扱うAPIはいずれも Promise<void> を返すので await が必要です。
typeとkeyboardの書き分け|特殊キーの記法
type() は対象要素に文字列を打ち込む操作、keyboard() はフォーカス中の要素にキー入力そのものを送る操作です。印字可能な文字はそのまま並べ、特殊キーは KeyboardEvent.key なら波括弧、KeyboardEvent.code なら角括弧で囲みます。
await user.type(screen.getByRole('textbox'), 'issoh')
await user.keyboard('{Shift>}A{/Shift}')
await user.keyboard('[ShiftLeft][KeyF][KeyO][KeyO]')
1行目の {Shift>}A{/Shift} は Shift の押下、A の入力、Shift の解放という順序に展開されます。> が押しっぱなし、/ が解放を意味する記法です。一方、2行目の [ShiftLeft] は押下と解放が即座に完結するため、入力結果は大文字ではなく foo になります。大文字にしたいなら [ShiftLeft>]foo[/ShiftLeft] と押しっぱなしの記法を使ってください。開き括弧そのものを入力したい場合は {{ や [[ のように二重にします。
clear・selectOptions・uploadの制約|reject条件とmultiple・accept
clear() は要素にフォーカスして全選択・削除する操作です。公式ドキュメントには、フォーカスできない要素や内容を選択できない要素では Promise が reject されると書かれています。読み取り専用の入力欄に対して呼ぶと失敗するのはこの仕様によるものです。
const file = new File(['dummy'], 'receipt.png', { type: 'image/png' })
await user.clear(screen.getByLabelText('備考'))
await user.selectOptions(screen.getByRole('combobox', { name: '都道府県' }), '東京都')
await user.upload(screen.getByLabelText('添付ファイル'), file)
selectOptions() は値・表示テキスト・要素そのもののいずれでも指定できますが、複数選択と選択解除は select 要素に multiple が付いている場合のみ可能です。upload() は accept 属性に一致しないファイルを自動的に破棄します。この挙動は applyAccept: false で無効化できるため、「拒否されるファイルを渡したときのエラー表示」を検証したい場合は setup 時に切ってください。
tab()によるフォーカス順の検証|shift:trueで逆方向
tab({ shift: true }) で逆方向のフォーカス移動を再現できます。フォーカストラップやフォームの入力順は、実装ミスが多いわりにクリック中心のテストでは検出できない領域です。Testing Trophy(テスティングトロフィー)とは?テストピラミッドとの違いと理想のテスト配分を解説が示す配分でいえば、この種の検証こそコンポーネントテストで拾うべき部分にあたります。
await user.tab()
await user.tab({ shift: true })
expect(screen.getByLabelText('メールアドレス')).toHaveFocus()
jest-dom 7.0.0の破壊的変更|toBeInTheDocumentが動かないときの確認点
toBeInTheDocument や toHaveFocus は RTL ではなく @testing-library/jest-dom が提供するマッチャです。このパッケージは 2026年7月20日に 7.0.0 が公開されました。
7.0.0で壊れる構成|dom 9系とNode 21以下
リリースノートに記載された破壊的変更は次の2点です。
@testing-library/domが必須の peer dependency になった(実際の許容範囲は>=10 <11)- サポートする Node.js の最小バージョンが 22 になった
あわせて toContainAnyBy* と toContainOneBy* のクエリ系マッチャが追加されています。実害が出るのは、@testing-library/dom が 9系以下で止まっている構成(RTL 15系から据え置きのプロジェクト)と、CI の Node.js が 21 以下の構成です。RTL 16.3.2 は @testing-library/dom ^10.0.0 を peer に持つため、16 系に上げてあれば範囲は自動的に満たされます。pnpm などで peer の自動解決を切っている場合だけ、明示的な devDependency の追加が要ります。
マッチャの読み込み|エントリポイントの選択
読み込み方法には古い情報が残りがちです。@testing-library/jest-dom/extend-expect というエントリポイントは 6.0.0 で削除されました。公式Readmeには元から記載が無く、解説記事を経由して広まった書き方だったため、いまだにこの記述を残したプロジェクトが見つかります。現在はパッケージ本体、または実行環境に応じた /vitest・/jest-globals・/matchers を読み込みます。
// setupTests.ts
import '@testing-library/jest-dom'
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/setupTests.ts'],
}
この設定を動かすには、Jest 28 以降で別パッケージになった jest-environment-jsdom の追加と、TypeScript の setup ファイルを読むための transform 設定が必要です。Jest 側の環境構築はハブ記事のインストール章、Vitest を使う場合はVitestの使い方|React Testing Libraryでコンポーネントテストを書く実践ガイド【2026年版】に手順があります。依存を上げる際はテストカバレッジとは?C0/C1/C2の網羅率と計測ツール・目標設定を実装者向けに解説で扱う計測設定も同時に確認しておくと手戻りが減ります。
よくある質問(FAQ)
@testing-library/user-event はどのバージョンを入れればよいですか?
2026年8月時点の npm 最新は 14.6.1 です。peer dependency は @testing-library/dom >=7.21.4 で、RTL 16.3.2 と併用する構成なら追加の調整は不要です。13系が残っている場合は、setup() の導入と await の付与だけで14系へ移行できます。
userEvent.click() に await を付けないと何が起きますか?
v14 の API はすべて Promise を返すため、await がないと操作の完了を待たずに次の行へ進みます。クリックで発生する再レンダリングや状態更新が終わる前にアサーションが評価され、要素が見つからないという実装とは無関係な失敗になります。エラーメッセージが原因を指さないので、user-event を使うテストは関数を async にすることを既定にしてください。
getByRole('textbox') で入力欄が見つからないのはなぜですか?
暗黙のロールが textbox ではない可能性が高いです。W3C の「ARIA in HTML」では input type="search"(list 属性なし)は searchbox、list 属性が付いた text・search・tel・url・email は combobox になり、input type="password" には対応するロールがありません。type と list 属性を確認し、ロールが割り当たらない要素は getByLabelText で取得します。
findByText と waitFor はどう使い分けますか?
要素の出現を待つだけなら findByText を使います。waitFor は「要素以外の条件(モック関数が呼ばれた、URLが変わった)を待つ」場合に限って使い、1つの waitFor に複数のアサーションを詰め込まないでください。どれが失敗したのか特定できなくなります。
screen.getByLabelText でラベルが見つからないと言われるのはなぜですか?
ラベルと入力欄の関連付けが成立していないケースがほとんどです。label の for 属性と入力欄の id が一致しているか、label が入力欄を入れ子にしているかを確認してください。この2つ以外に aria-label、aria-labelledby、title 属性からも名前は取得できます。テストを直す前にマークアップ側を疑うのが近道です。