見出し画像

React - The Complete Guide 2025 (incl. Next.js, Redux): ユニットテスト: Reactコンポーネントの自動テストを基礎から学び、Jest と React Testing Library を使った実践的なテスト手法を身につける (第31/40課)

  • 手動テストと自動テストの違いと、両方が重要である理由

  • ユニットテスト、統合テスト、E2Eテストの3つのカテゴリとテストピラミッド

  • Jest と React Testing Library を使ったReactコンポーネントのテスト方法

  • ユーザー操作、非同期コード、モックを含む実践的なテストの書き方


React -- The Complete Guide 2025 (incl. Redux) のここまでの学習で、propsを使ったコンポーネントの構築、useStateによる状態管理、useEffectによるデータ取得、ルーティングの設定、さらにはNext.jsのようなフレームワークの探求まで行ってきました。機能を追加するたびに、同じことを繰り返してきたはずです。ブラウザを開き、あちこちクリックして、変更が正しく表示されているか確認する、と。

これが手動テストであり、それ自体は絶対に欠かせないものです。ユーザーが見るものと同じものを確認できます。しかし、手動テストには機能を追加するたびに大きくなる死角があります。アプリの一箇所を変更したとき、他のすべてのページ、すべてのボタン、すべてのフォームを再確認しに戻るでしょうか?もちろんそんなことはしません。まさにそこにバグが紛れ込む隙が生まれるのです。

このセクションでは 自動テスト を紹介します。コードをテストするコードを書くということです。少しメタな話に聞こえますが、これはモダン開発において最も重要なスキルの一つです。最初の概念から、Reactコンポーネントの実際のテストを書くところまで、ゆっくり見ていきましょう。

なぜ自動テストが重要なのか

これまでに構築したすべてのReactプロジェクトを思い出してください。コンポーネントを書き、ファイルを保存し、ブラウザに切り替えて、うまく動いているか確認しました。このワークフローは自然なものであり、なくなることはありません。しかし、次のシナリオを考えてみてください。アプリには5つのページにまたがる20個のコンポーネントがあります。共有ユーティリティ関数のロジックを修正します。作業中のページをテストします。問題なし。しかし、そのユーティリティ関数は別のページの3つのコンポーネントでも使われていました。それらも確認しましたか?

おそらくしていないでしょう。そしてこれこそが、手動テストだけに頼ることの根本的な問題です。アプリケーションが大きくなるにつれて、変更のたびにすべての機能を手動で検証することは不可能になります。今日導入されたバグが、数日後や数週間後まで気づかれないかもしれません。発見した頃には、原因の追跡ははるかに困難になっています。

自動テストはこれを逆転させます。テストを一度書けば、毎回実行されます。変更のたびに、カバーするすべてのコンポーネントにわたって実行されます。何かが壊れれば、テストは即座に失敗し、何が間違っていたかを正確に教えてくれます。テストを書くコストは一度だけ支払い、実行する恩恵は永遠に受け続けることができます。

これは、自動テストが手動テストを置き換えるという意味ではありません。両者は補完的な関係にあります。手動テストは主観的な問題を捕捉します。レイアウトの見た目は適切か?アニメーションは滑らかか?自動テストはリグレッションを捕捉します。コードの変更が、以前動いていたものを誤って壊していないか?両方を合わせることで、完全なセーフティネットを形成します。

自動テストの3つのカテゴリ

すべてのテストが同じというわけではありません。スコープのレベルが異なる3つの主要カテゴリがあります。

ユニットテスト は基盤です。アプリケーションの最小単位を分離してテストします。単一の関数、単一のコンポーネント、単一のフックです。小さくて焦点が絞られているため、高速に実行でき、失敗の診断が容易です。個々のピースがそれぞれ正しく動作すれば、アプリケーション全体も正しく動作する可能性が非常に高くなります。典型的なプロジェクトには数十、あるいは数百のユニットテストが含まれます。

