VitestとTesting LibraryでReact 19のコンポーネントをテストする方法

​​React​React Testing Library,Suspense,TypeScript,useActionState,Vitest

Vitest と React Testing Library を組み合わせると、React 19 のコンポーネントを、利用者と同じようにボタンを押したりフォームに入力したりしてテストできます。設定ファイルの書き方から、useActionState を使うフォーム、use と Suspense でデータを読むコンポーネントのテストまでを、TypeScript(.tsx)で書いて確かめました。

検証の途中で、落とし穴が3つ見つかりました。1つ目は、後片づけを登録しないと2つ目のテストが失敗することです。2つ目は、非同期の結果を getBy で探すと、サーバーの応答の速さしだいで通ったり落ちたりすることです。3つ目は、中断(suspend)するコンポーネントを render するだけでは、一覧が出てこないことです。それぞれ、失敗する書き方と直した書き方を並べます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは vitest 5.0.5、jsdom 30.1.3、@testing-library/react 16.3.3、@testing-library/dom 10.4.2、@testing-library/user-event 14.6.8、@testing-library/jest-dom 7.0.1、react / react-dom 19.3.1、typescript 7.0.2 です。画面の画像は @vitest/ui 5.0.5 を Playwright 1.56.1 の Chromium で開いて撮りました。サーバーとの通信は、テストの中で偽の関数に差し替えています。

入れるパッケージ

React Testing Library は、@testing-library/dom を peerDependency にしています。React Testing Library の導入のページのとおり、一緒に入れる必要があります。TypeScript で書くなら、@types/react と @types/react-dom も入れます。

検証では、次のパッケージを入れました。jsdom はブラウザーの代わりの DOM を用意するため、@testing-library/user-event は利用者の操作を再現するため、@testing-library/jest-dom は toBeInTheDocument() などのマッチャーを使うためのものです。

bash
npm i -D vitest@5.0.5 jsdom@30.1.3 @testing-library/react@16.3.3 @testing-library/dom@10.4.2 @testing-library/user-event@14.6.8 @testing-library/jest-dom@7.0.1 typescript@7.0.2 @types/react @types/react-dom @types/node@22 react@19.3.1 react-dom@19.3.1

.tsx のテストは、@vitejs/plugin-react を入れなくても動きました(tsconfig の jsx は react-jsx)。また、package.json に "type": "module" がないと、Vitest を動かしたときに、Vite が設定ファイルについて「ESM syntax in a file loaded as CommonJS」という注意を出しました。テストは通りますが、"type": "module" を足すと注意は消えます。

設定:環境を jsdom にし、後片づけを登録する

Vitest の既定の環境は Node.js で、DOM がありません。Vitest の environment の設定で jsdom を指定します。あわせて、テストの前に読むファイルを setupFiles に書きます。

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
});

setup ファイルでは、jest-dom のマッチャーを読み込み、テストごとの後片づけ(cleanup)を登録します。jest-dom は、jest-dom の README にある、Vitest での使い方のとおり、@testing-library/jest-dom/vitest から読み込みます。

ts
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// globals: false(既定)のときは、自分で後片づけを登録する
afterEach(() => {
  cleanup();
});

後片づけを登録しないと、2つ目のテストで要素が2つ見つかる

React Testing Library は、afterEach をグローバルに使えるテストフレームワークでは、テストごとの後片づけを自動で登録します。しかし、Vitest は既定では afterEach などをグローバルに置きません(Vitest の globals の設定)。そのため、何もしないと後片づけが走りません。この点は、React Testing Library の設定のページにある、Vitest で後片づけを自動にする方法の説明にも書かれています。

試しに、setup ファイルから afterEach を消して、ボタンを描画するテストを2つ続けて流しました。すると、2つ目のテストが次のエラーで失敗しました。1つ目のテストで描画したボタンが、画面に残っていたためです。

text
 FAIL  src/Counter.test.tsx > 押すと1回になる
TestingLibraryElementError: Found multiple elements with the role "button"

直し方は2つあります。上の setup ファイルのように afterEach で cleanup() を呼ぶか、設定に globals: true を足すかです。検証では、どちらでも2つとも通りました。この記事では、globals を既定のままにして、cleanup() を自分で呼ぶ書き方にしています。

マッチャーの型は setup ファイルから付く

toBeInTheDocument() などの型は、setup ファイルの import '@testing-library/jest-dom/vitest' から付きます。そのため、tsconfig の include に setup ファイルを入れておきます。

json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "vitest.setup.ts", "vitest.config.ts"]
}

