Skip to content

7.5 テスト入門 — Vitest + React Testing Library ​

案件に入ると、テストは「書かれていることもあれば、書かれていないこともある」という状態で出会うことがほとんどです。既にテストがある案件では書き方の規約に従い、ない案件では追加しやすい形で 小さく 始めるのが通例です。

この章では、受託現場でよく見る構成として Vitest + React Testing Library を扱います。目的は「どうテストを書くか」の完全ガイドを作ることではなく、何をテストし、何をテストしないか の判断軸を持てるようになることです。

構成の役割分担 ​

  • Vitest — テストランナー + アサーションライブラリ。Vite エコシステムと統合されていて、Jest と API 互換性があります。
  • React Testing Library (RTL) — DOM 上で「ユーザー視点」のクエリと操作を行うためのユーティリティです。テストランナーそのものではありません。
  • @testing-library/user-event — ユーザーのクリックやキー入力を模倣する高水準 API(RTL に同梱ではなく別パッケージ)。

Vitest がテストを走らせ、RTL が「ユーザーになった気持ちで DOM を触る」部分を担う、という役割分担ですね。

図 7.5.1 テストツールの分担 ​

Vitest の位置づけ ​

Vitest (vitest.dev/guide/) は「Vite-powered な次世代テストフレームワーク」と自称しており、公式 Guide では次の特徴を挙げています。

  • Vite 統合: vite.config.* をそのまま読みます。別の設定ファイルを用意する必要はありません(分けたければ vitest.config.* を作ればよいです)。
  • Jest 互換: API パターンが Jest と似ていて、Jest からの移行パスが意識されています。
  • 必要環境: Node.js ≥ v20.0.0、Vite ≥ v6.0.0(公式 Guide より)。

最小のテストは次のように書けます。

ts
// vitest.dev/guide/ の基本パターン
import { expect, test } from "vitest";

test("足し算の基本", () => {
  expect(1 + 1).toBe(2);
});

describe でグループ化、beforeEach / afterEach でセットアップ、vi.fn() でモック関数、といった機能は Jest と同名同役です。

React Testing Library ​

RTL のトップメッセージは、次の一文に集約されています。

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

「テストは、ソフトウェアが実際に使われる方法に近ければ近いほど、信頼できる」という哲学ですね。ここから、次の姿勢が導かれます。

  • 実装詳細(コンポーネント内部の state や特定の prop 名)を直接検証しない。
  • ユーザーが操作するのと同じ方法で DOM にアクセスする(ラベル、ボタン文言、ロール)。
  • data-testid は「他に手段がないときの逃げ道」。

基本パターン ​

典型的なテストは、次の 3 段で構成されます。

  1. Render — render(<Component />) でコンポーネントを DOM に描画します。
  2. Query — screen.getByRole(...) などで DOM 要素を取得します。
  3. Assert — 取得した要素の状態・内容を expect(...) で検証します。

ユーザー操作が絡むなら、Query と Assert のあいだに userEvent.click(button) のような操作を挟みます。

最小のコンポーネントテスト ​

tsx
// Counter.tsx
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>現在: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>増やす</button>
    </div>
  );
}
tsx
// Counter.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";

describe("Counter", () => {
  test("ボタンをクリックすると値が 1 増える", async () => {
    render(<Counter />);

    // ユーザーが見るのと同じように、role と文言で要素を探す
    const button = screen.getByRole("button", { name: "増やす" });

    // 初期表示を確認
    expect(screen.getByText("現在: 0")).toBeInTheDocument();

    // ユーザー操作を模倣
    await userEvent.click(button);

    // 結果を検証
    expect(screen.getByText("現在: 1")).toBeInTheDocument();
  });
});

toBeInTheDocument は @testing-library/jest-dom のマッチャで、Vitest でもセットアップすれば使えます。Vitest の setup ファイルで import "@testing-library/jest-dom/vitest" を読み込むのが定跡です(testing-library/jest-dom 公式 README が Vitest 用の専用 import パスとして案内しています)。setup ファイルは vitest.config.* の test.setupFiles で指定します。

クエリの優先順位 ​

