テスト講座 #3 React Testing Library — ユーザーのように見る

#2 が純粋関数 1 つ分を扱ったとすれば、今回の記事は コンポーネント を扱います。React Testing Library(以下 RTL)が登場します。

テスト講座 におけるこの記事の位置づけ:

今回の記事では RTL の哲学と、もっともよく使う queries だけを扱います。ユーザーイベントやフォームは #5 の出番なので、ここではクリックを 1 〜 2 回触れる程度にとどめます。

RTL の哲学 — 「ユーザーのように見る」 #

#1 で押さえた肌触りをもう一度。RTL の核となるスローガンはこれです。

The more your tests resemble the way your software is used, the more confidence they can give you.

訳すと 「テストが実際の使われ方に近いほど、それが与える確信も大きい。」 この一文からすべての判断が流れ出します。

  • コンポーネント内部の state を覗きません。→ ユーザーには見えないからです。
  • DOM の id/className で要素を探しません。→ ユーザーはそれを知らないからです。
  • 代わりに role / label / 画面に見えるテキスト で要素を探す。→ ユーザーが画面を認識する方法だから。

これが自然と アクセシビリティ(a11y) に優しいコードを生み出します。スクリーンリーダーが見る方法と RTL が見る方法は同じだからです。

セットアップ — 一度だけ #

#2 で作った vitest + react プロジェクトの上に、追加パッケージをインストールします。

インストール
pnpm add -D @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event