統合テスト は、複数のビルディングブロックが正しく連携して動作するかを検証します。例えば、複数の子コンポーネントをレンダリングする親コンポーネントが正しい出力を生成するか?コンポーネントは依存するカスタムフックと適切にやり取りするか?ユニットテストよりも統合テストの数は少なくなりますが、それでも極めて重要です。興味深いことに、Reactではユニットテストと統合テストの境界がしばしば曖昧です。親コンポーネントをテストすると必然的にその子コンポーネントもレンダリングされるため、単一のコンポーネントをテストしている感覚でも、技術的には統合テストになっています。

エンドツーエンド(E2E)テスト は、完全なユーザーワークフローを再現します。ログイン、ページへの遷移、フォームの入力、送信といった一連の操作です。Cypress、Playwright、Seleniumなどのツールを使って、実際の(またはヘッドレスの)ブラウザを自動操作します。E2Eテストは最もリアルですが、最も遅く、メンテナンスも最も大変です。通常は最も重要なユーザーパスをカバーするものだけを少数書くことになります。

このセクションでは、ユニットテストと統合テストに焦点を当てます。これらは、しばしば テストピラミッド と呼ばれるものの基盤を形成します。底部に多数のユニットテスト、中間にいくつかの統合テスト、頂部に少数のE2Eテストという構造です。

何をテストし、どうテストするか

テストコードを書く前に、2つの質問に答える必要があります。

何をテストすべきか? アプリケーションの個々のビルディングブロックをテストします。各テストは1つの主要なことを検証すべきです。少数の大きく広範なテストよりも、多数の小さく焦点を絞ったテストを持つことが目標です。小さなテストが失敗すれば、理由は即座に明らかです。大きなテストが失敗すると、十数個の原因のいずれかである可能性があり、デバッグが頭痛の種になります。

どうテストすべきか? 各ビルディングブロックについて、3つのカテゴリのシナリオを考えます。

  • 成功ケース — ハッピーパスです。コンポーネントは正しいテキストをレンダリングするか?ボタンをクリックすると正しい結果が得られるか?

  • エラーケース — 問題が発生したときにどうなるか?バリデーション失敗時に正しいエラーメッセージが表示されるか?

  • エッジケース — まれだが起こりうるシナリオです。リストが空の場合はどうなるか?入力に予期しない値が含まれている場合はどうなるか?

各小さなビルディングブロックについて、成功、エラー、エッジケースを体系的にカバーすることで、ユーザー操作のあらゆる範囲にわたるバグを捕捉する包括的なカバレッジを構築します。

テストスタック:JestとReact Testing Library

Reactコンポーネントの自動テストを実行するには、2つの異なる役割を担う2つのツールが必要です。

Jest はテストランナーおよびアサーションライブラリです。テストファイル(.test.jsまたは.spec.jsで終わるファイル)を検出し、その中のテスト関数を実行し、各テストが成功したか失敗したかを報告します。Jestはテストを定義するためのtest()関数、アサーションを行うためのexpect()関数、そしてモック、コードカバレッジ、ウォッチモードなどの機能を提供します。

React Testing Library (@testing-library/react)は2つ目の役割を担います。Reactコンポーネントをシミュレートされた DOMにレンダリングし、テストが出力を検査したりユーザー操作をシミュレートしたりできるようにします。実際のブラウザを開く必要はありません。コンポーネントをマウントするためのrender()と、結果のDOMをクエリするためのscreenを提供します。その設計思想は、内部の状態変数のような実装の詳細ではなく、表示テキスト、ロール、ラベルなど、実際のユーザーが操作する方法でコンポーネントをテストすることです。

さらに2つのパッケージがツールキットを完成させます。

  • jest-dom (@testing-library/jest-dom)は、Jestのexpect()をDOM固有のマッチャーで拡張します。toBeInTheDocument()、toHaveTextContent()、toBeVisible()などです。これらはsetupTests.jsでグローバルに読み込まれるため、すべてのテストファイルで個別にインポートせずに使用できます。

  • user-event (@testing-library/user-event)は、低レベルのfireEvent APIよりも忠実に、リアルなユーザー操作(クリック、タイピング、ホバーなど)をシミュレートします。

Create React Appで作成されたプロジェクトを使用している場合、これらのツールはすべてプリインストールおよび事前設定されています。@testing-libraryパッケージはpackage.jsonに記載されており、Jest自体はreact-scriptsの推移的依存関係としてバンドルされています。npm testを実行すると、react-scriptsを通じてJestが自動的に呼び出されます。追加のセットアップは一切不要です。

