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 より)。
最小のテストは次のように書けます。
// 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 段で構成されます。
- Render —
render(<Component />)でコンポーネントを DOM に描画します。 - Query —
screen.getByRole(...)などで DOM 要素を取得します。 - Assert — 取得した要素の状態・内容を
expect(...)で検証します。
ユーザー操作が絡むなら、Query と Assert のあいだに userEvent.click(button) のような操作を挟みます。
最小のコンポーネントテスト
// 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>
);
}// 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)。上から順に試し、どうしても無理なら下に降りる、という指針です。
- getByRole — アクセシビリティツリーから見える要素を問い合わせる。
nameオプションでラベルを指定できます。ボタン、見出し、リンク、フォームコントロールはこれでほぼ取れます。 - getByLabelText — フォームフィールドに最適。ユーザーがラベル経由でフォームを使うのを模倣します。
- getByPlaceholderText — ラベルがない場合の次善策。
- getByText — 非インタラクティブ要素を、その文言で取得します。
- getByDisplayValue — 入力済みのフォーム値で取得します。
- getByAltText — 画像など alt が使える要素。
- getByTitle — 最終手段のひとつ(title はスクリーンリーダーで読み上げが安定しないため)。
- getByTestId — 他が使えないときだけの逃げ道。「ユーザーには見えない」ことに注意してください。
// 優先度の高いクエリから試す
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(() => ...)— 任意の条件が満たされるまでポーリングして待ちます。
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 テストピラミッド
RTL は主に「統合テスト」の層を担います。E2E は Playwright や Cypress のような別ツールが担当します。Part 7 の範囲では、ユニット + 統合までを押さえれば十分です。