目次
29 章

コンポーネントテスト — Vitest + Testing Library

Vitest + React Testing Library でコンポーネントとフックをテスト。render・userEvent・モッキングのパターン、Next.js 環境での注意点、CI 統合まで。

28章で4部が終わりました。本章から5部(運用・テスト・デプロイ)が始まります。5部の5章は「React を作れる」から「React で仕事をする」へ渡る橋です。

本章はその最初の橋として テスト を扱います。本書の1〜4部で作ったコンポーネントは画面に描画され、手で動作を確認してきました。ところがプロジェクトが大きくなると、すべての動作を毎回手で確認することはできなくなります。そこで安全網となるのが自動化されたテストです。本章ではコンポーネントとフック単位の自動テストを、次の30章ではユーザーフロー単位の E2E テストを扱います。

なぜ Vitest なのか #

React 陣営で長らく標準だったのは Jest です。ただし次の環境変化により、Vitest が新しい標準として急速に台頭しました。

  • Vite との整合: Vite プロジェクト(2章で作った環境)の設定をそのまま再利用します。別途 babel 設定や transformer は不要です。
  • ESM ネイティブ: 最新ライブラリが ESM-only へ向かう流れで、Jest は設定が面倒です。Vitest は ESM を基本として扱います。
  • 高速な起動と watch: Vite の HMR をそのまま活かし、テストはほぼ瞬時に再実行されます。
  • Jest 互換 API: describe / it / expect のシグネチャがほぼ同一で、Jest の資料をそのまま応用できます。

Next.js プロジェクトでも Vitest が使えます。Jest をすでに使っている大規模なコードベースでない限り、新規プロジェクトは Vitest で始めるのが標準 です。

テストの基本原理 — 実装ではなく振る舞いを検証する #

Vitest をインストールする前に、まず押さえておきたいことがあります。何をテストするのか です。

よくある罠は、コンポーネントの内部 state や props の流れをそのまま検証しようとすることです。

🚫 実装を検証するテスト — 壊れやすい
test('カウンターが state を上げる', () => {
  const wrapper = mount(<Counter />);
  expect(wrapper.state('count')).toBe(0);
  wrapper.instance().increment();
  expect(wrapper.state('count')).toBe(1);
});

このようなテストは、コンポーネントの実装が少し変わっただけで(state 変数名、内部関数名)壊れます。ユーザーは state 変数を気にしません。ユーザーが見るのは 画面インタラクション です。

よいコンポーネントテストの出発点はこちらです。

ユーザーが画面で何を見るか、ユーザーが何をするとどう変化を見るか を検証する。

これは React Testing Library が最初から強制している哲学であり、本書のすべてのコンポーネントテストもこの観点を出発点とします。

Vitest のセットアップ #

2章で作った Vite + React + TypeScript プロジェクトにテスト道具を追加します。

パッケージのインストール
pnpm add -D vitest @vitest/ui jsdom \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event

各パッケージの役割は次のとおりです。

  • vitest: テストランナー本体
  • @vitest/ui: ブラウザベースのテスト UI(オプション)
  • jsdom: Node 環境でブラウザ DOM をシミュレーション
  • @testing-library/react: コンポーネントのレンダリングとクエリ
  • @testing-library/jest-dom: toBeInTheDocument などの DOM マッチャー
  • @testing-library/user-event: キーボード・クリックなど実ユーザーのインタラクションをシミュレーション

vitest.config.ts を作ります。

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});

src/test-setup.ts です。

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

afterEach(() => {
  cleanup();
});

tsconfig.jsontypesvitest/globals を追加すると、describe / it / expect を import なしで使えるようになります。

tsconfig.json (一部)
{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"]
  }
}

package.json にスクリプトを書きます。

package.json (一部)
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui"
  }
}

pnpm test で watch モード、pnpm test:run で CI 用の一度きり実行モードです。

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

5章で作った Counter コンポーネントを再び取り出します。

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

