コンポーネント テストの実践

コンポーネント テストは、実践的なテストコードを示すのに適しています。コンポーネント テストは、単純な単体テストよりも内容が充実しており、エンドツーエンド テストほど複雑でなく、DOM の操作のデモを行います。より哲学的な観点から言えば、React の使用により、ウェブ デベロッパーはウェブサイトやウェブアプリをコンポーネントで構成されていると考えるほうが簡単になりました。

したがって、複雑さにかかわらず個々のコンポーネントをテストすることは、新規または既存のアプリケーションのテストについて検討する良い方法です。

このページでは、複雑な外部依存関係を持つ小さなコンポーネントをテストする手順について説明します。他のコードとやり取りしないコンポーネントは簡単にテストできます。たとえば、ボタンをクリックして数値が増加することを確認します。実際には、そのようなコードはほとんどありません。インタラクションのないコードをテストしても、価値は限られます。

テスト対象のコンポーネント

Vitest とその JSDOM 環境を使用して React コンポーネントをテストします。これにより、ブラウザをエミュレートしながら、コマンドラインで Node を使用してテストをすばやく実行できます。

名前のリスト(各名前の横に [選択] ボタンがあります)。
ネットワーク内のユーザーのリストを表示する小さな React コンポーネント。

UserList という名前のこの React コンポーネントは、ネットワークからユーザーのリストを取得し、そのうちの 1 つを選択できるようにします。ユーザーのリストは useEffect 内の fetch を使用して取得され、選択ハンドラは Context によって渡されます。コードは次のとおりです。

import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';

export function UserList({ count = 4 }: { count?: number }) {
  const [users, setUsers] = useState<any[]>([]);
  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, [count]);

  const c = useContext(UserContext);
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            <button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
            <UserRow u={u} />
          </li>
        ))}
      </ul>
    </div>
  );
}

この例は React のベスト プラクティスを示しているわけではありません(useEffect 内で fetch を使用しています)。しかし、コードベースにはこのようなケースが多数含まれている可能性があります。さらに重要なのは、このようなケースは一見するとテストが難しいように見えることです。このコースの今後のセクションでは、テスト可能なコードの記述について詳しく説明します。

この例でテストする内容は次のとおりです。

  • ネットワークからのデータに応じて、正しい DOM が作成されることを確認します。
  • ユーザーをクリックするとコールバックがトリガーされることを確認します。

コンポーネントはそれぞれ異なります。このテストが興味深いのはなぜですか?

  • グローバル fetch を使用してネットワークから実際のデータをリクエストします。テスト中に不安定になったり、遅くなったりする可能性があります。
  • 別のクラス UserRow をインポートしますが、このクラスは暗黙的にテストしたくない場合があります。
  • テスト対象のコードに固有ではない Context を使用し、通常は親コンポーネントから提供されます。

簡単なテストを作成して開始する

このコンポーネントの基本的な部分を簡単にテストできます。なお、この例はあまり役に立ちません。ただし、UserList.test.tsx というピアファイルにボイラープレートを設定すると便利です(Vitest などのテストランナーはデフォルトで、.test.js などで終わるファイル(.tsx を含む)を実行します)。

import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';

test('render', async () => {
  const c = render(<UserList />);

  const headingNode = await c.findAllByText(/Users);
  assert.isNotNull(headingNode);
});

このテストでは、コンポーネントがレンダリングされたときに「ユーザー」というテキストが含まれていることをアサートします。コンポーネントには、fetch をネットワークに送信するという副作用がある場合でも機能します。テストの終了時点で fetch は進行中であり、エンドポイントは設定されていません。少なくともタイムアウトを待たずに、テストの終了時にユーザー情報が表示されていることを確認することはできません。

fetch() のモック

モックとは、実際の関数またはクラスを、テストで制御できるものに置き換えることです。最もシンプルな単体テストを除き、ほぼすべての種類のテストで一般的な方法です。詳しくは、アサーションとその他のプリミティブをご覧ください。

テスト用に fetch() をモックすると、テストが迅速に完了し、「実世界」や未知のデータではなく、想定どおりのデータを返すことができます。fetchグローバルであるため、コードに import または require する必要はありません。

vitest では、vi.fn() によって返された特別なオブジェクトを使用して vi.stubGlobal を呼び出すことで、グローバルをモックできます。これにより、後で変更できるモックが作成されます。これらのメソッドの詳細については、このコースの後半のセクションで説明しますが、次のコードで実践できます。