それぞれの位置:

  • @testing-library/reactrender、queries、cleanup など。
  • @testing-library/dom — 上記の依存(自動インストールされますが明示)。
  • @testing-library/jest-domtoBeInTheDocumenttoBeVisible のような DOM フレンドリな matcher たち。
  • @testing-library/user-eventuserEvent.click()userEvent.type() のようなユーザーイベントシミュレーション。(#5 の本格的なテーマです。)

vitest.setup.ts に jest-dom を有効化:

vitest.setup.ts
import '@testing-library/jest-dom/vitest';

この一行だけで、すべてのテストで toBeInTheDocument() のような matcher が自動的に追加されます。

vitest.config.tsenvironment: 'jsdom' がオンになっているか一度確認してください。コンポーネントテストには jsdom が必須です。

最初のコンポーネントテスト — Greeting #

もっともシンプルなコンポーネント。

src/components/Greeting.tsx
type Props = { name?: string };

export function Greeting({ name = 'World' }: Props) {
  return <h1>Hello, {name}!</h1>;
}

テスト:

src/components/Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

describe('Greeting', () => {
  it('名前を渡すとその名前を表示する', () => {
    render(<Greeting name="Alice" />);
    expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
  });

  it('名前を渡さないと World を表示する', () => {
    render(<Greeting />);
    expect(screen.getByRole('heading')).toHaveTextContent('Hello, World!');
  });
});

3 つの新しい仲間が登場しました。

  • render — コンポーネントを jsdom 内の DOM にマウント。
  • screen — マウントされた DOM 上で queries を呼び出すオブジェクト。すべての queries は screen. から始まる という慣習(以前は render の戻り値から取り出していましたが、screen のほうがすっきりします)。
  • getByRole('heading') — 画面で「役割が heading の要素」を探す。<h1><h6> が自動的にその role を持ちます。

toHaveTextContent は jest-dom の matcher。テキストノードの内容を確認します。

render 後に別途 cleanup は要りません。RTL が vitest の afterEach に cleanup を自動登録します。

Queries — どれから使うか #

RTL には数十個の queries があります。覚える必要はなく、優先順位ガイド があるのでそれに従えば済みます。

Testing Library の優先順位
1. みんなに見える要素 (アクセシビリティツリー)
   1.1  getByRole          ← ほぼ常に最初
   1.2  getByLabelText      ← フォーム input
   1.3  getByPlaceholderText
   1.4  getByText
   1.5  getByDisplayValue

2. semantic queries
   2.1  getByAltText        ← img
   2.2  getByTitle           ← title 属性

3. test-id (最後の手段)
   3.1  getByTestId         ← data-testid 属性

上から試して、ダメなら下へ。ほぼすべてのケースが 1.1 〜 1.5 の中で解決します。

getByRole — 真っ先に #

WAI-ARIA の role で要素を探します。HTML 要素は暗黙の role を持っています。

暗黙の role
<button>保存</button>           // role="button"
<a href="...">ホーム</a>         // role="link"
<input type="checkbox" />       // role="checkbox"
<h1>タイトル</h1>                // role="heading"
<nav>...</nav>                  // role="navigation"
<main>...</main>                // role="main"

探す:

getByRole の例
screen.getByRole('button', { name: '保存' });       // テキストが '保存' のボタン
screen.getByRole('link', { name: 'ホーム' });
screen.getByRole('checkbox', { name: '同意' });
screen.getByRole('heading', { level: 1 });          // h1 のみ
screen.getByRole('textbox', { name: 'メール' });    // input[type="text"|"email"]

{ name: '...' } の意味は微妙です。role が button の要素が複数あるとき「accessible name が ‘保存’ のもの」に絞り込みます。accessible name は:

  • ボタン/リンクなら、その中のテキスト。
  • input なら、紐づく <label> のテキスト、または aria-label
  • 画像なら、alt

これがそのまま スクリーンリーダーが読む名前 と同じです。アクセシビリティに優しいコードを書けば、自然とテストも書きやすくなります。

getByLabelText — フォーム input #

フォーム input はほぼ常にこれで探します。

label + input
<label htmlFor="email">メール</label>
<input id="email" type="email" />

// または

<label>
  メール
  <input type="email" />
</label>
探す
screen.getByLabelText('メール');

getByRole('textbox', { name: 'メール' }) と同じ結果になりますが、getByLabelText のほうが意図がはっきりします。フォームテストの最初の手段。

getByText — ただのテキスト #

画面に見えるテキストで探したいとき。

getByText
screen.getByText('ようこそ');                        // 完全一致
screen.getByText(/ようこ/);                           // 正規表現
screen.getByText((content) => content.startsWith('ようこ')); // 関数

ただし、getByTextコンテナ 要素(<div>)も拾うことがあります。ニュアンスがやや微妙で — <div>ようこそ</div> なら div が拾われ、<div>よう<strong>こそ</strong></div> だとテキストが 2 つの子に分かれていて getByText('ようこそ') が拾えません。

data-testid — 本当に最後の手段 #

上記の queries でどうしても拾えないときだけ、data-testid="..." 属性を追加して getByTestId で探します。

testid
<div data-testid="cart-total">$42.50</div>
探す
screen.getByTestId('cart-total');

testid はユーザーには見えない属性です — RTL の哲学とちょうど反対方向。だからこそ最後の手段。テストの 50% が testid なら、コンポーネントのアクセシビリティそのものを疑うべきです。

getBy / queryBy / findBy — 3 つの位置 #

同じ query が 3 つの prefix に分かれます。最初に混乱する部分です。

見つからないとき複数見つかったとき非同期
getBy*エラー throwエラー throw同期
queryBy*null を返すエラー throw同期
findBy*1 秒待ったあと throwエラー throwPromise (async)

複数形(*All*)もあります — getAllByRolefindAllByText など。

それぞれの位置:

getBy — あるべきもの
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument();
// 見つからなければ即エラー — 意図が明確
queryBy — ないべきもの
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// getByRole を使うと見つからないときにエラーになり、検証自体が走らない
findBy — しばらく経ってから現れるもの
await screen.findByRole('alert');
// 非同期で現れるトーストやエラーメッセージなど

よくあるミス:

  • 「ボタンが画面にないこと」を検証するときに getByRole を使う場合 — ボタンがないとエラーで即失敗。queryBy* を使うべき。
  • 非同期な結果を同期 query で探す場合 — 「探そうとした時点ではまだレンダーされていない」。findBy* または waitFor (#4)。

クリック 1 回 — カウンター #

2 つ目のコンポーネント。

src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p aria-live="polite">現在のカウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(0)}>reset</button>
    </div>
  );
}

テスト:

src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('+1 ボタンを押すとカウントが 1 増える', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    expect(screen.getByText('現在のカウント: 0')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('現在のカウント: 1')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('現在のカウント: 2')).toBeInTheDocument();
  });

  it('reset ボタンを押すと 0 に戻る', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: 'reset' }));

    expect(screen.getByText('現在のカウント: 0')).toBeInTheDocument();
  });
});

新しい query たち:

  • userEvent.setup()userEvent のインスタンスを作成。テストごとに 1 回 setup() するのが推奨パターン。
  • await user.click(...) — userEvent のすべての動作は非同期。await で受け取らないと次の検証が正確になりません。