type Props = {
  initial?: number;
};

export default function Counter({ initial = 0 }: Props) {
  const [count, setCount] = useState(initial);
  return (
    <div>
      <p>現在の値: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(initial)}>リセット</button>
    </div>
  );
}

テストです。

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

describe('Counter', () => {
  it('初期値を表示する', () => {
    render(<Counter initial={5} />);
    expect(screen.getByText('現在の値: 5')).toBeInTheDocument();
  });

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

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

    expect(screen.getByText('現在の値: 2')).toBeInTheDocument();
  });

  it('リセットボタンを押すと initial に戻る', async () => {
    const user = userEvent.setup();
    render(<Counter initial={10} />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: 'リセット' }));

    expect(screen.getByText('現在の値: 10')).toBeInTheDocument();
  });
});

このテストが検証していること。

  • 画面に表示されるテキスト(screen.getByText)
  • ユーザーのクリック(user.click)
  • クリック後の画面の変化

count state がどう管理されているか、useState を使うのか useReducer を使うのか、内部関数名が何か — まったく検証しません。ユーザーの視点で検証 します。

queryBy / findBy / getBy — 三者の違い #

Testing Library のクエリ関数には3種類の prefix があり、それぞれ用途が違います。

prefix見つからないと非同期待機主な用途
getBythrow するしないいま即座に存在すべき要素
queryBynull を返すしない「存在しない」ことを断言したいとき
findBythrow する待つ非同期でやがて現れる要素

getBy は画面に即座に存在すべき要素を断言します。見つからなければ throw し、画面を親切に出力してくれます。

queryBy は「存在しないべき」要素を検証するときに使います。

queryBy 使用例
expect(screen.queryByText('エラー')).not.toBeInTheDocument();

findBy は非同期処理のあとに現れる要素を待ちます。内部的に Testing Library の waitFor が動きます。

findBy 使用例
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(await screen.findByText('ようこそ')).toBeInTheDocument();

順序が大事です。

  1. まず getBy* を試す — 同期的で速く、メッセージが明確です。
  2. 非同期で現れる要素なら findBy*
  3. 「存在しない」を断言するときだけ queryBy*

インタラクションテスト — userEvent の使い方 #

userEvent は実ユーザーのようにキーボード入力やクリックをシミュレートします。単純な fireEvent よりも現実に近いです(例: type は1文字ずつ入力しながら、各キーのイベントをすべて発火します)。

フォーム入力のテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

it('ログインフォームがユーザー入力と送信を処理する', async () => {
  const user = userEvent.setup();
  const handleSubmit = vi.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('メールアドレス'), 'me@example.com');
  await user.type(screen.getByLabelText('パスワード'), 'secret123');
  await user.click(screen.getByRole('button', { name: 'ログイン' }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'me@example.com',
    password: 'secret123',
  });
});

getByLabelText<label> で繋がった入力を探します。アクセシビリティのマークアップと自然に噛み合い、ユーザーが見るラベルをそのままセレクタとして使う流れになります。

getByRole は ARIA role を基準に探します。'button' / 'textbox' / 'heading' などがよく使われます。アクセシビリティ寄りのセレクタを優先 すると、テストが自然と a11y を検証する効果も生まれます。

モッキング — vi.mock #

外部モジュール(例: fetch ラッパー、API クライアント)をモックするときは vi.mock を使います。

モジュールモックの例
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import PostList from './PostList';

vi.mock('./api', () => ({
  fetchPosts: vi.fn().mockResolvedValue([
    { id: '1', title: '最初の投稿' },
    { id: '2', title: '2番目の投稿' },
  ]),
}));

it('ポスト一覧を表示する', async () => {
  render(<PostList />);
  expect(await screen.findByText('最初の投稿')).toBeInTheDocument();
  expect(await screen.findByText('2番目の投稿')).toBeInTheDocument();
});