test('render', async () => {
  const fetchMock = vi.fn();
  fetchMock.mockReturnValue(
    Promise.resolve({
      json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
    }),
  );
  vi.stubGlobal('fetch', fetchMock);

  const c = render(<UserList />);

  const headingNode = await c.queryByText(/Users);
  assert.isNotNull(headingNode);

  await waitFor(async () => {
    const samNode = await c.queryByText(/Sam);
    assert.isNotNull(samNode);
  });
});

afterAll(() => {
  vi.unstubAllGlobals();
});

このコードは、モックを追加し、ネットワーク取得 Response の偽のバージョンを記述してから、その出現を待ちます。テキストが表示されない場合(queryByText のクエリを新しい名前に変更して確認できます)、テストは失敗します。

この例では Vitest の組み込みモック ヘルパーを使用していますが、他のテスト フレームワークでもモックに同様のアプローチが使用されています。Vitest は、すべてのテストの後に vi.unstubAllGlobals() を呼び出すか、同等のグローバル オプションを設定しなければならないという点で独特です。作業を「元に戻す」ことなく、fetch モックが他のテストに影響を与える可能性があり、すべてのリクエストが JSON の奇妙な山で応答されます。

モック インポート

UserList コンポーネント自体が UserRow というコンポーネントをインポートしていることに気づいたかもしれません。コードは含まれていませんが、ユーザー名がレンダリングされていることがわかります。前のテストでは「Sam」をチェックしていますが、これは UserList 内で直接レンダリングされないため、UserRow から取得する必要があります。

コンポーネント内でのユーザー名の移動を示すフローチャート。
UserListTest には UserRow の公開設定がありません。

ただし、UserRow 自体が複雑なコンポーネントである可能性があります。さらにユーザーデータを取得したり、テストに関連しない副作用が生じたりする場合があるためです。このようなばらつきを取り除くことで、テストの有用性が向上します。特に、テストするコンポーネントがより複雑になり、依存関係が複雑になってきた場合に有用です。

幸い、Vitest を使用すると、テストで直接使用しない場合でも、特定のインポートをモック化できます。これにより、インポートを使用するコードにシンプルなバージョンまたは既知のバージョンが提供されます。

vi.mock('./UserRow.tsx', () => {
  return {
    UserRow(arg) {
      return <>{arg.u.name}</>;
    },
  }
});

test('render', async () => {
  // ...
});

fetch グローバルのモック化と同様に、これは強力なツールですが、コードに多くの依存関係がある場合は、持続できなくなる可能性があります。繰り返しになりますが、テスト可能なコードを記述することが最善の解決策です。

クリックしてコンテキストを提供する

React や Lit などの他のライブラリには、Context というコンセプトがあります。サンプルコードには UserContext が含まれています。これは、ユーザーが選択された場合にメソッドを呼び出します。これは、コールバックが UserList に直接渡される「プロップ ドリル」の代替手段としてよく見られます。

テストハーネスは UserContext を提供していません。クリック アクションを追加せずに React テストに追加すると、最悪の場合テストがクラッシュする可能性があります。デフォルト インスタンスが他の場所で指定されている場合は、Google の制御不能な動作(上記の未知の UserRow と同様)を引き起こす可能性があります。

  const c = render(<UserList />);
  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();

代わりに、コンポーネントのレンダリング時に独自の Context を指定できます。この例では、Vitest 疑似関数である vi.fn() のインスタンスを使用します。このインスタンスを使用すると、呼び出しが行われたことと、使用された引数を確認できます。

この例では、これは前述の例でモックされた fetch と連携し、渡された ID が sam であることをテストで確認できます。

  const userChosenFn = vi.fn();
  const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
  const c = render(
    <UserContext.Provider value={ucForTest}>
      <UserList />
    </UserContext.Provider>,
  );

  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();
  assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);

これはシンプルながら強力なパターンで、テスト対象のコア コンポーネントから無関係な依存関係を削除できます。

まとめ

この例では、テストが難しい React コンポーネントをテストして保護するためのコンポーネント テストを作成する方法を示しました。このテストでは、コンポーネントが依存関係(fetch グローバル、インポートされたサブコンポーネント、Context)と正しくやり取りすることを確認しました。

理解度を確認する

React コンポーネントのテストにどのようなアプローチが使用されましたか?

数値がインクリメントされたことを確認する
Context を使用した依存関係インジェクション
テスト用に複雑な依存関係を単純な依存関係でモックする
グローバル変数のスタブ化