最初のテストを実行する

すべてのCreate React Appプロジェクトには、App.jsのすぐ隣にApp.test.jsというファイルが同梱されています。この命名規則は重要で、テストファイルはカバーするソースファイルの名前を反映し、拡張子の前に.testが挿入されます。

App.js       → App.test.js
Greeting.js  → Greeting.test.js
Header.js    → Header.test.js

Jestはこのパターンに一致するファイルを自動的に検出し、テストファイルとして扱います。名前に.testまたは.specを含まない.jsファイルは、テストランナーによって完全に無視されます。

テストを実行するには、ターミナルを開いて以下を実行します。

npm test

これにより、Jestが ウォッチモード で起動します。ソースファイルとテストファイルの変更を監視します。ファイルを保存すると、Jestは関連するテストを自動的に再実行します。aを押すとすべてのテストを実行し、qを押すと終了します。

テストが成功すると、緑色のチェックマークとPASSの文字が表示されます。

PASS  src/App.test.js
  ✓ renders learn react link (23 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

テストが失敗すると、出力は劇的に変わります。赤いバツ印、FAILの文字、そして何が間違っていたかの詳細な説明が表示され、コンポーネントが実際に生成した完全なHTMLとテストが期待していたものの比較が含まれます。

この即座のフィードバックループが自動テストの核心的な価値です。何かを変更し、保存すれば、何かを壊していないかすぐにわかります。

最初のカスタムテストを書く:3つのA

スターターテストを超えて、ゼロからテストを書いてみましょう。シンプルなGreetingコンポーネントを作成してテストします。

// src/components/Greeting.js
export default function Greeting() {
  return (
    <div>
      <h2>Hello World!</h2>
      <p>It's good to see you.</p>
    </div>
  );
}

次にテストファイルです。コロケーション規則に従い、同じディレクトリにGreeting.test.jsを作成します。

// src/components/Greeting.test.js
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('renders Hello World as a text', () => {
  // ARRANGE — コンポーネントをレンダリング
  render(<Greeting />);

  // ACT — ここでは何もしない(初期レンダリングのテスト)

  // ASSERT — 出力を確認
  const helloWorldElement = screen.getByText('Hello World!');
  expect(helloWorldElement).toBeInTheDocument();
});

よく構造化されたテストはすべて AAAパターン に従います。

  1. Arrange(準備) — テスト環境をセットアップします。コンポーネントをレンダリングし、必要なpropsを提供し、必要に応じて依存関係をモックします。

  2. Act(実行) — テスト対象のアクションを実行します。ボタンのクリック、入力フィールドへの入力、送信のトリガーなど。初期レンダリングの出力のみを検証するテストでは、このステップは完全に省略できます。

  3. Assert(検証) — 結果を確認します。レンダリングされたDOMで期待される要素をクエリし、マッチャーで検証します。

screenクエリに関するいくつかの重要な詳細について説明します。クエリメソッドには3つのファミリーがあり、要素が見つからない場合の動作がそれぞれ異なります。

  • getBy... は要素を即座に返します。一致するものが見つからない場合はエラーをスローします。要素が今すぐ存在することを期待する場合に使用します。

  • queryBy... は要素を返すか、見つからない場合はnullを返します。スローしません。要素が存在 しない ことをアサートする必要がある場合に使用します。

  • findBy... は要素が表示されたときに解決されるプロミスを返します。非同期的に表示される要素(フェッチ完了後など)に使用します。

各ファミリーには、異なるクエリ戦略のバリアントがあります。ByText、ByRole、ByLabelText、ByPlaceholderText、ByAltText、ByTitle、ByTestIdなどです。

デフォルトでは、getByTextは完全一致を行います。第2引数として{ exact: false }を渡すと、大文字小文字を無視し、部分文字列の一致も許容するようになります。

テストスイートによるテストのグループ化

テストファイルが大きくなると、関連するテストをラベル付きのカテゴリにまとめたくなります。describe()関数が テストスイート を作成します。

describe('Greeting component', () => {
  test('renders Hello World as a text', () => {
    render(<Greeting />);
    const helloWorldElement = screen.getByText('Hello World!');
    expect(helloWorldElement).toBeInTheDocument();
  });
});

test()やexpect()と同様に、describe()もグローバルに利用可能で、インポートは不要です。2つの引数を取ります。説明文字列と、test()呼び出しを含むコールバック関数です。

ターミナルの出力は、フラットなリストからグループ化されたインデント構造に変わります。

PASS  src/components/Greeting.test.js
  Greeting component
    ✓ renders Hello World as a text (4 ms)

テストファイルが数十個ある場合、これは不可欠になります。さらに細かい分類のために、describe()ブロックを入れ子にすることもできます。

ユーザー操作と状態変化のテスト

静的なコンポーネントのテストは簡単です。自動テストの真の価値は、状態とユーザー操作を持つコンポーネントで発揮されます。Greetingコンポーネントをアップグレードしましょう。

// src/components/Greeting.js
import { useState } from 'react';

export default function Greeting() {
  const [changedText, setChangedText] = useState(false);

  const changeTextHandler = () => {
    setChangedText(true);
  };

  return (
    <div>
      <h2>Hello World!</h2>
      {!changedText && <p>It's good to see you.</p>}
      {changedText && <p>Changed!</p>}
      <button onClick={changeTextHandler}>Change Text</button>
    </div>
  );
}

ここでは複数のシナリオをテストする必要があります。重要な原則は、1つだけでなくすべての可能な状態をテストすることです。そうしなければ死角が残ります。

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Greeting from './Greeting';

describe('Greeting component', () => {
  test('renders Hello World as a text', () => {
    render(<Greeting />);
    const helloWorldElement = screen.getByText('Hello World!');
    expect(helloWorldElement).toBeInTheDocument();
  });

  test('renders good to see you if the button was not clicked', () => {
    render(<Greeting />);
    const outputElement = screen.getByText('good to see you', { exact: false });
    expect(outputElement).toBeInTheDocument();
  });

  test('renders Changed! if the button was clicked', () => {
    render(<Greeting />);
    const buttonElement = screen.getByRole('button');
    userEvent.click(buttonElement);
    const outputElement = screen.getByText('Changed!');
    expect(outputElement).toBeInTheDocument();
  });

  test('does not render good to see you if the button was clicked', () => {
    render(<Greeting />);
    const buttonElement = screen.getByRole('button');
    userEvent.click(buttonElement);
    const outputElement = screen.queryByText('good to see you', {
      exact: false,
    });
    expect(outputElement).toBeNull();
  });
});

ここでいくつかの重要なテクニックが登場します。

ユーザーイベントのシミュレーション。 @testing-library/user-eventのuserEventオブジェクトを使って、リアルなユーザー操作をトリガーできます。userEvent.click(element)はマウスクリックをシミュレートし、userEvent.type(element, text)はタイピングをシミュレートします。各メソッドは、第1引数として対象のDOM要素が必要です。

ロールによる要素の選択。 screen.getByRole('button')はARIAロールで要素を選択します。HTML要素には暗黙のロールがあります。<button>のロールは"button"、<a>のロールは"link"などです。同じロールを持つ要素が複数ある場合は、{ name: '...' }オプションを渡して区別します。

不在のアサーション。 4番目のテストは見落としやすいですが、非常に重要です。開発者が最初の段落の条件チェックを誤って削除し、両方の段落が同時にレンダリングされるようになったと想像してください。テスト1から3はそれでも成功します。新しいテキストは表示されますし、チェックしている内容に変化はありません。古い段落が 消える ことを明示的に検証する4番目のテストだけが、そのバグを捕捉できます。

getByTextではなくqueryByTextを使用していることに注目してください。存在しないと期待する要素にgetByTextを使うと、アサーションが実行される前にエラーがスローされてしまいます。queryByTextは代わりにnullを返すため、toBeNull()でアサートできます。

サバイバルのヒント: 条件付きレンダリングのテストを書くときは、常に両面をテストしてください。何が 表示される かと、何が 消える かの両方です。不在テストこそ、巧妙なバグを捕まえる場所です。

接続されたコンポーネントのテスト

コンポーネントが他のコンポーネントをレンダリングする場合はどうなるでしょうか?段落を再利用可能なOutputラッパーに抽出したとしましょう。

// src/components/Output.js
export default function Output(props) {
  return <p>{props.children}</p>;
}

そしてGreetingを更新して使用します。

{!changedText && <Output>It's good to see you.</Output>}
{changedText && <Output>Changed!</Output>}

良いニュースがあります。 既存のテストは一切変更する必要がありません。 React Testing Libraryのrender()はコンポーネントツリー全体を走査し、親とともに子コンポーネントもレンダリングします。最終的なDOMには同じテキストが<p>要素に含まれているため、すべてのクエリとアサーションは同一に動作します。

厳密に言えば、これらのテストは統合テストになっています。GreetingとOutputを一緒にテストしているからです。しかし、独自のロジックを持たないシンプルなラッパーコンポーネントの場合、これで全く問題ありません。ラッパーコンポーネントが独自の状態や複雑な動作を含むようにならない限り、別のOutput.test.jsは必要ありません。

このユニットテストと統合テストの境界の曖昧さは、Reactでは普通のことです。気にする必要はありません。重要なのは、テストがコンポーネントの正しい動作に対する自信を与えてくれることです。

非同期コードのテスト

非同期にデータを取得するコンポーネントは、独特の課題をもたらします。APIから投稿を読み込む次のコンポーネントを考えてみましょう。

// src/components/Async.js
import { useState, useEffect } from 'react';

export default function Async() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/posts')
      .then((response) => response.json())
      .then((data) => {
        setPosts(data);
      });
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

このコンポーネントは2回レンダリングされます。最初は空の配列で即座にレンダリングされ(HTTPレスポンスが到着する前)、フェッチが完了した後に再度レンダリングされます。テストでgetAllByRole('listitem')を使おうとすると、呼び出された正確な瞬間、つまり初期の空のレンダリング中のDOMを検査するため、失敗します。

解決策はfindAllByRoleです。これはプロミスを返します。React Testing LibraryはDOMを定期的に再評価し、一致する要素が表示されるのを待ちます。

test('renders posts if request succeeds', async () => {
  render(<Async />);
  const listItemElements = await screen.findAllByRole('listitem');
  expect(listItemElements).not.toHaveLength(0);
});

findAllByRoleはプロミスを返すため、テスト関数はasyncで宣言し、クエリはawaitする必要があります。Jestは結果を評価する前に、プロミスが解決されるのを自動的に待ちます。

findクエリのデフォルトタイムアウトは1秒です。非同期操作にそれ以上の時間がかかる場合は、延長できます。

await screen.findAllByRole('listitem', {}, { timeout: 3000 });

また「All」バリアントにも注目してください。getAllByRoleとfindAllByRoleは要素の配列を返しますが、getByRoleとfindByRoleは単一の要素を返します(一致するものが複数見つかった場合はスローします)。

モックの使い方

上記のテストには深刻な問題があります。実行するたびに実際のHTTPリクエストを送信してしまうのです。これは以下のいくつかの理由で問題があります。

  • ネットワークトラフィック — テストは開発中に頻繁に実行されます。数百のテストからのリクエストで実際のサーバーに負荷をかけるのは無駄です。

  • 副作用 — POST、PUT、DELETEリクエストを送信するコンポーネントは、テスト中にサーバー上の実データを変更してしまう可能性があります。テストが現実世界に副作用を及ぼすべきではありません。

  • 信頼性 — サーバーがダウンしていたりレート制限されていたりすると、コードとは無関係な理由でテストが失敗します。

  • 速度 — ネットワークのラウンドトリップはレイテンシを増加させます。モック関数は即座に解決します。

ここでの重要な原則を押さえておきましょう。 自分が書いていないコードをテストするな ということです。組み込みのfetch()関数はブラウザが提供するものです。ブラウザベンダーが正しく実装していると信頼しています。テストしたいのは、受け取ったレスポンスに基づいてコンポーネントがどのように 振る舞う かです。モックを使えば、そのレスポンスを制御できます。

Jestでfetchをモックする方法を示します。

test('renders posts if request succeeds', async () => {
  // 実際のfetchをモック関数で上書き
  window.fetch = jest.fn();

  // 呼び出されたときにモックが返す値を定義
  window.fetch.mockResolvedValueOnce({
    json: async () => [{ id: 'p1', title: 'First post' }],
  });

  render(<Async />);

  const listItemElements = await screen.findAllByRole('listitem');
  expect(listItemElements).not.toHaveLength(0);
});

jest.fn()はテストの間、実際のfetchを置き換えるモック関数を作成します。.mockResolvedValueOnce()は、次の呼び出し時にプロミスが解決する値を設定します。戻り値は実際のfetchのResponseオブジェクトを模倣する構造にします。つまり、自身もプロミスを返す.json()メソッドを持つオブジェクトです。

コンポーネントは通常通りfetch()を呼び出しますが、ネットワークリクエストの代わりに、制御されたレスポンスを受け取ります。モックは即座に解決するため(ネットワーク遅延なし)、テストがはるかに高速になります。

window.fetchを通常の関数で上書きすることもできますが、jest.fn()には追加の機能があります。関数が何回呼び出されたか、どのような引数を受け取ったかの追跡、連続する呼び出しに対する異なる戻り値のサポートなどです。これらの機能は、より高度なテストシナリオで価値を発揮します。

まとめ:テストの基本ワークフロー

このセクションでは、Reactアプリケーションのテストに関する確かな入門を提供しました。テストはそれだけで1つのコース全体を埋められるほど深いトピックですが、ここで学んだ基本ワークフローが土台となります。

  1. @testing-library/reactのrender()でコンポーネントを レンダリング する

  2. screen(getBy、queryBy、findByとそのバリアント)でDOMを クエリ する

  3. @testing-library/user-eventのuserEventでユーザー操作を シミュレート する

  4. expect()とjest-domマッチャーで結果を アサート する

  5. jest.fn()で外部依存関係(fetchなど)を モック する

このセクションでカバーしたすべてのクイックリファレンスです。

  • 手動テストと自動テスト、そして両方が重要である理由

  • 3つのカテゴリ(ユニット、統合、E2E)とテストピラミッド

  • 何をテストするか(個々のビルディングブロック)とどうテストするか(成功、エラー、エッジケース)

  • テストランナーとしてのJestとコンポーネントレンダラーとしてのReact Testing Library

  • テストファイルの命名規則(*.test.js)とnpm testのウォッチモード

  • AAAパターン(Arrange、Act、Assert)

  • describe()スイートによるテストの整理

  • userEventを使った状態変化とユーザー操作のテスト

  • 接続されたコンポーネントの統合テスト(なぜテストがそのまま動くことが多いのか)

  • フェッチデータに対するfindByクエリを使った非同期テスト

  • jest.fn()とmockResolvedValueOnceを使ったHTTPリクエストのモック

より深い探求のために、3つのリソースをブックマークする価値があります。

  • Jestドキュメントjestjs.io)— マッチャー、非同期テストパターン、モックについて詳しく解説しています。Jestは汎用のJavaScriptテストツールであるため、ドキュメント内のセットアップ手順はCRAプロジェクトではスキップできます。

  • React Testing Libraryドキュメントtesting-library.com)— 完全なクエリAPI、イベントシミュレーション、非同期パターンの決定版リファレンスです。Core APIセクションでは、クエリメソッドの違いを詳しく説明しています。

  • React Hooks Testing Librarygithub.com/testing-library/react-hooks-testing-library)— カスタムフックを分離してテストするための専用拡張です。なお、renderHookはバージョン13.1以降@testing-library/reactに統合されたため、新しいプロジェクトでは直接そこからインポートできます。

この教材にはじっくり時間をかけてください。以前のコースプロジェクトの1つでいくつかのテストを書いてみましょう。条件付きレンダリングを含むコンポーネントを選び、ワークフロー全体を練習してください。テストの筋力は反復で鍛えられ、一度コツを掴めば、テストなしでどうやってコードを出荷していたのか不思議に思うことでしょう。


「超在宅のドンキー」へ

いいなと思ったら応援しよう!