vi.mock の第1引数はモジュールパス、第2引数は mock の形を返すファクトリ関数です。

MSW が必要なケース #

複数のテストが同じ API をモックする必要がある場合、あるいはネットワーク層自体を横取りしたい場合は MSW (Mock Service Worker) のほうが良い選択です。

MSW 使用例(概念)
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/posts', () =>
    HttpResponse.json([{ id: '1', title: '最初の投稿' }])
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

MSW は fetch / XHR を横取りしてネットワーク層で応答します。同じハンドラを30章(Playwright)でも再利用できるため、テストレベルが違っても同じ mock を共有できる 利点が大きいです。

フックのテスト — renderHook #

フック単独で動作を検証したいときは renderHook を使います。13章で作った useToggle のようなカスタムフックが対象です。

フックのテスト
import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';

it('useToggle が boolean をトグルする', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => result.current[1]());

  expect(result.current[0]).toBe(true);
});

act は state の更新を React のレンダーサイクルに包み込みます。これを呼ばないと React が警告を出します。

フックを単位でテストするか、統合でテストするか #

フックが複雑でなく、どうせ1つのコンポーネント内でしか使わないなら、そのコンポーネントの振る舞いテスト がフックも一緒に検証します。あえて renderHook をもう一度使う必要はありません。

renderHook が特に役立つケースはこちらです。

  • フックが複数のコンポーネントで使われるライブラリ的なコード。
  • フック内部の分岐が多く、すべてのケースをコンポーネントとして組むのが面倒な場合。
  • コンポーネントとフックの責務を分けながら作っているとき。

ほとんどのアプリケーションフックは 統合テストで十分 です。

Next.js コンポーネントテスト — 注意点 #

Next.js プロジェクトで Vitest を使う際に把握しておくべき限界があります。

Server Component の直接テスト #

Server Component はサーバーで一度実行され、結果を HTML として送り出すモデルです。Vitest の jsdom 環境で直接レンダリングするのは部分的にしかできません。特に async 関数コンポーネントや RSC 専用 API(headerscookies など)を使うコードはユニットテストで扱いにくいです。

原則: Server Component の動作検証は30章 Playwright の E2E テストに委譲する のが自然です。コンポーネントユニットテストは Client Component と純粋関数に集中させます。

Client Component は正常動作 #

'use client' が付いたコンポーネントは通常の React コンポーネントとして扱われ、Vitest で問題なく動作します。Server Action を import するコードは mock で横取りするのが標準です。

Server Action のモック
vi.mock('./actions', () => ({
  postMessage: vi.fn().mockResolvedValue({ success: true }),
}));

App Router の next/navigation のモック #

useRouteruseSearchParams のようなフックを使うコンポーネントはモックが必要です。

next/navigation のモック
vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
  useSearchParams: () => new URLSearchParams(),
}));

CI 統合 — GitHub Actions #

テストはローカルでだけ回っても意味が半減します。CI で PR ごとに回ってこそ本物の安全網になります。

.github/workflows/test.yml:

.github/workflows/test.yml
name: test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:run
      - run: pnpm test:run -- --coverage

pnpm test:run は CI で一度だけ実行するモードです(watch ではなく)。カバレッジを毎回測るとコストが嵩む場合があるので、別 job に分けるパターンもよく見られます。

カバレッジ計測 #

vitest.config.ts (coverage 追加)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['**/*.test.{ts,tsx}', 'src/test-setup.ts'],
    },
  },
});

v8 provider は別途パッケージなしで Node 組み込みのカバレッジを使います。速く、設定も少ないです。

目標カバレッジを強制的に決めないでください。テストしやすいコードを書き、意味のあるテストを書くこと が先で、数字は結果指標にすぎません。

テストレベルの役割分担 #

5部の最初の章らしく、大きな絵を一度描いておきます。次の30章 Playwright との分担です。

