はじめに#
前回の記事では、TypeScriptとReactを組み合わせた型安全なコンポーネント開発について解説しました。本記事では、VitestとReact Testing Libraryを使った「Reactコンポーネントの自動テスト」について詳しく解説します。
テストを書くことで、コードの品質を保証し、リファクタリング時の安全性を確保できます。また、テストはコンポーネントの仕様を明文化するドキュメントとしても機能します。React公式ドキュメントでもテストの重要性が強調されており、2025年現在のReact開発においてテストは必須のスキルとなっています。
本記事を読むことで、以下のことができるようになります。
- VitestとReact Testing Libraryの環境をセットアップする
- コンポーネントのレンダリングテストを実装する
- user-eventを使ってユーザー操作をシミュレートする
- vi.fn()やvi.mock()を使ったモックの作成方法を理解する
- 非同期処理を含むコンポーネントのテストを書く
実行環境・前提条件#
必要な環境#
- Node.js 20.x以上
- Viteで作成したReactプロジェクト(TypeScript)
- VS Code(推奨)
前提知識#
- 関数コンポーネントの基本
- useState・useEffectの使い方
- TypeScriptの基本的な型定義
- Propsによるデータの受け渡し
なぜテストを書くのか#
テストがもたらす価値#
テストを書くことで得られるメリットは非常に大きいです。
| メリット |
説明 |
| バグの早期発見 |
コード変更時に既存機能が壊れていないか即座に確認できる |
| リファクタリングの安全性 |
テストがあれば安心してコードを改善できる |
| 仕様のドキュメント化 |
テストケースがコンポーネントの期待動作を示す |
| 開発速度の向上 |
手動での確認作業が減り、長期的には開発が速くなる |
| チーム開発の効率化 |
他のメンバーが書いたコードの意図を理解しやすくなる |
テストの種類#
Reactアプリケーションにおけるテストは、主に以下の3種類に分類されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 1. 単体テスト(Unit Test)
// 個々の関数やコンポーネントを独立してテスト
test("足し算関数が正しく動作する", () => {
expect(add(1, 2)).toBe(3);
});
// 2. 統合テスト(Integration Test)
// 複数のコンポーネントが連携して動作することをテスト
test("フォーム送信でAPIが呼ばれる", async () => {
render(<ContactForm />);
await userEvent.click(screen.getByRole("button"));
expect(mockSubmit).toHaveBeenCalled();
});
// 3. E2Eテスト(End-to-End Test)
// ブラウザ上でアプリ全体の動作をテスト(Playwright/Cypressなど)
|
本記事では、Reactコンポーネントの単体テストと統合テストに焦点を当てます。
Vitestの概要#
Vitestとは#
Vitestは、Viteをベースにした次世代のテストフレームワークです。Viteプロジェクトとシームレスに統合でき、高速なテスト実行が特徴です。
VitestとJestの比較#
| 項目 |
Vitest |
Jest |
| 実行速度 |
非常に高速(Viteベース) |
標準的 |
| 設定の簡単さ |
Viteプロジェクトなら設定不要 |
別途設定が必要 |
| ESMサポート |
ネイティブサポート |
追加設定が必要 |
| API互換性 |
Jestと互換性あり |
- |
| ウォッチモード |
デフォルトで有効 |
手動で有効化 |
Viteを使用しているプロジェクトでは、Vitestが最適な選択肢です。JestからVitestへの移行も、API互換性があるため比較的容易に行えます。
テスト環境のセットアップ#
必要なパッケージのインストール#
まず、テストに必要なパッケージをインストールします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Vitestのインストール
npm install -D vitest
# React Testing Libraryのインストール
npm install -D @testing-library/react @testing-library/dom
# user-eventのインストール(ユーザー操作シミュレーション)
npm install -D @testing-library/user-event
# jsdomのインストール(ブラウザ環境のシミュレーション)
npm install -D jsdom
# jest-domのインストール(DOM用カスタムマッチャー)
npm install -D @testing-library/jest-dom
|
Vitestの設定#
vite.config.tsにテスト設定を追加します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
// ブラウザ環境をシミュレート
environment: "jsdom",
// グローバルAPIを有効化(describe, test, expectなど)
globals: true,
// テスト実行前に読み込むセットアップファイル
setupFiles: ["./src/test/setup.ts"],
// テストファイルのパターン
include: ["src/**/*.{test,spec}.{js,ts,jsx,tsx}"],
},
});
|
セットアップファイルの作成#
src/test/setup.tsを作成し、テスト環境の初期設定を行います。
1
2
3
4
5
6
7
8
9
|
// src/test/setup.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// 各テスト後にDOMをクリーンアップ
afterEach(() => {
cleanup();
});
|
TypeScript設定の更新#
tsconfig.jsonにVitestの型定義を追加します。
1
2
3
4
5
|
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}
|
package.jsonにテストスクリプトを追加#
1
2
3
4
5
6
7
8
9
|
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
|
期待される動作#
セットアップが完了したら、以下のコマンドでテストを実行できます。
1
2
3
4
5
|
# ウォッチモードでテスト実行(ファイル変更を監視)
npm test
# 一度だけテスト実行
npm run test:run
|
基本的なテストの書き方#
はじめてのテスト#
まず、シンプルな関数のテストから始めましょう。
1
2
3
4
5
6
7
8
|
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// src/utils/math.test.ts
import { describe, test, expect } from "vitest";
import { add, multiply } from "./math";
describe("math関数", () => {
describe("add", () => {
test("2つの数値を足し算できる", () => {
expect(add(1, 2)).toBe(3);
});
test("負の数も扱える", () => {
expect(add(-1, 1)).toBe(0);
});
});
describe("multiply", () => {
test("2つの数値を掛け算できる", () => {
expect(multiply(2, 3)).toBe(6);
});
test("0を掛けると0になる", () => {
expect(multiply(5, 0)).toBe(0);
});
});
});
|
テストの基本構造#
Vitestのテストは以下の構造で記述します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import { describe, test, expect } from "vitest";
// describe: テストをグループ化
describe("テスト対象の名前", () => {
// test(またはit): 個別のテストケース
test("期待する動作の説明", () => {
// Arrange: テストの準備
const input = 1;
// Act: テスト対象の実行
const result = input + 1;
// Assert: 結果の検証
expect(result).toBe(2);
});
});
|
主要なマッチャー#
Vitestには様々なマッチャーが用意されています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
import { describe, test, expect } from "vitest";
describe("マッチャーの例", () => {
// 等価性のテスト
test("toBe - 厳密等価(===)", () => {
expect(1 + 1).toBe(2);
expect("hello").toBe("hello");
});
test("toEqual - 深い等価性(オブジェクト・配列)", () => {
expect({ a: 1 }).toEqual({ a: 1 });
expect([1, 2, 3]).toEqual([1, 2, 3]);
});
// 真偽値のテスト
test("toBeTruthy / toBeFalsy", () => {
expect(true).toBeTruthy();
expect(1).toBeTruthy();
expect(false).toBeFalsy();
expect(0).toBeFalsy();
});
// 数値のテスト
test("toBeGreaterThan / toBeLessThan", () => {
expect(10).toBeGreaterThan(5);
expect(5).toBeLessThan(10);
});
// 文字列のテスト
test("toContain - 文字列・配列に含まれる", () => {
expect("Hello World").toContain("World");
expect([1, 2, 3]).toContain(2);
});
// 例外のテスト
test("toThrow - 例外がスローされる", () => {
const throwError = () => {
throw new Error("エラー発生");
};
expect(throwError).toThrow("エラー発生");
});
// 否定のテスト
test("not - 否定", () => {
expect(1).not.toBe(2);
expect([1, 2]).not.toContain(3);
});
});
|
React Testing Libraryの基本#
Testing Libraryの哲学#
React Testing Libraryは「ユーザーがどのようにアプリケーションを使うか」に焦点を当てたテストライブラリです。
The more your tests resemble the way your software is used, the more confidence they can give you.
(テストがソフトウェアの使用方法に似ているほど、より多くの自信を与えてくれる)
実装の詳細ではなく、ユーザーの視点からテストを書くことで、リファクタリング耐性の高いテストが実現できます。
コンポーネントのレンダリングテスト#
シンプルなコンポーネントをテストしてみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// src/components/Greeting.tsx
type GreetingProps = {
name: string;
};
export function Greeting({ name }: GreetingProps) {
return (
<div>
<h1>こんにちは、{name}さん</h1>
<p>React Testing Libraryへようこそ</p>
</div>
);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// src/components/Greeting.test.tsx
import { describe, test, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Greeting } from "./Greeting";
describe("Greeting", () => {
test("名前を含む挨拶が表示される", () => {
// Arrange & Act: コンポーネントをレンダリング
render(<Greeting name="太郎" />);
// Assert: 期待するテキストが表示されているか確認
expect(screen.getByText("こんにちは、太郎さん")).toBeInTheDocument();
expect(
screen.getByText("React Testing Libraryへようこそ")
).toBeInTheDocument();
});
test("見出しがh1タグで表示される", () => {
render(<Greeting name="花子" />);
// getByRole: アクセシビリティロールで要素を取得
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent("こんにちは、花子さん");
});
});
|
クエリの種類と使い分け#
Testing Libraryには様々なクエリが用意されています。
| クエリタイプ |
要素なし |
要素1つ |
要素複数 |
| getBy |
エラー |
要素を返す |
エラー |
| getAllBy |
エラー |
配列を返す |
配列を返す |
| queryBy |
null |
要素を返す |
エラー |
| queryAllBy |
[] |
配列を返す |
配列を返す |
| findBy |
エラー |
Promiseを返す |
エラー |
| findAllBy |
エラー |
Promiseを返す |
Promiseを返す |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import { render, screen } from "@testing-library/react";
describe("クエリの使い分け", () => {
test("getBy - 要素が存在することを確認", () => {
render(<button>クリック</button>);
// 要素が存在しないとエラーになる
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
});
test("queryBy - 要素が存在しないことを確認", () => {
render(<div>テスト</div>);
// 要素が存在しなくてもエラーにならない
const button = screen.queryByRole("button");
expect(button).not.toBeInTheDocument();
});
test("findBy - 非同期で要素を取得", async () => {
render(<AsyncComponent />);
// 要素が表示されるまで待機(デフォルト1秒)
const element = await screen.findByText("読み込み完了");
expect(element).toBeInTheDocument();
});
});
|
推奨されるクエリの優先順位#
Testing Libraryでは、以下の優先順位でクエリを使用することが推奨されています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
import { render, screen } from "@testing-library/react";
describe("クエリの優先順位", () => {
test("1. getByRole - アクセシビリティロールで取得(最優先)", () => {
render(<button>送信</button>);
// ユーザーが認識する方法に最も近い
expect(screen.getByRole("button", { name: "送信" })).toBeInTheDocument();
});
test("2. getByLabelText - フォーム要素のラベルで取得", () => {
render(
<label>
メールアドレス
<input type="email" />
</label>
);
expect(screen.getByLabelText("メールアドレス")).toBeInTheDocument();
});
test("3. getByPlaceholderText - プレースホルダーで取得", () => {
render(<input placeholder="名前を入力" />);
expect(screen.getByPlaceholderText("名前を入力")).toBeInTheDocument();
});
test("4. getByText - テキストコンテンツで取得", () => {
render(<p>説明文です</p>);
expect(screen.getByText("説明文です")).toBeInTheDocument();
});
test("5. getByTestId - data-testidで取得(最終手段)", () => {
render(<div data-testid="custom-element">カスタム要素</div>);
// 他の方法で取得できない場合のみ使用
expect(screen.getByTestId("custom-element")).toBeInTheDocument();
});
});
|
ユーザーイベントのテスト#
user-eventの基本#
@testing-library/user-eventは、実際のユーザー操作をより正確にシミュレートするライブラリです。fireEventよりも推奨されています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// src/components/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>
<button onClick={() => setCount((c) => c - 1)}>減らす</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
// src/components/Counter.test.tsx
import { describe, test, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
describe("Counter", () => {
test("初期値は0", () => {
render(<Counter />);
expect(screen.getByText("カウント: 0")).toBeInTheDocument();
});
test("増やすボタンでカウントが増加する", async () => {
// user-eventはsetup()を呼び出して使用する
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole("button", { name: "増やす" });
await user.click(incrementButton);
expect(screen.getByText("カウント: 1")).toBeInTheDocument();
});
test("減らすボタンでカウントが減少する", async () => {
const user = userEvent.setup();
render(<Counter />);
const decrementButton = screen.getByRole("button", { name: "減らす" });
await user.click(decrementButton);
expect(screen.getByText("カウント: -1")).toBeInTheDocument();
});
test("リセットボタンでカウントが0になる", async () => {
const user = userEvent.setup();
render(<Counter />);
// まずカウントを増やす
await user.click(screen.getByRole("button", { name: "増やす" }));
await user.click(screen.getByRole("button", { name: "増やす" }));
expect(screen.getByText("カウント: 2")).toBeInTheDocument();
// リセット
await user.click(screen.getByRole("button", { name: "リセット" }));
expect(screen.getByText("カウント: 0")).toBeInTheDocument();
});
});
|
フォーム入力のテスト#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
// src/components/LoginForm.tsx
import { useState } from "react";
type LoginFormProps = {
onSubmit: (data: { email: string; password: string }) => void;
};
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError("すべての項目を入力してください");
return;
}
if (password.length < 8) {
setError("パスワードは8文字以上で入力してください");
return;
}
setError("");
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password">パスワード</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p role="alert">{error}</p>}
<button type="submit">ログイン</button>
</form>
);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
// src/components/LoginForm.test.tsx
import { describe, test, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
test("フォームが正しくレンダリングされる", () => {
render(<LoginForm onSubmit={() => {}} />);
expect(screen.getByLabelText("メールアドレス")).toBeInTheDocument();
expect(screen.getByLabelText("パスワード")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "ログイン" })).toBeInTheDocument();
});
test("入力値が正しく反映される", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={() => {}} />);
const emailInput = screen.getByLabelText("メールアドレス");
const passwordInput = screen.getByLabelText("パスワード");
await user.type(emailInput, "test@example.com");
await user.type(passwordInput, "password123");
expect(emailInput).toHaveValue("test@example.com");
expect(passwordInput).toHaveValue("password123");
});
test("空のフォームを送信するとエラーが表示される", async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn();
render(<LoginForm onSubmit={mockSubmit} />);
await user.click(screen.getByRole("button", { name: "ログイン" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"すべての項目を入力してください"
);
expect(mockSubmit).not.toHaveBeenCalled();
});
test("パスワードが8文字未満だとエラーが表示される", async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText("メールアドレス"), "test@example.com");
await user.type(screen.getByLabelText("パスワード"), "short");
await user.click(screen.getByRole("button", { name: "ログイン" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"パスワードは8文字以上で入力してください"
);
expect(mockSubmit).not.toHaveBeenCalled();
});
test("正しい入力でフォームを送信するとonSubmitが呼ばれる", async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText("メールアドレス"), "test@example.com");
await user.type(screen.getByLabelText("パスワード"), "password123");
await user.click(screen.getByRole("button", { name: "ログイン" }));
expect(mockSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
expect(mockSubmit).toHaveBeenCalledTimes(1);
});
});
|
user-eventの主要なメソッド#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
import userEvent from "@testing-library/user-event";
describe("user-eventのメソッド", () => {
test("クリック操作", async () => {
const user = userEvent.setup();
render(<button>ボタン</button>);
// シングルクリック
await user.click(screen.getByRole("button"));
// ダブルクリック
await user.dblClick(screen.getByRole("button"));
// 右クリック
await user.pointer({ keys: "[MouseRight]", target: screen.getByRole("button") });
});
test("キーボード入力", async () => {
const user = userEvent.setup();
render(<input />);
const input = screen.getByRole("textbox");
// テキスト入力
await user.type(input, "Hello World");
// 入力をクリア
await user.clear(input);
// 特殊キー入力
await user.type(input, "{Enter}");
await user.type(input, "{Escape}");
await user.type(input, "{Backspace}");
});
test("セレクトボックス", async () => {
const user = userEvent.setup();
render(
<select>
<option value="a">オプションA</option>
<option value="b">オプションB</option>
</select>
);
await user.selectOptions(screen.getByRole("combobox"), "b");
expect(screen.getByRole("combobox")).toHaveValue("b");
});
test("ホバー", async () => {
const user = userEvent.setup();
render(<div>ホバー対象</div>);
await user.hover(screen.getByText("ホバー対象"));
await user.unhover(screen.getByText("ホバー対象"));
});
test("タブ移動", async () => {
const user = userEvent.setup();
render(
<>
<input placeholder="1つ目" />
<input placeholder="2つ目" />
</>
);
// タブキーでフォーカス移動
await user.tab();
expect(screen.getByPlaceholderText("1つ目")).toHaveFocus();
await user.tab();
expect(screen.getByPlaceholderText("2つ目")).toHaveFocus();
});
});
|
モックの使い方#
vi.fn()によるモック関数#
vi.fn()を使用すると、関数の呼び出しを追跡できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
import { describe, test, expect, vi } from "vitest";
describe("vi.fn()", () => {
test("関数が呼び出されたことを確認", () => {
const mockFn = vi.fn();
mockFn("引数1", "引数2");
// 呼び出されたことを確認
expect(mockFn).toHaveBeenCalled();
// 呼び出し回数を確認
expect(mockFn).toHaveBeenCalledTimes(1);
// 引数を確認
expect(mockFn).toHaveBeenCalledWith("引数1", "引数2");
});
test("戻り値を設定", () => {
const mockFn = vi.fn().mockReturnValue("モック値");
expect(mockFn()).toBe("モック値");
});
test("非同期関数のモック", async () => {
const mockAsyncFn = vi.fn().mockResolvedValue("非同期モック値");
const result = await mockAsyncFn();
expect(result).toBe("非同期モック値");
});
test("実装を指定", () => {
const mockFn = vi.fn().mockImplementation((a: number, b: number) => a + b);
expect(mockFn(1, 2)).toBe(3);
});
});
|
vi.mock()によるモジュールのモック#
外部モジュールをモックする場合はvi.mock()を使用します。
1
2
3
4
5
|
// src/services/api.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
// src/components/UserProfile.tsx
import { useEffect, useState } from "react";
import { fetchUser } from "../services/api";
type User = {
id: string;
name: string;
email: string;
};
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUser(userId)
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error}</p>;
if (!user) return <p>ユーザーが見つかりません</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
// src/components/UserProfile.test.tsx
import { describe, test, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
// モジュールをモック
vi.mock("../services/api", () => ({
fetchUser: vi.fn(),
}));
// モックされた関数をインポート
import { fetchUser } from "../services/api";
describe("UserProfile", () => {
beforeEach(() => {
// 各テスト前にモックをリセット
vi.clearAllMocks();
});
test("読み込み中が表示される", () => {
// モックが解決されないPromiseを返す
vi.mocked(fetchUser).mockReturnValue(new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByText("読み込み中...")).toBeInTheDocument();
});
test("ユーザー情報が表示される", async () => {
const mockUser = {
id: "1",
name: "田中太郎",
email: "tanaka@example.com",
};
vi.mocked(fetchUser).mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
// 非同期でユーザー情報が表示されるのを待つ
expect(await screen.findByText("田中太郎")).toBeInTheDocument();
expect(screen.getByText("tanaka@example.com")).toBeInTheDocument();
});
test("エラー時にエラーメッセージが表示される", async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error("ネットワークエラー"));
render(<UserProfile userId="1" />);
expect(await screen.findByText("エラー: ネットワークエラー")).toBeInTheDocument();
});
test("正しいuserIdでfetchUserが呼ばれる", async () => {
vi.mocked(fetchUser).mockResolvedValue({
id: "123",
name: "テスト",
email: "test@example.com",
});
render(<UserProfile userId="123" />);
await screen.findByText("テスト");
expect(fetchUser).toHaveBeenCalledWith("123");
expect(fetchUser).toHaveBeenCalledTimes(1);
});
});
|
vi.spyOn()によるスパイ#
既存のオブジェクトのメソッドを監視する場合はvi.spyOn()を使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
import { describe, test, expect, vi } from "vitest";
describe("vi.spyOn()", () => {
test("console.logをスパイ", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
console.log("テストメッセージ");
expect(consoleSpy).toHaveBeenCalledWith("テストメッセージ");
// スパイを元に戻す
consoleSpy.mockRestore();
});
test("localStorageをスパイ", () => {
const getItemSpy = vi.spyOn(Storage.prototype, "getItem");
getItemSpy.mockReturnValue("保存された値");
const value = localStorage.getItem("key");
expect(value).toBe("保存された値");
expect(getItemSpy).toHaveBeenCalledWith("key");
getItemSpy.mockRestore();
});
});
|
非同期処理のテスト#
waitForを使った待機#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
import { describe, test, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// 非同期で状態が変わるコンポーネント
function AsyncButton() {
const [status, setStatus] = useState("idle");
const handleClick = async () => {
setStatus("loading");
await new Promise((resolve) => setTimeout(resolve, 100));
setStatus("complete");
};
return (
<>
<button onClick={handleClick}>送信</button>
<p>{status}</p>
</>
);
}
describe("非同期処理のテスト", () => {
test("findByで非同期に表示される要素を取得", async () => {
const user = userEvent.setup();
render(<AsyncButton />);
await user.click(screen.getByRole("button"));
// findByは要素が見つかるまで待機する(デフォルト1秒)
expect(await screen.findByText("complete")).toBeInTheDocument();
});
test("waitForで条件を満たすまで待機", async () => {
const user = userEvent.setup();
render(<AsyncButton />);
await user.click(screen.getByRole("button"));
// waitForは条件を満たすまで繰り返しチェック
await waitFor(() => {
expect(screen.getByText("complete")).toBeInTheDocument();
});
});
test("waitForにタイムアウトを設定", async () => {
const user = userEvent.setup();
render(<AsyncButton />);
await user.click(screen.getByRole("button"));
await waitFor(
() => {
expect(screen.getByText("complete")).toBeInTheDocument();
},
{ timeout: 3000 } // 3秒まで待機
);
});
});
|
waitForElementToBeRemovedを使った要素の消失待機#
1
2
3
4
5
6
7
8
9
10
|
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
test("ローディング表示が消えるのを待つ", async () => {
render(<DataLoader />);
// 要素が消えるまで待機
await waitForElementToBeRemoved(() => screen.queryByText("読み込み中..."));
expect(screen.getByText("データ表示")).toBeInTheDocument();
});
|
テストのベストプラクティス#
1. ユーザー視点でテストを書く#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 悪い例: 実装の詳細をテスト
test("stateが更新される", () => {
const { result } = renderHook(() => useState(0));
act(() => result.current[1](1));
expect(result.current[0]).toBe(1);
});
// 良い例: ユーザーが見る結果をテスト
test("ボタンをクリックするとカウントが増える", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "増やす" }));
expect(screen.getByText("カウント: 1")).toBeInTheDocument();
});
|
2. アクセシビリティを意識したクエリを使う#
1
2
3
4
5
|
// 悪い例: data-testidに依存
const button = screen.getByTestId("submit-button");
// 良い例: ロールとアクセシブルな名前を使用
const button = screen.getByRole("button", { name: "送信" });
|
3. テストを独立させる#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 悪い例: テスト間で状態を共有
let component: RenderResult;
beforeAll(() => {
component = render(<MyComponent />);
});
// 良い例: 各テストで独立してレンダリング
test("テスト1", () => {
render(<MyComponent />);
// ...
});
test("テスト2", () => {
render(<MyComponent />);
// ...
});
|
4. 適切な粒度でテストを書く#
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 1つのテストで複数のことを検証しすぎない
// 悪い例
test("フォームの全機能", async () => {
// 入力テスト、バリデーションテスト、送信テストを全部...
});
// 良い例: 機能ごとにテストを分割
describe("LoginForm", () => {
test("初期状態で入力フィールドが空", () => {});
test("メールアドレスを入力できる", () => {});
test("空の状態で送信するとエラー", () => {});
test("正しい入力で送信成功", () => {});
});
|
5. テストの可読性を高める#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
describe("TodoList", () => {
// ヘルパー関数でセットアップを共通化
function renderTodoList(initialTodos: string[] = []) {
const user = userEvent.setup();
render(<TodoList initialTodos={initialTodos} />);
return {
user,
getInput: () => screen.getByRole("textbox"),
getAddButton: () => screen.getByRole("button", { name: "追加" }),
getTodos: () => screen.getAllByRole("listitem"),
};
}
test("Todoを追加できる", async () => {
const { user, getInput, getAddButton, getTodos } = renderTodoList();
await user.type(getInput(), "新しいTodo");
await user.click(getAddButton());
expect(getTodos()).toHaveLength(1);
expect(getTodos()[0]).toHaveTextContent("新しいTodo");
});
});
|
コードカバレッジ#
カバレッジの計測#
Vitestはv8を使用したコードカバレッジをサポートしています。
1
2
3
4
5
|
# カバレッジ計測用パッケージをインストール
npm install -D @vitest/coverage-v8
# カバレッジ付きでテスト実行
npm run test:coverage
|
vite.config.tsにカバレッジ設定を追加できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
exclude: [
"node_modules/",
"src/test/",
"**/*.d.ts",
"**/*.test.{ts,tsx}",
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});
|
期待される出力#
カバレッジレポートは以下のような形式で出力されます。
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 85.71 | 83.33 | 90.00 | 85.71 |
components/ | 88.89 | 85.71 | 100.00 | 88.89 |
Counter.tsx | 100.00 | 100.00 | 100.00 | 100.00 |
LoginForm.tsx | 83.33 | 75.00 | 100.00 | 83.33 |
utils/ | 80.00 | 80.00 | 75.00 | 80.00 |
math.ts | 100.00 | 100.00 | 100.00 | 100.00 |
--------------------|---------|----------|---------|---------|
まとめ#
本記事では、VitestとReact Testing Libraryを使用したReactコンポーネントのテスト方法について解説しました。
学んだ内容を振り返ります。
- テスト環境のセットアップ: Vitest、React Testing Library、jsdom、user-eventのインストールと設定
- 基本的なテストの書き方: describe、test、expect、マッチャーの使い方
- コンポーネントのテスト: render、screenを使ったレンダリングテスト
- クエリの使い分け: getBy、queryBy、findByの違いと優先順位
- ユーザーイベント: user-eventによるクリック、入力、選択のシミュレーション
- モック: vi.fn()、vi.mock()、vi.spyOn()の使い方
- 非同期処理: findBy、waitFor、waitForElementToBeRemovedの使い方
- ベストプラクティス: ユーザー視点、アクセシビリティ、テストの独立性
テストを書くことで、コードの品質と信頼性が大幅に向上します。最初は時間がかかりますが、長期的には開発効率の向上につながります。小さなコンポーネントから始めて、徐々にテストのカバレッジを広げていきましょう。
次に読む記事#
本記事でReactコンポーネントのテストを学びました。次は、Reactアプリケーションのパフォーマンス最適化について学びましょう。
Reactパフォーマンス最適化 - 無駄な再レンダリングを防ぐ(公開予定)
参考リンク#