- React
React Testing Library使い方|getBy・findByの違い
React Testing Library(RTL)とは、Reactコンポーネントを「利用者が画面で見て操作するのと同じ手がかり」でテストするためのライブラリです。 renderで描画し、screenのクエリ(getByRoleなど)で要素を探し、userEventで操作し、expectで結果を確かめる、という4つの手順で書きます。
この記事では、Vitest+jsdom+@testing-library/react+user-event v14の環境を作るところから、すべてのコードを実際に動かして出力を掲載しています。getBy・queryBy・findByの違いは「0件・1件・複数件」で実行した結果を表にし、よく出会うエラーは出力をそのまま載せて読み方と直し方を説明します。
確認環境:Ubuntu 24.04/Node.js 22.22.2/react 19.3.0/vitest 5.0.3/jsdom 30.1.1/@testing-library/react 16.3.3/@testing-library/dom 10.4.2/@testing-library/user-event 14.6.7/@testing-library/jest-dom 7.0.1/@vitejs/plugin-react 6.1.1/TypeScript 7.0.2。出力中のフォルダ名は /home/taro/rtl-sample に置き換えています。
React Testing Libraryの早見表
基本の4手順
| 手順 | 使うもの | 例 |
|---|---|---|
| 1. 操作役を用意 | userEvent.setup() |
const user = userEvent.setup() |
| 2. 描画 | render |
render(<Counter />) |
| 3. 探して操作 | screen のクエリ+ user |
await user.click(screen.getByRole('button', { name: '増やす' })) |
| 4. 確かめる | expect + jest-dom のマッチャー |
expect(screen.getByText('現在の値: 1')).toBeInTheDocument() |
getBy・queryBy・findByの戻り値(実行して確認)
| クエリ | 0件 | 1件 | 複数件 | 待つか | 主な用途 |
|---|---|---|---|---|---|
getBy... |
エラー | 要素 | エラー | 待たない | 今あるはずの要素を取る |
queryBy... |
null |
要素 | エラー | 待たない | 「無いこと」を確かめる |
findBy... |
エラー | 要素 | エラー | 待つ(Promise) | あとから現れる要素を待つ |
getAllBy... |
エラー | 配列 | 配列 | 待たない | 複数の要素をまとめて取る |
queryAllBy... |
空の配列 | 配列 | 配列 | 待たない | 件数が0件であることを確かめる |
findAllBy... |
エラー | 配列 | 配列 | 待つ(Promise) | あとから現れる複数の要素を待つ |
迷ったら「あるはず=getBy」「無いはず=queryBy」「あとから出る=findBy」と覚えると使い分けられます。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React Testing Library使い方|getBy・findByの違い」
Vitestとjsdomで環境を作る
導入コマンド
React のプロジェクトのフォルダで、次のコマンドを実行します。
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom @vitejs/plugin-react
それぞれの役割は次のとおりです。
| パッケージ | 役割 |
|---|---|
vitest |
テストを実行する本体(test・expect・vi を提供) |
jsdom |
Node.js の中でブラウザの DOM を再現する |
@testing-library/react |
render・screen・act など React 用の関数 |
@testing-library/dom |
クエリの本体(@testing-library/react が利用する) |
@testing-library/user-event |
クリックや入力など利用者の操作を再現する |
@testing-library/jest-dom |
toBeInTheDocument など DOM 用のマッチャー |
@vitejs/plugin-react |
テストファイル内の JSX を変換する |
設定ファイル3つ
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // ブラウザの代わりに jsdom を使う
globals: true, // describe / test / expect を import なしで使う
setupFiles: ['./src/setupTests.ts'],
},
})
// src/setupTests.ts
// toBeInTheDocument などのマッチャーを expect に追加する
import '@testing-library/jest-dom/vitest'
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
3つ目は tsconfig.json です。types に2つを書いておくと、test や expect、toBeInTheDocument に型が付きます。この設定で npx tsc --noEmit がエラーなく通ることを確かめています。
package.json には "type": "module" と、テスト用のスクリプトを足します。
npm pkg set type=module scripts.test="vitest run"
設定が足りないときに出たエラーも載せておきます。
| 足りない設定 | 実際に出たエラー | 直し方 |
|---|---|---|
setupFiles(jest-dom の読み込み) |
Error: Invalid Chai property: toBeInTheDocument |
setupTests.ts を作り setupFiles に指定する |
environment: 'jsdom' |
TypeError: Cannot read properties of undefined (reading 'Symbol(Node prepared with document state workarounds)') |
environment に jsdom を指定する |
最初のテストを書いて実行する
テスト対象のコンポーネントです。
// src/Counter.tsx
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<section>
<h2>カウンター</h2>
<p>現在の値: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>増やす</button>
</section>
)
}
テストは同じフォルダに .test.tsx で作ります。
// src/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
test('ボタンを押すと値が1増える', async () => {
const user = userEvent.setup() // 1. 操作役を用意する
render(<Counter />) // 2. 描画する
await user.click(screen.getByRole('button', { name: '増やす' })) // 3. 操作する
expect(screen.getByText('現在の値: 1')).toBeInTheDocument() // 4. 確かめる
})
npx vitest run --reporter=verbose src/Counter.test.tsx の出力(テスト結果の行)です。
✓ src/Counter.test.tsx > ボタンを押すと値が1増える 189ms
ここで使った部品を整理します。
| 部品 | 意味 |
|---|---|
render |
コンポーネントを jsdom の document.body の中に描画する |
screen |
描画された画面全体を対象にクエリを呼ぶための入口 |
getByRole('button', { name: '増やす' }) |
役割がボタンで、名前(表示文字)が「増やす」の要素を1つ取る |
userEvent.setup() |
操作役(user)を作る。以降は await user.click(...) のように使う |
toBeInTheDocument() |
要素が画面に存在することを確かめる(jest-dom) |
globals: true にしていると、テストが終わるたびに画面が自動で片付けられるため、テストごとに render し直すだけで独立したテストになります。同じコンポーネントを2つのテストで続けて描画して確かめたところ、globals が true のときは2つ目のテストでボタンは1個、false のときは前のテストの分が残って2個になりました。
フォームをテストする
入力、チェックボックス、送信、エラー表示を持つフォームで、よく使うクエリと操作をひととおり確認します。
// src/SignupForm.tsx
import { useState, type FormEvent } from 'react'
export function SignupForm() {
const [name, setName] = useState('')
const [agree, setAgree] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (name.trim() === '') {
setError('名前を入力してください')
setMessage('')
return
}
setError('')
setMessage(`${name}さんを登録しました`)
}
return (
<form onSubmit={handleSubmit} aria-label="会員登録">
<label htmlFor="name">名前</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="山田 太郎" />
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
規約に同意する
</label>
<button type="submit" disabled={!agree}>登録する</button>
{error && <p role="alert">{error}</p>}
{message && <p role="status">{message}</p>}
</form>
)
}
// src/SignupForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SignupForm } from './SignupForm'
describe('SignupForm', () => {
test('同意するまで登録ボタンは押せない', () => {
render(<SignupForm />)
expect(screen.getByRole('button', { name: '登録する' })).toBeDisabled()
})
test('名前を入力して登録すると完了メッセージが出る', async () => {
const user = userEvent.setup()
render(<SignupForm />)
await user.type(screen.getByLabelText('名前'), '佐藤')
await user.click(screen.getByRole('checkbox', { name: '規約に同意する' }))
await user.click(screen.getByRole('button', { name: '登録する' }))
expect(screen.getByRole('status')).toHaveTextContent('佐藤さんを登録しました')
expect(screen.queryByRole('alert')).not.toBeInTheDocument() // 「無いこと」は queryBy
})
test('名前が空のまま登録するとエラーが出る', async () => {
const user = userEvent.setup()
render(<SignupForm />)
await user.click(screen.getByRole('checkbox', { name: '規約に同意する' }))
await user.click(screen.getByRole('button', { name: '登録する' }))
expect(screen.getByRole('alert')).toHaveTextContent('名前を入力してください')
})
})
✓ src/SignupForm.test.tsx > SignupForm > 同意するまで登録ボタンは押せない 191ms
✓ src/SignupForm.test.tsx > SignupForm > 名前を入力して登録すると完了メッセージが出る 142ms
✓ src/SignupForm.test.tsx > SignupForm > 名前が空のまま登録するとエラーが出る 58ms
「エラー表示が出ていないこと」を確かめる行で queryByRole を使っている点がポイントです。 getByRole は見つからないとその場でエラーを投げるため、「無いこと」の確認には使えません。
よく使う jest-dom のマッチャーです。
| マッチャー | 確かめること |
|---|---|
toBeInTheDocument() |
画面に存在する |
toHaveTextContent('文字') |
指定の文字を含む |
toBeDisabled() / toBeEnabled() |
操作できない/できる |
toBeChecked() |
チェックが入っている |
toHaveValue('値') |
入力欄の値 |
toBeVisible() |
見える状態である |
getBy・queryBy・findByの違いを実行して確かめる
冒頭の早見表を、実際に動かして確認します。「りんご」が1つ、「みかん」が2つ、「ぶどう」は無い、というリストに対して6種類のクエリを呼び、結果を1行ずつ出力します。
// src/queries.test.tsx
import { render, screen } from '@testing-library/react'
function Sample() {
return (
<ul>
<li>りんご</li>
<li>みかん</li>
<li>みかん</li>
</ul>
)
}
// 結果を1行の文字列にする
function show(fn: () => unknown): string {
try {
const r = fn()
if (r === null) return 'null'
if (Array.isArray(r)) return `配列(${r.length}件)`
return '要素1つ'
} catch (e) {
return 'エラー: ' + (e as Error).message.split('\n')[0]
}
}
async function showAsync(fn: () => Promise<unknown>): Promise<string> {
try {
const r = await fn()
return Array.isArray(r) ? `配列(${r.length}件)` : '要素1つ'
} catch (e) {
return 'エラー: ' + (e as Error).message.split('\n')[0]
}
}
test('0件・1件・複数件での戻り値', async () => {
render(<Sample />)
const cases = [['0件', 'ぶどう'], ['1件', 'りんご'], ['複数件', 'みかん']] as const
const opt = { timeout: 100 }
for (const [label, text] of cases) {
console.log(`--- ${label}(${text})`)
console.log('getByText :', show(() => screen.getByText(text)))
console.log('queryByText :', show(() => screen.queryByText(text)))
console.log('findByText :', await showAsync(() => screen.findByText(text, {}, opt)))
console.log('getAllByText :', show(() => screen.getAllByText(text)))
console.log('queryAllByText :', show(() => screen.queryAllByText(text)))
console.log('findAllByText :', await showAsync(() => screen.findAllByText(text, {}, opt)))
}
})
npx vitest run --reporter=verbose src/queries.test.tsx の出力から、console.log の行を抜き出したものです。0件のときのエラー文は4つとも同じなので、2つ目以降は「(同上)」と書いています。
--- 0件(ぶどう)
getByText : エラー: Unable to find an element with the text: ぶどう. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
queryByText : null
findByText : エラー: (同上)
getAllByText : エラー: (同上)
queryAllByText : 配列(0件)
findAllByText : エラー: (同上)
--- 1件(りんご)
getByText : 要素1つ
queryByText : 要素1つ
findByText : 要素1つ
getAllByText : 配列(1件)
queryAllByText : 配列(1件)
findAllByText : 配列(1件)
--- 複数件(みかん)
getByText : エラー: Found multiple elements with the text: みかん
queryByText : エラー: Found multiple elements with the text: みかん
findByText : エラー: Found multiple elements with the text: みかん
getAllByText : 配列(2件)
queryAllByText : 配列(2件)
findAllByText : 配列(2件)
この結果は、Testing Library 公式ドキュメント(About Queries)の一覧表と一致しています。押さえておきたい点は3つです。
- queryBy も、複数件のときはエラーになります。「null を返すから安全」とは限りません。
- All 系は、1件でも配列で返します。件数は
toHaveLength(2)で確かめます。 - 0件で止まらないのは queryBy と queryAllBy だけです。
あとから現れる要素はfindByで待つ
データの読み込み後に表示される要素は、描画直後にはまだありません。findBy は、要素が見つかるまで繰り返し探してくれます。
// src/TaskList.tsx
import { useEffect, useState } from 'react'
type Task = { id: number; title: string }
type Props = { load: () => Promise<Task[]> }
export function TaskList({ load }: Props) {
const [tasks, setTasks] = useState<Task[] | null>(null)
useEffect(() => {
let active = true
load().then((data) => {
if (active) setTasks(data)
})
return () => {
active = false
}
}, [load])
if (tasks === null) return <p>読み込み中…</p>
return (
<ul aria-label="タスク一覧">
{tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)
}
// src/TaskList.test.tsx
import { render, screen } from '@testing-library/react'
import { TaskList } from './TaskList'
const load = () =>
new Promise<{ id: number; title: string }[]>((resolve) =>
setTimeout(
() =>
resolve([
{ id: 1, title: '備品を発注する' },
{ id: 2, title: '議事録を共有する' },
]),
100,
),
)
test('読み込み後にタスクが2件表示される', async () => {
render(<TaskList load={load} />)
expect(screen.getByText('読み込み中…')).toBeInTheDocument()
// あとから現れる要素は findBy で待つ
const items = await screen.findAllByRole('listitem')
expect(items).toHaveLength(2)
expect(screen.queryByText('読み込み中…')).not.toBeInTheDocument()
})
ここまでの4ファイルをまとめて npm test で実行した結果です。
Test Files 4 passed (4)
Tests 6 passed (6)
findBy は Promise を返すので、必ず await を付けます。 いつまでも現れない要素を待った場合は、約1秒(今回の計測で 1,020ms)で次のエラーになりました。先頭が「Unable to find role="list"」で、その時点の画面が続けて表示されます。
FAIL demo/findtimeout.test.tsx > いつまでも現れない要素を findBy で待つ例
TestingLibraryElementError: Unable to find role="list"
Ignored nodes: comments, script, style
<body>
<div>
<p>
読み込み中…
</p>
</div>
</body>
クエリの優先順位はByRoleから
要素の探し方(By〜の部分)は8種類あります。Testing Library 公式ドキュメントは、利用者や支援技術が画面を認識する方法に近いものから順に使うことを勧めており、その順位は次のとおりです。
| 順位 | クエリ | 探す手がかり | 向いている要素 |
|---|---|---|---|
| 1 | getByRole |
役割(button、textbox、heading など)と名前 | ボタン、リンク、入力欄、見出しなどほぼすべて |
| 2 | getByLabelText |
ラベルの文字 | フォームの入力欄 |
| 3 | getByPlaceholderText |
プレースホルダー | ラベルが無い入力欄 |
| 4 | getByText |
表示されている文字 | 段落やメッセージなど操作しない要素 |
| 5 | getByDisplayValue |
入力欄に入っている値 | 値が入力済みのフォーム |
| 6 | getByAltText |
alt の文字 | 画像 |
| 7 | getByTitle |
title 属性 | title しか手がかりが無い要素 |
| 8 | getByTestId |
data-testid 属性 | 上のどれでも特定できない要素 |
公式ドキュメントでは、1〜5を「誰もが利用できる手がかり」、6〜7を「意味に基づく手がかり」、8を「テスト用の ID」と3つのグループに分けています。ByRole を第一候補にすると、テストが通ること自体が「役割と名前が正しく付いている」ことの確認にもなります。
よく使う role と、対応する HTML の対応表です(SignupForm と Counter で確認できたもの)。
| role | 対応する要素 | 名前になるもの |
|---|---|---|
button |
button |
中の文字 |
textbox |
input(文字入力) |
結び付いた label の文字 |
checkbox |
input type="checkbox" |
結び付いた label の文字 |
heading |
h1〜h6 |
中の文字 |
form |
form(aria-label などで名前を付けた場合) |
aria-label の文字 |
list / listitem |
ul・ol / li |
― |
alert / status |
role="alert" / role="status" を付けた要素 |
― |
userEventの使い方とawaitを忘れたとき
setupしてからawaitで操作する
user-event v14 では、テストの最初に userEvent.setup() を呼び、返ってきた user のメソッドを await 付きで呼ぶのが公式ドキュメントで勧められている書き方です。setup は render より前に呼びます。
| 操作 | 書き方 |
|---|---|
| クリック | await user.click(要素) |
| 文字入力 | await user.type(要素, '佐藤') |
| 入力欄を空にする | await user.clear(要素) |
| 選択肢を選ぶ | await user.selectOptions(要素, '値') |
| Tab で移動 | await user.tab() |
| キー入力 | await user.keyboard('{Enter}') |
awaitを付け忘れるとどうなるか
Counter のテストから await を外してみます。
// demo/noawait.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from '../src/Counter'
test('await を付け忘れた例', () => {
const user = userEvent.setup()
render(<Counter />)
user.click(screen.getByRole('button', { name: '増やす' })) // await が無い
expect(screen.getByText('現在の値: 1')).toBeInTheDocument()
})
FAIL demo/noawait.test.tsx > await を付け忘れた例
TestingLibraryElementError: Unable to find an element with the text: 現在の値: 1. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Ignored nodes: comments, script, style
<body>
<div>
<section>
<h2>
カウンター
</h2>
<p>
現在の値:
0
</p>
<button>
増やす
</button>
</section>
</div>
</body>
user.click は Promise を返す非同期の処理なので、await が無いとクリックが終わる前に expect が動きます。 エラーに表示された画面では値が 0 のままです。エラー文は「要素が見つからない」ですが、原因は await の付け忘れ、という点が気付きにくいところです。「操作したのに画面が変わっていない」ときは、まず await とテスト関数の async を確かめてください。
fireEventとの違い
@testing-library/react には fireEvent もあります。入力欄に対して両方を使い、発生したイベントを記録して比べました。
// demo/events.test.tsx
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function Field({ log }: { log: (type: string) => void }) {
return (
<input
aria-label="検索"
onFocus={() => log('focus')}
onKeyDown={() => log('keydown')}
onChange={() => log('change')}
onKeyUp={() => log('keyup')}
/>
)
}
test('fireEvent と userEvent で起きるイベントを比べる', async () => {
const a: string[] = []
const { unmount } = render(<Field log={(t) => a.push(t)} />)
fireEvent.change(screen.getByLabelText('検索'), { target: { value: 'ab' } })
console.log('fireEvent.change:', a.join(' → '))
unmount()
const b: string[] = []
const user = userEvent.setup()
render(<Field log={(t) => b.push(t)} />)
await user.type(screen.getByLabelText('検索'), 'ab')
console.log('user.type :', b.join(' → '))
})
fireEvent.change: change
user.type : focus → keydown → change → keyup → keydown → change → keyup
fireEvent は指定したイベントを1つだけ発生させ、userEvent はフォーカスから1文字ずつのキー操作まで、利用者の操作に沿って一連のイベントを発生させます。 公式ドキュメントも「fireEvent は DOM のイベントを送り、user-event は操作全体を再現する」と説明しています。
| 項目 | userEvent | fireEvent |
|---|---|---|
| 再現するもの | 利用者の操作全体 | イベント1つ |
| 呼び方 | await user.type(...)(非同期) |
fireEvent.change(...)(同期) |
| 向いている場面 | クリック、入力などふだんのテスト | 特定のイベントを1つだけ直接起こしたいとき |
よく出会うエラーと警告の読み方
Unable to find an element が出たとき
ボタン名を間違えた例です。
// demo/notfound.test.tsx
import { render, screen } from '@testing-library/react'
import { Counter } from '../src/Counter'
test('ボタン名を間違えた例', () => {
render(<Counter />)
screen.getByRole('button', { name: '追加' }) // 実際のボタン名は「増やす」
})
FAIL demo/notfound.test.tsx > ボタン名を間違えた例
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "追加"
Here are the accessible roles:
heading:
Name "カウンター":
<h2 />
--------------------------------------------------
paragraph:
Name "":
<p />
--------------------------------------------------
button:
Name "増やす":
<button />
--------------------------------------------------
Ignored nodes: comments, script, style
<body>
<div>
<section>
<h2>
カウンター
</h2>
<p>
現在の値:
0
</p>
<button>
増やす
</button>
</section>
</div>
</body>
❯ demo/notfound.test.tsx:6:10
4| test('ボタン名を間違えた例', () => {
5| render(<Counter />)
6| screen.getByRole('button', { name: '追加' }) // 実際のボタン名は「増やす」
| ^
7| })
(node_modules 内のスタックトレース4行は省いています。)このエラーは情報が多く、上から順に読むと原因がわかります。
| 出力の部分 | 読み取れること |
|---|---|
| 1行目 | 探した条件(role が button、名前が「追加」) |
| Here are the accessible roles | 画面にある role と名前の一覧。button の名前は「増やす」だとわかる |
| body 以下 | その時点の画面の HTML |
| 最後のコード | テストのどの行で起きたか |
原因ごとの直し方です。
| 原因 | 直し方 |
|---|---|
| 名前や文字の打ち間違い | 一覧に出ている名前に合わせる |
| まだ表示されていない(読み込み中など) | await screen.findBy... に替える |
| 操作の await を付け忘れている | await user.click(...) にする |
| 文字が一部しか一致しない | 正規表現(/登録/)や { exact: false } を使う |
Found multiple elements が出たとき
// demo/multiple.test.tsx
import { render, screen } from '@testing-library/react'
function Menu() {
return (
<nav>
<button>削除</button>
<button>削除</button>
</nav>
)
}
test('同じ名前のボタンが2つある例', () => {
render(<Menu />)
screen.getByRole('button', { name: '削除' })
})
FAIL demo/multiple.test.tsx > 同じ名前のボタンが2つある例
TestingLibraryElementError: Found multiple elements with the role "button" and name "削除"
Here are the matching elements:
Ignored nodes: comments, script, style
<button>
削除
</button>
Ignored nodes: comments, script, style
<button>
削除
</button>
(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)).
このあとに画面全体の HTML が続きます。直し方は3つあります。
| 直し方 | 書き方 |
|---|---|
| 複数あるのが正しいなら All 系に替える | screen.getAllByRole('button', { name: '削除' })[0] |
| 範囲を絞って探す | within(screen.getByRole('listitem', { name: '…' })).getByRole('button') |
| ボタンに区別できる名前を付ける | aria-label="備品を削除" のように対象を含める |
3つ目は、画面を読み上げて使う人にとっても区別しやすくなるので、テストと使いやすさの両方に効きます。
actの警告が出たとき
1秒ごとに表示が変わるコンポーネントで、時間を進める処理を act で囲まずに書いた例です。
// demo/act.test.tsx
import { useEffect, useState } from 'react'
import { render, screen } from '@testing-library/react'
function Clock() {
const [sec, setSec] = useState(0)
useEffect(() => {
const id = setInterval(() => setSec((s) => s + 1), 1000)
return () => clearInterval(id)
}, [])
return <p>{sec}秒</p>
}
afterEach(() => {
vi.useRealTimers()
})
test('act で囲まずに時間を進める例', () => {
vi.useFakeTimers()
render(<Clock />)
vi.advanceTimersByTime(1000) // React の外から state が更新される
expect(screen.getByText('1秒')).toBeInTheDocument()
})
stderr | demo/act.test.tsx > act で囲まずに時間を進める例
An update to Clock inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act
FAIL demo/act.test.tsx > act で囲まずに時間を進める例
TestingLibraryElementError: Unable to find an element with the text: 1秒. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
警告のあと、画面が「0秒」のままなので getByText も見つからずに止まっています。act は「state の更新と画面への反映が終わるまでをひとまとまりにする」ための関数です。 render、userEvent、findBy は内部で act を使っているため、ふだんは自分で書く必要がありません。今回のように、テストコードが React の外から直接 state を変えるきっかけを作るときだけ、自分で囲みます。
// demo/act_fixed.test.tsx
import { useEffect, useState } from 'react'
import { act, render, screen } from '@testing-library/react'
function Clock() {
const [sec, setSec] = useState(0)
useEffect(() => {
const id = setInterval(() => setSec((s) => s + 1), 1000)
return () => clearInterval(id)
}, [])
return <p>{sec}秒</p>
}
afterEach(() => {
vi.useRealTimers()
})
test('act で囲んで時間を進める', () => {
vi.useFakeTimers()
render(<Clock />)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(screen.getByText('1秒')).toBeInTheDocument()
})
Test Files 1 passed (1)
Tests 1 passed (1)
警告文の1行目にコンポーネント名(ここでは Clock)が出るので、そのコンポーネントの state を、テストのどの行が React の外から動かしているかを探すと原因にたどり着けます。
screen.debugとlogRolesで画面を確かめる
クエリが思ったとおりに当たらないときは、画面の中身を出力して確かめるのが近道です。
// demo/debug.test.tsx
import { logRoles, render, screen } from '@testing-library/react'
import { SignupForm } from '../src/SignupForm'
test('screen.debug と logRoles', () => {
const { container } = render(<SignupForm />)
screen.debug() // 画面全体の HTML を表示
screen.debug(screen.getByRole('button')) // 要素を渡すとその部分だけ
logRoles(container) // 使える role と名前の一覧
})
1つ目の screen.debug() の出力です。
<body>
<div>
<form
aria-label="会員登録"
>
<label
for="name"
>
名前
</label>
<input
id="name"
placeholder="山田 太郎"
value=""
/>
<label>
<input
type="checkbox"
/>
規約に同意する
</label>
<button
disabled=""
type="submit"
>
登録する
</button>
</form>
</div>
</body>
2つ目(要素を渡した場合)は、その要素だけが表示されます。
<button
disabled=""
type="submit"
>
登録する
</button>
3つ目の logRoles(container) の出力です。
form:
Name "会員登録":
<form
aria-label="会員登録"
/>
--------------------------------------------------
textbox:
Name "名前":
<input
id="name"
placeholder="山田 太郎"
value=""
/>
--------------------------------------------------
checkbox:
Name "規約に同意する":
<input
type="checkbox"
/>
--------------------------------------------------
button:
Name "登録する":
<button
disabled=""
type="submit"
/>
--------------------------------------------------
logRoles の出力は、そのまま getByRole の引数になります。 「textbox / Name "名前"」とあれば getByRole('textbox', { name: '名前' }) で取れます。
1点、知っておくと便利なことがあります。今回の環境(Vitest 5.0.3)では、成功したテストの console 出力は既定の表示には出ず、--reporter=verbose を付けると表示されました。debug の出力が見えないときは、次のように実行してください。
npx vitest run --reporter=verbose demo/debug.test.tsx
| 道具 | 出力 | 使う場面 |
|---|---|---|
screen.debug() |
画面全体の HTML | 今どんな画面になっているか見たい |
screen.debug(要素) |
その要素の HTML | 一部分だけ見たい |
logRoles(container) |
role と名前の一覧 | getByRole に何を書けばよいか知りたい |
よくある質問
Q. getByRole で要素が取れないときは、すぐ getByTestId を使ってよいですか? A. まず logRoles やエラー文の「Here are the accessible roles」で、role と名前を確かめるのがおすすめです。名前が空の場合は、label や aria-label を付けると getByRole で取れるようになります。getByTestId は、文字も役割も手がかりにできない要素のための最後の手段という位置づけです。
Q. findBy は何秒待ちますか?
A. 今回の実行では、現れない要素を待って約1秒(1,020ms)でエラーになりました。待ち時間は3つ目の引数で変えられ、screen.findByText('無い文字', {}, { timeout: 300 }) としたときは 303ms でエラーになりました。
Q. waitFor と findBy はどう使い分けますか?
A. 「要素が現れるのを待つ」なら findBy が短く書けます。「関数が呼ばれた」「要素が消えた」など、要素の取得以外の条件を待つときは await waitFor(() => expect(...)) を使います。
Q. render の戻り値と screen はどちらを使いますか? A. クエリは screen から呼ぶ書き方にそろえると、テストごとに変数を受け取らずに済みます。render の戻り値は、logRoles に渡す container や、unmount を使いたいときに受け取ります。
まとめ
- テストは setup → render → 探して操作 → expect の4手順で書く
- getBy は「あるはず」、queryBy は「無いはず」、findBy は「あとから出る」要素に使う
- queryBy も複数件ではエラーになる。複数あるなら All 系か within で絞る
- クエリは ByRole を第一候補にし、ByTestId は最後に回す
- userEvent は setup して await。await が無いと、操作が終わる前に expect が動く
- Unable to find an element は、エラー文中の role 一覧と画面の HTML を読むと原因がわかる
- 迷ったら screen.debug と logRoles で画面を出力する(Vitest では
--reporter=verbose)
参考・出典
- Testing Library 公式「About Queries」 https://testing-library.com/docs/queries/about
- Testing Library 公式「user-event Introduction」 https://testing-library.com/docs/user-event/intro
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React Testing Library使い方|getBy・findByの違い」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。