React統合テストでコードの保守性を向上させる

公開: 2022-03-11

統合テストは、テストのコストと価値の間のスイートスポットです。 コンポーネントユニットテストの代わりに、またはコンポーネントユニットテストに加えて、react-testing-libraryを使用してReactアプリの統合テストを作成すると、開発速度を損なうことなくコードの保守性を向上させることができます。

先に進む前に有利なスタートを切りたい場合は、reactアプリ統合テストにreact-testing-libraryを使用する方法の例をここで確認できます。

統合テストに投資する理由

「統合テストは、自信とスピード/費用の間のトレードオフで大きなバランスを取ります。これが、あなたの努力のほとんど(すべてではない)をそこで費やすことをお勧めする理由です。」
書き込みテストでのケントC.ドッド。 あまり多くありません。 主に統合。

多くの場合、Reactの「酵素」をテストするための一般的なライブラリを使用して、Reactコンポーネントの単体テストを作成するのが一般的な方法です。 具体的には、その「浅い」方法です。 このアプローチにより、アプリの他の部分から分離してコンポーネントをテストできます。 ただし、Reactアプリの作成はコンポーネントの作成がすべてであるため、単体テストだけではアプリにバグがないことを確認できません。

たとえば、コンポーネントの受け入れられた小道具を変更し、関連する単体テストを更新すると、すべてのテストに合格する可能性がありますが、別のコンポーネントがそれに応じて更新されなかった場合、アプリはまだ壊れている可能性があります。

統合テストは、コンポーネントの構成が目的のUXになることを確認するため、Reactアプリに変更を加える際の安心感を維持するのに役立ちます。

Reactアプリ統合テストの要件

統合テストを作成するときにReact開発者がやりたいことのいくつかを次に示します。

  • ユーザーの観点からアプリケーションのユースケースをテストします。 ユーザーはWebページの情報にアクセスし、使用可能なコントロールを操作します。
  • 模擬API呼び出しは、テストの合格/不合格のAPIの可用性と状態に依存しません。
  • ブラウザAPI(ローカルストレージなど)は、テスト環境には存在しないため、モックします。
  • React DOM状態(ブラウザーDOMまたはネイティブモバイル環境)でアサートします。

さて、Reactアプリ統合テストを書くときに避けるべきいくつかのことについて:

  • 実装の詳細をテストします。 実装の変更は、実際にバグが発生した場合にのみテストを中断する必要があります。
  • 嘲笑しすぎる。 アプリのすべての部分がどのように連携しているかをテストしたいと思います。
  • 浅いレンダリング。 アプリ内のすべてのコンポーネントの構成を最小のコンポーネントまでテストしたいと思います。

なぜReact-testing-libraryを選ぶのですか?

前述の要件により、react-testing-libraryは優れた選択肢になります。その主な指針は、Reactコンポーネントを実際の人間が使用する方法に似た方法でテストできるようにすることです。

このライブラリは、オプションのコンパニオンライブラリとともに、DOMと対話し、その状態をアサートするテストを記述できるようにします。

サンプルアプリのセットアップ

サンプル統合テストを作成するアプリは、単純なシナリオを実装します。

  • ユーザーはGitHubユーザー名を入力します。
  • アプリは、入力されたユーザー名に関連付けられたパブリックリポジトリのリストを表示します。

上記の機能がどのように実装されるかは、統合テストの観点からは無関係である必要があります。 ただし、実際のアプリケーションに近づけるために、アプリは一般的なReactパターンに従います。したがって、アプリは次のようになります。

  • シングルページアプリ(SPA)です。
  • APIリクエストを作成します。
  • グローバルな状態管理を行っています。
  • 国際化をサポートします。
  • Reactコンポーネントライブラリを利用します。

アプリ実装のソースコードはここにあります。

統合テストの作成

依存関係のインストール

糸付き:

 yarn add --dev jest @testing-library/react @testing-library/user-event jest-dom nock

またはnpmで:

 npm i -D jest @testing-library/react @testing-library/user-event jest-dom nock

統合テストスイートファイルの作成

アプリケーションの./testフォルダーにviewGitHubRepositoriesByUsername.spec.jsファイルという名前のファイルを作成します。 Jestは自動的にそれをピックアップします。

テストファイルへの依存関係のインポート