include から setup ファイルを外すと、tsc は「Property 'toBeInTheDocument' does not exist on type 'Assertion<void, HTMLElement>'」というエラーを出しました。target を ES2024 にしているのは、あとで使う Promise.withResolvers() の型のためです。ES2023 にすると、tsc は「Property 'withResolvers' does not exist on type 'PromiseConstructor'.」というエラーを出しました。

補足

今回の組み合わせ(vitest 5.0.5 と @testing-library/jest-dom 7.0.1)では、skipLibCheck を false にすると、tsc が2つのパッケージの型定義で「All declarations of 'Assertion' must have identical type parameters.」というエラーを出しました。型定義を見ると、jest-dom は Assertion の型引数を1つ(T)、vitest は2つ(R と T)で宣言していました。検証では、skipLibCheck: true にして型チェックを通しました。これは、Vite の React + TypeScript 用テンプレートにある tsconfig.app.json と同じ設定です。

ボタンを押すテスト:userEvent.setup() で操作する

まずは、押した回数を表示するだけのボタンでテストの形を確かめます。

tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>押した回数 {count}</button>;
}

操作には、user-event の setup のページで勧められている userEvent.setup() を使います。user-event の導入のページの例と同じく、user.click() などの操作には await を付けます。

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

test('最初は0回', () => {
  render(<Counter />);
  expect(screen.getByRole('button')).toHaveTextContent('押した回数 0');
});

test('押すと1回になる', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole('button'));
  expect(screen.getByRole('button')).toHaveTextContent('押した回数 1');
});

要素は、getByRole や getByLabelText のように、利用者に見える役割やラベルで探します。これは、Testing Library のクエリの優先順位で最初に挙げられている探し方です。

useActionState のフォーム:結果は findBy で待つ

次は、React 19 の useActionState を使った登録フォームです。送信すると、subscribe() でメールアドレスをサーバーに送り、結果のメッセージを出します。送信中はボタンを押せなくし、文言を「送信中…」にします。

tsx
import { useActionState } from 'react';
import { subscribe } from './api';

type State = { message: string };

async function subscribeAction(_prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email'));
  const { ok } = await subscribe(email);
  return { message: ok ? `${email} を登録しました` : '登録できませんでした' };
}

export function SubscribeForm() {
  const [state, formAction, isPending] = useActionState(subscribeAction, { message: '' });
  return (
    <form action={formAction}>
      <label>
        メールアドレス
        <input name="email" type="email" />
      </label>
      <button type="submit" disabled={isPending}>
        {isPending ? '送信中…' : '登録する'}
      </button>
      <p role="status">{state.message}</p>
    </form>
  );
}

テストでは、vi.mock('./api') で subscribe() を偽物に差し替えます。Vitest の vi.mock の説明によると、差し替え方を渡さず、__mocks__ フォルダもなければ、モジュールの export はすべて自動でモックになります。そのうえで、vi.mocked() を通して返す値を決めます。

結果のメッセージは、findByText で探します。Testing Library の非同期のクエリの説明によると、findBy は getBy と waitFor を組み合わせたもので、要素が出てくるまで待ちます。既定では1000msまで待ちます。

tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { subscribe } from './api';
import { SubscribeForm } from './SubscribeForm';

vi.mock('./api');

test('登録すると、結果のメッセージが出る', async () => {
  vi.mocked(subscribe).mockResolvedValue({ ok: true });
  const user = userEvent.setup();
  render(<SubscribeForm />);

  await user.type(screen.getByLabelText('メールアドレス'), 'neko@example.com');
  await user.click(screen.getByRole('button', { name: '登録する' }));

  expect(await screen.findByText('neko@example.com を登録しました')).toBeInTheDocument();
  expect(subscribe).toHaveBeenCalledWith('neko@example.com');
});

getBy でも通ってしまうのは、モックがすぐ答えるとき

結果のメッセージを探す確認を getByText に変えても、テストは通りました。モックがすぐに解決する Promise を返すこのテストでは、await user.click() が終わった時点で、結果がもう画面に出ていたことになります。

そこで、モックを50msたってから答えるものに変えました。すると、getByText の版は「Unable to find an element with the text: neko@example.com を登録しました.」で失敗しました。そのときの画面では、ボタンはまだ「送信中…」でした。同じ条件で、findByText の版は通りました。サーバーの応答にかかる時間でテストの結果が変わらないように、送信の結果は findBy で待つのが安全です。

送信中の表示は、応答の時期をテストで決めて確かめる

「送信中…」を確かめるには、モックが答える時期をテストの側で決められるようにします。Promise.withResolvers() で作った Promise を返させておき、確認が済んでから resolve() を呼びます。

