はじめに

前回の記事では、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コンポーネントのテスト方法について解説しました。

学んだ内容を振り返ります。

  1. テスト環境のセットアップ: Vitest、React Testing Library、jsdom、user-eventのインストールと設定
  2. 基本的なテストの書き方: describe、test、expect、マッチャーの使い方
  3. コンポーネントのテスト: render、screenを使ったレンダリングテスト
  4. クエリの使い分け: getBy、queryBy、findByの違いと優先順位
  5. ユーザーイベント: user-eventによるクリック、入力、選択のシミュレーション
  6. モック: vi.fn()、vi.mock()、vi.spyOn()の使い方
  7. 非同期処理: findBy、waitFor、waitForElementToBeRemovedの使い方
  8. ベストプラクティス: ユーザー視点、アクセシビリティ、テストの独立性

テストを書くことで、コードの品質と信頼性が大幅に向上します。最初は時間がかかりますが、長期的には開発効率の向上につながります。小さなコンポーネントから始めて、徐々にテストのカバレッジを広げていきましょう。

次に読む記事

本記事でReactコンポーネントのテストを学びました。次は、Reactアプリケーションのパフォーマンス最適化について学びましょう。

Reactパフォーマンス最適化 - 無駄な再レンダリングを防ぐ(公開予定)

参考リンク