レベル道具検証対象速度比重
ユニットVitest純粋関数、フック、小さなコンポーネント非常に速いアルゴリズム、分岐、エッジケース
統合Vitest + jsdom複数コンポーネントの協調速いフォームの流れ、状態のリフトアップ、Context
E2EPlaywright(30章)ユーザーシナリオ全体遅い会員登録→ログイン→Todo 追加→完了 のようなフルシナリオ

3つのレベルすべてが必要なわけではありません。小さなプロジェクトは Vitest のユニット + 中核フローの E2E が1〜2本あれば十分です。大規模なプロジェクトは3つのレベルをバランスよく持ちます。

ピラミッドの形を覚えておいてください。ユニットテストがいちばん多く、E2E がいちばん少なく。逆さまにすると CI が遅くなって PR が溜まり、結局テストを切ることになります。

自分でやってみよう — 27章の方名録にテストを入れる #

27章で作った方名録の MessageForm にテストを入れてみます。

  1. Server Action のモック: vi.mock('./actions', ...)postMessage をモックします。成功ケース({ success: true })と検証失敗ケース({ error: '名前を入力してください' })の2種類を用意します。
  2. 空入力の検証: 名前・メッセージを空のまま送信ボタンを押したとき、エラーメッセージが画面に現れるかを検証します。screen.findByText で非同期の登場を待ちます。
  3. 成功ケース: 名前とメッセージを埋めて送信するとフォームが reset されるかを検証します。screen.getByLabelText('名前') の value が空文字に戻ることまで確認します。
  4. SubmitButton の pending: 別コンポーネントとして分離された SubmitButton の disabled 状態が、送信中にどう変わるかを検証します。useFormStatus は polyfill または mock が必要なことがあります。

3つのシナリオを書き終えると、React Testing Library の中核パターン — render + userEvent + findBy + Server Action mock — が手に馴染みます。

練習問題 #

  1. getBy / findBy / queryBy の選択. 次の3つの状況それぞれにどの prefix を使うのが適切かを答え、理由を書いてみてください。(a) ページ読み込み直後にウェルカムメッセージがあるかを確認、(b) ログインボタンを押したあとにウェルカムメッセージが現れるかを確認、(c) エラーメッセージがない状態かを確認。答えを書いてから本文の表と照らし合わせます。
  2. 実装 vs 振る舞いテストの識別. 5章(useState)の例コードに次の2つのテストを仮定します。(a) 「ボタンを押すと count state が 1 増える」、(b) 「ボタンを押すと画面の ‘現在の値’ テキストが 1 増える」。どちらがより安定的で、コンポーネントを useReducer にリファクタリングしても通り続けるかを説明してみてください。
  3. テストピラミッドの適用. 6部 34章のフルスタック Todo アプリにどのテストをどこに置くと良いかを設計してみてください。(a) Todo 項目のソート関数、(b) MessageForm の検証フロー、(c) 会員登録→ログイン→Todo 追加 のフルシナリオ — それぞれユニット / 統合 / E2E のどこに適しているかを分類し、理由を1行ずつ書きます。

一行まとめ: Vitest + React Testing Library は「実装ではなく振る舞いを検証する」テストの標準的な組み合わせです。getBy / findBy / queryBy の用途を覚え、userEvent でユーザーのように操作し、vi.mock で外部依存を切ります。Next.js の Server Component はユニットテストの限界があるため、30章 Playwright の E2E に委譲するのが自然です。ユニット → 統合 → E2E のピラミッドを覚え、数字のカバレッジよりも意味のあるテストを優先します。

次の章 #

次の 30章 E2E テスト — Playwright では、本章で扱えなかったユーザーシナリオ単位の自動テストを扱います。Vitest が jsdom 内でコンポーネントを検証したなら、Playwright は実ブラウザを立ち上げて会員登録 → ログイン → Todo 追加のようなフルフローを自動化します。そして本章のモックパターンと自然に繋がるよう、MSW のような道具を両側で共有する流れまで整理します。

X