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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?