1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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 で止める

fetchvi.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. バグが出た箇所。修正と同時に再発防止のテストを1本書く。ここが一番費用対効果が高い
  2. 金と個人情報が絡むところ。決済、応募フォーム、権限判定
  3. 切り出した純粋関数。書くのが楽で壊れにくい
  4. 主要な画面の表示テスト。「落ちずに描画されて、主要な文言が出る」だけでも十分価値がある

逆に、最初から全コンポーネントを網羅しようとすると必ず挫折する。カバレッジは結果であって目標ではない。


10. E2Eとの棲み分け

レイヤ ツール 対象 本数
ユニット Vitest 純粋関数、フック 多め
コンポーネント Vitest + RTL 単一画面の表示・操作 中心
統合 Vitest + RTL + MSW 画面 + API連携 中心
E2E Playwright ログイン〜申込みの一連 3〜5本だけ

E2Eは実ブラウザを動かすので遅く、壊れやすい。「これが動かなければサービスとして終わり」という導線だけに絞る。CIの実行時間を守るためにも、ここは欲張らない。


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?