import React from 'react'; // so that we can use JSX syntax import { render, cleanup, waitForElement } from '@testing-library/react'; // testing helpers import userEvent from '@testing-library/user-event' // testing helpers for imitating user events import 'jest-dom/extend-expect'; // to extend Jest's expect with DOM assertions import nock from 'nock'; // to mock github API import { FAKE_USERNAME_WITH_REPOS, FAKE_USERNAME_WITHOUT_REPOS, FAKE_BAD_USERNAME, REPOS_LIST } from './fixtures/github'; // test data to use in a mock API import './helpers/initTestLocalization'; // to configure i18n for tests import App from '../App'; // the app that we are going to test

テストスイートの設定

describe('view GitHub repositories by username', () => { beforeAll(() => { nock('https://api.github.com') .persist() .get(`/users/${FAKE_USERNAME_WITH_REPOS}/repos`) .query(true) .reply(200, REPOS_LIST); }); afterEach(cleanup); describe('when GitHub user has public repositories', () => { it('user can view the list of public repositories for entered GitHub username', async () => { // arrange // act // assert }); }); describe('when GitHub user has no public repositories', () => { it('user is presented with a message that there are no public repositories for entered GitHub username', async () => { // arrange // act // assert }); }); describe('when GitHub user does not exist', () => { it('user is presented with an error message', async () => { // arrange // act // assert }); }); });

ノート:

  • すべてのテストの前に、GitHub APIをモックして、特定のユーザー名で呼び出されたときにリポジトリのリストを返します。
  • 各テストの後で、テストReact DOMをクリーンアップして、各テストがクリーンな場所から開始されるようにします。
  • describeブロックは、統合テストのユースケースとフローのバリエーションを指定します。
  • テストしているフローのバリエーションは次のとおりです。
    • ユーザーは、パブリックGitHubリポジトリに関連付けられている有効なユーザー名を入力します。
    • ユーザーは、パブリックGitHubリポジトリが関連付けられていない有効なユーザー名を入力します。
    • ユーザーがGitHubに存在しないユーザー名を入力しました。
  • テストしitいるユースケースには非同期ステップがあるため、ブロックは非同期コールバックを使用します。

最初のフローテストの作成

まず、アプリをレンダリングする必要があります。

 const { getByText, getByPlaceholderText, queryByText } = render(<App />);

@testing-library/reactモジュールからインポートされたrenderメソッドは、テストReact DOMでアプリをレンダリングし、レンダリングされたアプリコンテナにバインドされたDOMクエリを返します。 これらのクエリは、対話およびアサートするDOM要素を見つけるために使用されます。

ここで、テスト対象のフローの最初のステップとして、ユーザーにユーザー名フィールドが表示され、ユーザー名文字列を入力します。

 userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_USERNAME_WITH_REPOS);

インポートされた@testing-library/user-eventモジュールのuserEventヘルパーには、ユーザーがテキストフィールドにテキストを入力するときのユーザーの動作を模倣するtypeメソッドがあります。 入力を受け入れるDOM要素とユーザーが入力する文字列の2つのパラメーターを受け入れます。

ユーザーは通常、それらに関連付けられたテキストによってDOM要素を見つけます。 入力の場合は、ラベルテキストまたはプレースホルダーテキストのいずれかです。 以前にrenderから返されたgetByPlaceholderTextクエリメソッドを使用すると、プレースホルダーテキストでDOM要素を見つけることができます。

テキスト自体は変更される可能性が高いため、実際のローカリゼーション値に依存せず、代わりにローカリゼーションアイテムキーを値として返すようにローカリゼーションモジュールを構成することをお勧めします。

たとえば、「en-US」ローカリゼーションが通常、 userSelection.usernamePlaceholderキーの値としてEnter GitHub usernameを返す場合、テストでは、 userSelection.usernamePlaceholderを返すようにします。

ユーザーがフィールドにテキストを入力すると、テキストフィールドの値が更新されていることがわかります。

 expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_USERNAME_WITH_REPOS);

次にフローで、ユーザーは送信ボタンをクリックし、リポジトリーのリストが表示されることを期待します。

 userEvent.click(getByText('userSelection.submitButtonText').closest('button')); getByText('repositories.header');

userEvent.clickメソッドは、ユーザーがDOM要素をクリックするのを模倣しますが、 getByTextクエリは、含まれているテキストによってDOM要素を検索します。 closest修飾子は、正しい種類の要素を選択することを保証します。

注:統合テストでは、多くの場合、ステップはactassertの両方の役割を果たします。 たとえば、ユーザーがボタンをクリックすることでボタンをクリックできることを表明します。

前の手順で、ユーザーにアプリのリポジトリリストセクションが表示されることを確認しました。 ここで、GitHubからリポジトリのリストをフェッチするのに時間がかかる可能性があるため、ユーザーにはフェッチが進行中であることが示されていることを確認する必要があります。 また、リポジトリリストがまだフェッチされている間、入力されたユーザー名に関連付けられたリポジトリがないことをアプリがユーザーに通知しないようにする必要があります。

 getByText('repositories.loadingText'); expect(queryByText('repositories.empty')).toBeNull();

getByクエリプレフィックスはDOM要素が見つかることを表明するために使用され、 queryByクエリプレフィックスは反対のアサーションに役立つことに注意してください。 また、要素が見つからない場合、 queryByはエラーを返しません。

次に、最終的に、アプリがリポジトリのフェッチを終了し、それらをユーザーに表示することを確認します。

 await waitForElement(() => REPOS_LIST.reduce((elementsToWaitFor, repository) => { elementsToWaitFor.push(getByText(repository.name)); elementsToWaitFor.push(getByText(repository.description)); return elementsToWaitFor; }, []));

waitForElement非同期メソッドは、メソッドパラメーターとして提供されたアサーションをtrueにするDOM更新を待機するために使用されます。 この場合、モックされたGitHubAPIによって返されるすべてのリポジトリの名前と説明がアプリに表示されることを表明します。

最後に、アプリはリポジトリがフェッチされていることを示すインジケーターを表示しなくなり、エラーメッセージも表示されなくなります。

 expect(queryByText('repositories.loadingText')).toBeNull(); expect(queryByText('repositories.error')).toBeNull();

結果として得られるReact統合テストは次のようになります。

 it('user can view the list of public repositories for entered GitHub username', async () => { const { getByText, getByPlaceholderText, queryByText } = render(<App />); userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_USERNAME_WITH_REPOS); expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_USERNAME_WITH_REPOS); userEvent.click(getByText('userSelection.submitButtonText').closest('button')); getByText('repositories.header'); getByText('repositories.loadingText'); expect(queryByText('repositories.empty')).toBeNull(); await waitForElement(() => REPOS_LIST.reduce((elementsToWaitFor, repository) => { elementsToWaitFor.push(getByText(repository.name)); elementsToWaitFor.push(getByText(repository.description)); return elementsToWaitFor; }, [])); expect(queryByText('repositories.loadingText')).toBeNull(); expect(queryByText('repositories.error')).toBeNull(); });

代替フローテスト

ユーザーがパブリックリポジトリが関連付けられていないGitHubユーザー名を入力すると、アプリは適切なメッセージを表示します。

 describe('when GitHub user has no public repositories', () => { it('user is presented with a message that there are no public repositories for entered GitHub username', async () => { const { getByText, getByPlaceholderText, queryByText } = render(<App />); userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_USERNAME_WITHOUT_REPOS); expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_USERNAME_WITHOUT_REPOS); userEvent.click(getByText('userSelection.submitButtonText').closest('button')); getByText('repositories.header'); getByText('repositories.loadingText'); expect(queryByText('repositories.empty')).toBeNull(); await waitForElement(() => getByText('repositories.empty')); expect(queryByText('repositories.error')).toBeNull(); }); });

ユーザーが存在しないGitHubユーザー名を入力すると、アプリはエラーメッセージを表示します。

 describe('when GitHub user does not exist', () => { it('user is presented with an error message', async () => { const { getByText, getByPlaceholderText, queryByText } = render(<App />); userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_BAD_USERNAME); expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_BAD_USERNAME); userEvent.click(getByText('userSelection.submitButtonText').closest('button')); getByText('repositories.header'); getByText('repositories.loadingText'); expect(queryByText('repositories.empty')).toBeNull(); await waitForElement(() => getByText('repositories.error')); expect(queryByText('repositories.empty')).toBeNull(); }); });

React統合テストがロックする理由

統合テストは、Reactアプリケーションに最適な場所です。 これらのテストは、バグを見つけてTDDアプローチを使用するのに役立ちますが、同時に、実装が変更されたときにメンテナンスを必要としません。

この記事で紹介するReact-testing-libraryは、ユーザーと同じようにアプリを操作し、ユーザーの観点からアプリの状態と動作を検証できるため、React統合テストを作成するための優れたツールです。

ここで提供される例が、新規および既存のReactプロジェクトの統合テストの作成を開始するのに役立つことを願っています。 アプリの実装を含む完全なサンプルコードは、私のGitHubにあります。