Reactのテストコード、何をどう書くか【2026年版】
結論から
- 新規なら Vitest + React Testing Library + MSW。この3つで9割カバーできる。
- テストするのは「ユーザーに見えること」だけ。stateの中身やpropsの受け渡しをテストし始めた瞬間、そのテストは負債になる。
-
クエリは
getByRoleを第一候補に。querySelectorやクラス名で要素を探すテストは、リファクタのたびに壊れる。 - カバレッジ100%は目指さない。壊れたときに困る場所から書く。
-
react-dom/test-utilsはReact 19で削除、react-test-rendererは非推奨、Enzymeは事実上死んでいる。古い記事のコードをコピペしないこと。
1. なぜバックエンド出身者はフロントのテストで詰まるのか
PHPUnitやPestに慣れていると、フロントのテストで手が止まる。理由ははっきりしている。
// バックエンド:入力と出力が明確
public function test_税込価格を計算する()
{
$this->assertSame(1100, calcTax(1000));
}
関数に値を入れて、返り値を検証する。これは迷わない。
ところがReactコンポーネントには「返り値」に相当するものが分かりにくい。JSXを返すが、それを比較しても意味がない。何を検証すればテストになるのかが最初の壁になる。
答えはシンプルで、**「画面に何が表示されるか」と「操作したら何が起きるか」**の2つだけ検証する。それ以外は見ない。
test('税込価格が表示される', async () => {
render(<PriceLabel price={1000} />);
expect(screen.getByText('1,100円')).toBeInTheDocument();
});
内部で useState を使っていようが useReducer だろうが、テストは知らなくていい。これが「実装ではなく振る舞いをテストする」の実際の意味。
2. セットアップ
npm i -D vitest @testing-library/react @testing-library/jest-dom \
@testing-library/user-event jsdom
各パッケージの役割:
| パッケージ | 役割 | Laravelでいうと |
|---|---|---|
vitest |
テストランナー | PHPUnit / Pest |
@testing-library/react |
コンポーネントを描画して要素を探す | — |
@testing-library/jest-dom |
toBeInTheDocument() 等のマッチャ |
カスタムアサーション |
@testing-library/user-event |
クリック・入力の再現 | Dusk のブラウザ操作 |
jsdom |
Node上に擬似DOMを用意 | — |
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup(); // テスト間でDOMを掃除。これがないと前のテストの残骸を拾う
});
JestではなくVitestを選ぶ理由は、Vite設定をそのまま流用できること。エイリアス(@/components)やTypeScriptの解決を二重管理せずに済む。既存のCRA/Next.jsプロジェクトでJestが動いているなら、無理に移行しなくていい。
3. 基本の型:Arrange → Act → Assert
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
test('ボタンを押すとカウントが増える', async () => {
// Arrange
const user = userEvent.setup();
render(<Counter />);
// Act
await user.click(screen.getByRole('button', { name: '増やす' }));
// Assert
expect(screen.getByText('1')).toBeInTheDocument();
});
ポイントが3つ。
userEvent.setup() を先に呼ぶ。v14以降の作法で、これを忘れると入力系が正しく動かない。
fireEvent ではなく userEvent を使う。fireEvent.click() はDOMイベントを直接発火するだけだが、userEvent.click() はフォーカス移動・hover・mousedown/mouseup まで再現する。実際のユーザー操作に近い分、バグを拾える。
await を付ける。userEvent は全て非同期。付け忘れると検証が先に走って落ちる。
4. クエリの優先順位 —— ここが一番大事
要素の探し方には推奨順序がある。上から順に検討する。
// ① Role:最優先。アクセシビリティツリー経由で探す
screen.getByRole('button', { name: '送信' });
screen.getByRole('textbox', { name: 'メールアドレス' });
screen.getByRole('heading', { level: 1 });
// ② Label:フォーム要素はこれ
screen.getByLabelText('パスワード');
// ③ Placeholder / Text
screen.getByPlaceholderText('72.0');
screen.getByText('保存しました');
// ④ TestId:最後の手段
screen.getByTestId('user-card');
なぜ getByRole が最優先か。 これで見つからない要素は、スクリーンリーダーからも見つけられない可能性が高い。テストを書くこと自体がアクセシビリティのチェックになる。
// ✗ 実装に依存している。classNameを変えたら壊れる
const btn = container.querySelector('.btn-primary');
// ✓ ユーザーから見た姿で探している。見た目を変えても壊れない
const btn = screen.getByRole('button', { name: '送信' });
data-testid は「roleもlabelもテキストも安定して付けられない」場合だけ。多用しているテストファイルは、大抵どこかで設計を間違えている。
get / query / find の使い分け
| 接頭辞 | 見つからないとき | 用途 |
|---|---|---|
getBy |
エラーを投げる | 存在するはずのもの |
queryBy |
null を返す |
存在しないことの検証 |
findBy |
Promise。一定時間待つ | 非同期で現れるもの |
// 存在しないことを検証するときだけ queryBy
expect(screen.queryByText('エラー')).not.toBeInTheDocument();
// 非同期で出てくるものは findBy(awaitを忘れない)
expect(await screen.findByText('保存しました')).toBeInTheDocument();
getBy で「無いこと」を検証しようとすると、そもそもエラーで落ちる。ここは最初に必ず踏む。
5. フォームのテスト
実務で一番書く形。
test('必須項目が空だとエラーが出て、送信されない', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(await screen.findByText('メールアドレスを入力してください')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
test('入力して送信すると、値が渡される', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('メールアドレス'), 'test@example.com');
await user.type(screen.getByLabelText('パスワード'), 'pass1234');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'pass1234',
});
});
vi.fn() はJestの jest.fn() と同じ。呼ばれたか、何を引数に呼ばれたかを検証できる。
6. API通信は MSW で止める
fetch を vi.mock() で潰すやり方は動くが、実装に密着しすぎる。MSW(Mock Service Worker)はネットワーク層で止めるので、fetch でも axios でもテストを書き換えずに済む。
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/jobs', () => {
return HttpResponse.json([
{ id: '1', title: '介護スタッフ' },
{ id: '2', title: 'ケアマネージャー' },
]);
}),
];
// src/test/setup.ts に追記
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
import { beforeAll, afterAll, afterEach } from 'vitest';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('求人一覧が表示される', async () => {
render(<JobList />);
// ローディング中の状態を検証してから
expect(screen.getByText('読み込み中')).toBeInTheDocument();
// データが来た後を検証
expect(await screen.findByText('介護スタッフ')).toBeInTheDocument();
expect(screen.getByText('ケアマネージャー')).toBeInTheDocument();
});
エラーケースはテスト内でハンドラを差し替える。
test('APIが失敗したらエラーメッセージを出す', async () => {
server.use(
http.get('/api/jobs', () => new HttpResponse(null, { status: 500 }))
);
render(<JobList />);
expect(await screen.findByText('求人を取得できませんでした')).toBeInTheDocument();
});
onUnhandledRequest: 'error' を入れておくのが地味に効く。ハンドラを定義し忘れたリクエストがあれば即座に落ちるので、「なぜかテストが通らない」の調査時間が減る。
7. カスタムフックのテスト
UIを持たないロジックは renderHook で直接叩ける。
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('incrementで値が増える', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
ただしフックのテストを増やしすぎないほうがいい。フックは実装の一部であって、ユーザーから見える単位ではない。コンポーネント経由でテストできるなら、そちらを優先する。複雑な計算ロジック(日付、料金、バリデーション)を切り出したときだけ直接テストする、くらいの温度感。
そもそも純粋な関数に切り出せるロジックは、フックにせず普通の関数にしてテストするのが一番楽。
// utils/price.ts —— Reactに依存しない。テストが一瞬で書ける
export const withTax = (price: number, rate = 0.1) =>
Math.floor(price * (1 + rate));
8. 何をテストしないか
テストは書けば書くほどいいものではない。以下は書かないほうがいい。
スナップショットテストの乱用。toMatchSnapshot() は差分が出ても内容を読まずに -u で更新されがちで、実質的に何も守っていないケースが多い。使うなら小さく安定した表示(アイコン、バッジ)に限る。
state・propsの直接検証。expect(component.state.count).toBe(1) のようなテスト。実装を変えた瞬間に壊れ、しかもユーザー影響はゼロ。
ライブラリの動作確認。React Hook Formが正しくバリデーションするかは、React Hook Formのテストの仕事。
見た目。CSSの当たり方をjsdomでテストしても意味がない。ここはVisual Regression Test(Playwright, Chromatic)の領域。
判断基準
そのテストが落ちたとき、ユーザーに実害があるか?
Noなら書かない。この一問でだいたい仕分けできる。
9. どこから書くか
既存プロジェクトにテストがゼロの状態から始めるなら、この順番。
- バグが出た箇所。修正と同時に再発防止のテストを1本書く。ここが一番費用対効果が高い
- 金と個人情報が絡むところ。決済、応募フォーム、権限判定
- 切り出した純粋関数。書くのが楽で壊れにくい
- 主要な画面の表示テスト。「落ちずに描画されて、主要な文言が出る」だけでも十分価値がある
逆に、最初から全コンポーネントを網羅しようとすると必ず挫折する。カバレッジは結果であって目標ではない。
10. E2Eとの棲み分け
| レイヤ | ツール | 対象 | 本数 |
|---|---|---|---|
| ユニット | Vitest | 純粋関数、フック | 多め |
| コンポーネント | Vitest + RTL | 単一画面の表示・操作 | 中心 |
| 統合 | Vitest + RTL + MSW | 画面 + API連携 | 中心 |
| E2E | Playwright | ログイン〜申込みの一連 | 3〜5本だけ |
E2Eは実ブラウザを動かすので遅く、壊れやすい。「これが動かなければサービスとして終わり」という導線だけに絞る。CIの実行時間を守るためにも、ここは欲張らない。