RTL は「どのクエリを使うべきか」を優先順位付きで定めています (testing-library.com/docs/queries/about/#priority)。上から順に試し、どうしても無理なら下に降りる、という指針です。

  1. getByRole — アクセシビリティツリーから見える要素を問い合わせる。name オプションでラベルを指定できます。ボタン、見出し、リンク、フォームコントロールはこれでほぼ取れます。
  2. getByLabelText — フォームフィールドに最適。ユーザーがラベル経由でフォームを使うのを模倣します。
  3. getByPlaceholderText — ラベルがない場合の次善策。
  4. getByText — 非インタラクティブ要素を、その文言で取得します。
  5. getByDisplayValue — 入力済みのフォーム値で取得します。
  6. getByAltText — 画像など alt が使える要素。
  7. getByTitle — 最終手段のひとつ(title はスクリーンリーダーで読み上げが安定しないため)。
  8. getByTestId — 他が使えないときだけの逃げ道。「ユーザーには見えない」ことに注意してください。
tsx
// 優先度の高いクエリから試す
screen.getByRole("button", { name: "送信" }); // Tier 1
screen.getByLabelText("メールアドレス"); // Tier 1
screen.getByText("ログインに失敗しました"); // Tier 1 (非インタラクティブ文言)
screen.getByTestId("analytics-banner"); // Tier 3, 最後の手段

getByRole を優先すると、副次効果として「アクセシビリティ上正しくマークアップされているか」のチェックにもなります。ボタンが <div onClick> で実装されていると getByRole("button") で取れず、テストが書けない → そもそもマークアップを直そう、という健全な流れになります。

非同期の扱い ​

API を叩くコンポーネントでは、テスト時点で目的の要素がまだ DOM にないことがあります。RTL はこのために findBy* と waitFor を提供しています。

  • findByRole / findByText など — 要素が現れるまで待ちます(既定タイムアウト 1000ms 前後)。Promise を返します。
  • waitFor(() => ...) — 任意の条件が満たされるまでポーリングして待ちます。
tsx
test("データを読み込んで表示する", async () => {
  render(<UserProfile id={1} />);

  // まずローディングが出る
  expect(screen.getByText("読み込み中")).toBeInTheDocument();

  // データが届いて名前が表示されるのを待つ
  expect(await screen.findByText("田中太郎")).toBeInTheDocument();
});

findBy* は「非同期で現れるものを取る」、getBy* は「今すぐ DOM にあるものを取る」、queryBy* は「なければ null を返す(= 存在しないことを検証する用途)」。使い分けを覚えておけば、非同期テストで詰まる頻度がぐっと下がります。

fireEvent より userEvent を推奨 ​

RTL には低水準の fireEvent(単発のイベントを発火する)もありますが、実務では @testing-library/user-event の高水準 API を使うのが推奨されています。理由は、fireEvent が click イベント 1 発なのに対し、userEvent.click は pointerdown → pointerup → click → focus のような 実際のブラウザ挙動に近い 一連のイベントを発火してくれるためです。ユーザー視点にぐっと近いテストになります。

何をテストするか、しないか ​

テストすべきもの:

  • ユーザーが観測できる挙動(ボタンを押すと値が変わる、エラー時にメッセージが出る、フォーム送信後に確認画面に遷移する)。
  • ビジネスロジック(関数単位で入出力が決まる箇所は Vitest で直接テスト)。

避けるべきもの:

  • 実装詳細(内部 state 変数の値、特定の関数が何回呼ばれたかの過剰な検証)。
  • スタイルの色・ピクセル値など、別ツールで扱うべき視覚的リグレッション(Chromatic や Playwright で分担)。

「ボタンを押すと setCount が呼ばれる」ではなく、「ボタンを押すと表示数値が 1 増える」が書くべきテストです。前者は useState を useReducer にリファクタしただけで壊れますが、後者はユーザー体験が変わらない限り壊れません。

図 7.5.2 テストピラミッド ​

E2E少数 / 重い統合テスト(RTL が主役)中程度の数 / 中程度のコストユニットテスト多数 / 軽い(Vitest で関数・単一コンポーネント)コスト・実行時間数Part 7 の範囲 = ユニット + 統合までを押さえれば十分E2E は Playwright / Cypress が別の領域で担当

RTL は主に「統合テスト」の層を担います。E2E は Playwright や Cypress のような別ツールが担当します。Part 7 の範囲では、ユニット + 統合までを押さえれば十分です。

参照ソース ​