tsx
test('サーバーが応答するまでは「送信中…」になる', async () => {
  // テストから応答の時期を決められる Promise
  const { promise, resolve } = Promise.withResolvers<{ ok: boolean }>();
  vi.mocked(subscribe).mockReturnValue(promise);
  const user = userEvent.setup();
  render(<SubscribeForm />);

  await user.type(screen.getByLabelText('メールアドレス'), 'neko@example.com');
  await user.click(screen.getByRole('button', { name: '登録する' }));
  expect(screen.getByRole('button', { name: '送信中…' })).toBeDisabled();

  resolve({ ok: false });
  expect(await screen.findByText('登録できませんでした')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: '登録する' })).toBeEnabled();
});

このテストは通り、act についての警告も出ませんでした。resolve() は act の外で呼んでいます。そのあとの画面の更新は、findByText で待っています。

use と Suspense のコンポーネント:render を await act で包む

最後は、use で Promise を読み、<Suspense> で読み込み中の表示を出すコンポーネントです。Promise は親から受け取ります。

tsx
import { Suspense, use } from 'react';

export type Album = { id: number; title: string };

function AlbumList({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
  const albums = use(albumsPromise);
  return (
    <ul>
      {albums.map((album) => (
        <li key={album.id}>{album.title}</li>
      ))}
    </ul>
  );
}

export function Albums({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
  return (
    <Suspense fallback={<p>読み込み中…</p>}>
      <AlbumList albumsPromise={albumsPromise} />
    </Suspense>
  );
}

フォームのテストと同じように、render() を呼んでから resolve() し、findAllByRole で一覧を待つテストを書きました。ところが、このテストは1000ms待ったあとに「Unable to find role="listitem"」で失敗しました。画面は「読み込み中…」のままでした。あわせて、React が次の警告を出していました。

text
A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:

await act(() => ...)

警告の指示どおり、render() を await act(async () => { ... }) で包むと、テストは通りました。act は @testing-library/react から読み込みます。この act は、React Testing Library の API のページにある act の説明によると、React の act に引数をそのまま渡すだけの関数です。React の act のリファレンスでも、act は await と async 関数で使うよう勧められています。

tsx
import { act, render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { Albums, type Album } from './Albums';

test('読み込み中の表示のあとに、一覧が出る', async () => {
  // Promise はテストの側で作って渡す
  const { promise, resolve } = Promise.withResolvers<Album[]>();
  // 中断するコンポーネントは、await act の中で描画する
  await act(async () => {
    render(<Albums albumsPromise={promise} />);
  });
  expect(screen.getByText('読み込み中…')).toBeInTheDocument();

  resolve([
    { id: 1, title: 'Abbey Road' },
    { id: 2, title: 'Let It Be' },
  ]);
  expect(await screen.findAllByRole('listitem')).toHaveLength(2);
  expect(screen.queryByText('読み込み中…')).not.toBeInTheDocument();
});

どこを act で包めばよいかを確かめるため、3つの書き方を1つのファイルに並べて流しました。

書き方 結果
A:render() を await act で包み、resolve() はそのまま呼ぶ 通った
B:解決済みの Promise を渡し、render() を await act で包む 通った
C:render() はそのまま呼び、resolve() のほうを await act で包む 失敗した(警告も出た)

C の失敗を Vitest UI で開くと、次のようになります。左の一覧の上にある「FAIL (1)」「PASS (0)」は、テストではなくファイルの数です。resolve() を act で包んで待っても、画面には「読み込み中…」しか残っていません。

Vitest UI の画面。左の一覧で A と B に緑のチェック、C に赤のバツが付いている。右には C のエラーが表示され、body の中に「読み込み中…」の p 要素だけがある
Vitest UI で開いた C の失敗。一覧の代わりに読み込み中の表示が残っている
ポイント

中断するコンポーネントは、最初の render() を await act(async () => { ... }) で包みます。あとから resolve() を act で包んでも、代わりにはなりませんでした。

なお、use のリファレンスにある、Client Component で Promise をキャッシュする方法の説明によると、use に渡す Promise は、描画をやり直しても同じものを渡せるようにしておく必要があります。このテストでは、Promise をテストの側で1回だけ作り、props で渡しています。

3つの落とし穴の早見表

症状 原因 直し方
2つ目のテストで「Found multiple elements」 globals が既定の false で、後片づけが走らない setup ファイルの afterEach で cleanup() を呼ぶか、globals: true にする
送信の結果を getBy で探すと、通ったり落ちたりする 結果が出る前に探している findBy で待つ
use と Suspense の画面が「読み込み中…」のまま render() を await act で包んでいない await act(async () => { render(...) }) にする

​Posted by 86