userEvent を使う理由 — fireEvent よりも実際のユーザー動作に近いから。クリック 1 つは実際には mousedown → mouseup → click のシーケンスですが、fireEvent.click はそのうち click だけを発火させます。userEvent.click は 3 つすべて + フォーカスの変化までシミュレーションします。(#5 で深く扱います。)

aria-live と動的な更新 #

上の Counter の <p aria-live="polite"> は興味深い部分です。これはスクリーンリーダーに「このテキストが変わったらユーザーに知らせてください」と伝える属性。getByRole('status') でも探せます(aria-live は status role の一部)。

このような部分で RTL とアクセシビリティが自然に出会います。テストしやすいコンポーネントは、たいていアクセシビリティも良いものです。

within — 特定の領域内だけを探す #

大きな画面の中で、一部分だけを検査したいとき。

複雑な画面
<>
  <header>
    <button>ログアウト</button>
  </header>
  <main>
    <button>保存</button>
    <button>削除</button>
  </main>
  <footer>
    <button>ヘルプ</button>
  </footer>
</>

この画面で getByRole('button') を使うと 4 つすべてが拾われてエラー。メイン領域のボタンだけ見たいなら:

within
import { within } from '@testing-library/react';

const main = screen.getByRole('main');
const saveButton = within(main).getByRole('button', { name: '保存' });

within(element) は、その element の内部だけを探す新しい query オブジェクトを作ってくれます。

jest-dom matcher — よく使うもの #

@testing-library/jest-dom が追加してくれる matcher たち。DOM の検証が自然になります。

よく使う jest-dom
expect(el).toBeInTheDocument();
expect(el).toBeVisible();
expect(el).toBeDisabled();
expect(el).toBeEnabled();
expect(el).toBeChecked();        // checkbox
expect(el).toHaveValue('text');  // input
expect(el).toHaveTextContent('hello');
expect(el).toHaveClass('btn-primary');
expect(el).toHaveAttribute('href', '/about');
expect(el).toHaveFocus();
expect(el).toBeRequired();
expect(el).toHaveAccessibleName('保存');

混同しやすい 2 つ:

  • toBeInTheDocument() — DOM ツリーに付いているか。
  • toBeVisible() — それに加えて display: none などになっていないか。

UI が条件付きで表示/非表示になる場合は、toBeVisible のほうが正確です。

デバッグ — screen.debug() #

テストが壊れたのに理由が分からないとき。

現在の DOM を出力
render(<MyComponent />);
screen.debug();  // コンソールに現在の DOM ツリーを出力

または特定の要素だけ:

要素だけ
const el = screen.getByRole('button');
screen.debug(el);

もう 1 つ強力なツール — screen.logTestingPlaygroundURL()。コンソールに URL が出力され、開くと Testing Playground が現在の DOM を可視化して、どの query を使えばいいか提案してくれます。

Playground URL
screen.logTestingPlaygroundURL();
// "Open this URL in your browser: https://testing-playground.com/#..."

queries の書き方に詰まったとき、これが答えです。

よくある罠 #

act の警告がよく出る — userEvent を使えばほぼ遭遇しません。fireEvent を使ったり、直接 state setter を呼んだときに発生します。ほぼ常に非同期の変化を await し忘れたのが原因。

getByText が拾えない — テキストが複数のノードに分かれている可能性。screen.debug() で実際の DOM を確認しましょう。関数形式の matcher か正規表現が答えになることが多いです。

同じ query で複数拾われるgetAllBy* に変えるか、name オプションで絞り込みます。data-testid に逃げないこと。

テストが prod と違う動きをするように見える<StrictMode><Suspense>、theme provider のような wrapping が抜けている場合が多いです。カスタム render を作るのが定石:

custom render
// src/test/utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from '@/theme';

export function render(ui: React.ReactElement) {
  return rtlRender(ui, {
    wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
  });
}

export * from '@testing-library/react';

以後のテストでは import { render, screen } from '@/test/utils' で使います。

まとめ #

  • RTL を一文で — 「テストが実際の使われ方に近いほど、確信が大きい。」
  • コンポーネントは render でマウント、queries は screen から始める。
  • queries の優先順位: getByRolegetByLabelTextgetByText → … → getByTestId(最後の手段)。
  • getBy*(あるべき、同期)/ queryBy*(なくてもよい、同期)/ findBy*(しばらく経ってから現れる、非同期)の役割を分ける。
  • userEventfireEvent より優先。テストごとに userEvent.setup() を 1 回。
  • jest-dom matcher(toBeInTheDocumenttoBeVisibletoHaveValue …)はセットアップすれば強力。
  • 詰まったら screen.debug() / screen.logTestingPlaygroundURL() の 2 つのツール。

次の記事(#4 非同期とネットワークモッキング)では、コンポーネントがデータを fetch する場合。findBy*waitFor の役割、そして MSW でネットワークレイヤーを横取りするパターン。もっともよく使われるけれど、もっとも罠にハマりやすい部分です。

X