JestとReact Testing Libraryを使用してReactアプリをテストする方法

著者は、Vets Who Codeを、寄付のために書くプログラムの一環として選びました。

はじめに

堅牢なテストカバレッジを確保することは、Webアプリケーションを構築する上で不可欠です。 JestはJavaScriptのテストランナーであり、テストの記述と実行のためのリソースを提供します。 React Testing Libraryは、コンポーネントの実装の詳細ではなく、ユーザーのインタラクションに基づいてテストを構造化するためのテストヘルパーのセットを提供します。 JestとReact Testing Libraryは、Create React Appに事前にパッケージ化されており、アプリのテストはソフトウェアの使用方法に類似しているべきだという指針に従います。

このチュートリアルでは、さまざまなUI要素を含むサンプルプロジェクトで非同期コードとインタラクションをテストします。 Jestを使用してユニットテストを記述して実行し、コンポーネントとのやり取りを処理するためのヘルパーDOM(Document Object Model)ライブラリとしてReact Testing Libraryを実装します。

前提条件

このチュートリアルを完了するには、次のものが必要です:

  • ローカルマシンにインストールされているNode.jsバージョン14以上。macOSまたはUbuntu 18.04にNode.jsをインストールするには、macOSにNode.jsをインストールし、ローカル開発環境を作成する方法またはUbuntu 18.04にNode.jsをインストールする方法PPAを使用してインストールするセクションの手順に従ってください。

  • ローカルマシンにnpmバージョン5.2以上が必要です。これは、Create React Appとサンプルプロジェクトでnpxを使用するために必要です。もしnpmNode.jsと一緒にインストールしていない場合は、今すぐインストールしてください。Linuxの場合、sudo apt install npmというコマンドを使用してください。

    • このチュートリアルでnpmパッケージを使用するには、build-essentialパッケージをインストールする必要があります。Linuxの場合は、sudo apt install build-essentialというコマンドを使用してください。
  • ローカルマシンにGitがインストールされている必要があります。Gitがコンピューターにインストールされているかどうかを確認するか、Ubuntu 20.04にGitをインストールする方法を実行してください。

  • Reactに精通しており、React.jsでコーディングする方法シリーズで開発できます。サンプルプロジェクトはCreate React Appでブートストラップされているため、別途インストールする必要はありません。

  • Jestをテストランナーやフレームワークとしてある程度理解していると役立ちますが、必須ではありません。JestはCreate React Appに事前にパッケージ化されているため、別途インストールする必要はありません。

ステップ1 — プロジェクトの設定

このステップでは、サンプルプロジェクトをクローンしてテストスイートを起動します。サンプルプロジェクトでは、3つの主要なツールを利用しています:Create React App、Jest、およびReact Testing Library。Create React Appは、単一ページのReactアプリケーションの作成をサポートするために使用されます。Jestはテストランナーとして使用され、React Testing Libraryはユーザーの操作を中心にテストを構築するためのテストヘルパーを提供します。

まず、GitHubから事前に構築されたReactアプリをクローンします。これは、特定の品種に基づいて犬の画像のコレクションの検索および表示システムを構築するためにDog APIを活用したサンプルプロジェクトであるDoggy Directoryアプリを使用します。

プロジェクトをGitHubからクローンするには、ターミナルを開いて次のコマンドを実行します:

  1. git clone https://github.com/do-community/doggy-directory

次のような出力が表示されます:

Output
Cloning into 'doggy-directory'... remote: Enumerating objects: 64, done. remote: Counting objects: 100% (64/64), done. remote: Compressing objects: 100% (48/48), done. remote: Total 64 (delta 21), reused 55 (delta 15), pack-reused 0 Unpacking objects: 100% (64/64), 228.16 KiB | 3.51 MiB/s, done.

doggy-directoryフォルダに移動します:

  1. cd doggy-directory

プロジェクトの依存関係をインストールします:

  1. npm install

npm installコマンドは、package.jsonファイルで定義されたすべてのプロジェクトの依存関係をインストールします。

依存関係をインストールした後、アプリのデプロイバージョンを表示するか、次のコマンドを使用してアプリをローカルで実行できます:

  1. npm start

アプリをローカルで実行する場合、http://localhost:3000/で開きます。ターミナルには次の出力が表示されます:

Output
Compiled successfully! You can now view doggy-directory in the browser. Local: http://localhost:3000 On Your Network: http://network_address:3000

起動後、アプリのランディングページは次のようになります:

プロジェクトの依存関係がインストールされ、アプリケーションが実行されています。次に、新しいターミナルを開き、次のコマンドでテストを実行してください:

  1. npm test

npm testコマンドは、そのテストランナーとして Jest を使用して、対話型のウォッチモードでテストを開始します。ウォッチモードでは、ファイルが変更されると自動的にテストが再実行されます。テストは、ファイルを変更するたびに実行され、その変更がテストに合格したかどうかを通知します。

最初に npm testを実行した後、ターミナルにこの出力が表示されます:

Output
No tests found related to files changed since last commit. Press `a` to run all tests, or run Jest with `--watchAll`. Watch Usage › Press a to run all tests. › Press f to run only failed tests. › Press q to quit watch mode. › Press p to filter by a filename regex pattern. › Press t to filter by a test name regex pattern. › Press Enter to trigger a test run.

今、サンプルアプリケーションとテストスイートが実行されているので、ランディングページのテストを開始できます。

ステップ2 — ランディングページのテスト

Jestはデフォルトで、.test.jsサフィックスのファイルと、__tests__フォルダー内の.jsサフィックスのファイルを検索します。関連するテストファイルを変更すると、それらが自動的に検出されます。テストケースが修正されると、出力が自動的に更新されます。 doggy-directory サンプルプロジェクト向けに準備されたテストファイルには、テストパラダイムを追加する前に最小限のコードが設定されています。このステップでは、検索を実行する前にアプリケーションのランディングページが読み込まれることを確認するテストを書きます。

エディタで src/App.test.js を開き、次のコードを確認してください:

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

test('renders the landing page', () => {
  render(<App />);
});

A minimum of one test block is required in each test file. Each test block accepts two required parameters: the first argument is a string representing the name of the test case; the second argument is a function that holds the expectations of the test.

関数内には、React Testing Libraryが提供するrenderメソッドがあります。これを使用して、コンポーネントをDOMにレンダリングします。テスト対象のコンポーネントがテスト環境のDOMにレンダリングされたら、期待される機能に対してコードをアサートし始めることができます。

次のハイライトされたコードをrenderメソッドの下に追加します:

src/App.test.js
...
test('renders the landing page', () => {
  render(<App />);
  
  expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
  expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
  expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
  expect(screen.getByRole("img")).toBeInTheDocument();
});

expect関数は、特定の結果を検証したい場合に毎回使用されます。これは、コードが生成する値を表す単一の引数を受け取ります。ほとんどのexpect関数は、特定の値について何かをアサートするmatcher関数とペアになっています。これらのアサーションのほとんどについては、DOMで見つかる一般的な要素を確認するために提供されるjest-domによって追加のマッチャーが使用されます。たとえば、最初の行の.toHaveTextContentexpect関数のマッチャーであり、getByRole("heading")はDOM要素を取得するセレクタです。

React Testing Libraryはscreenオブジェクトを提供しており、テストDOM環境に対してアサートするために必要な関連するクエリに簡単にアクセスできます。デフォルトでは、React Testing LibraryはDOM内の要素を見つけるためのクエリを提供しています。クエリには3つの主要なカテゴリがあります:

  • getBy*(最も一般的に使用される)
  • queryBy*(エラーをスローせずに要素の不在をテストする場合に使用される)
  • findBy*(非同期コードをテストする場合に使用される)

各クエリタイプは特定の目的を果たします。後でチュートリアルで定義されます。このステップでは、最も一般的なクエリタイプであるgetBy*クエリに焦点を当てます。異なるクエリのバリエーションの詳細なリストを確認するには、Reactのクエリチートシートを参照してください。

以下は、Doggy Directoryランディングページの注釈付き画像で、最初のテスト(ランディングページのレンダリング)がカバーする各セクションを示しています:

expect関数は、次のものにアサートしています(上の注釈付き画像に表示されています):

  1. 見出しの役割を持つ要素が部分文字列としてDoggy Directoryに一致することを期待します。
  2. セレクト入力が品種を選択の正確な表示値を持つことを期待します。
  3. 検索ボタンが選択されていないため無効になっていることを期待します。
  4. 検索が行われていないため、プレースホルダー画像がドキュメントに存在することを期待します。

完了したら、src/App.test.jsファイルを保存してください。テストがウォッチモードで実行されているため、変更は自動的に登録されます。変更が自動的に登録されない場合は、テストスイートを停止して再起動する必要がある場合があります。

今、ターミナルでテストを見ると、次の出力が表示されます:

Output
PASS src/App.test.js ✓ renders the landing page (172 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 2.595 s, estimated 5 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

このステップでは、Doggy Directoryのランディングページの初期レンダリングビューを検証する初期テストを書きました。次のステップでは、非同期コードのテストのためにAPI呼び出しをモックする方法を学びます。

ステップ3 — fetchメソッドのモック

このステップでは、JavaScriptのfetchメソッドをモックする1つのアプローチを見ていきます。これを実現する方法はさまざまありますが、この実装ではJestのspyOnmockImplementationメソッドを使用します。

外部APIに依存する場合、そのAPIがダウンしたり、応答に時間がかかる可能性があります。fetchメソッドをモックすると、一貫性のある予測可能な環境が提供され、テストに対する信頼性が向上します。外部APIを使用するテストを正しく実行するには、APIのモックメカニズムが必要です。

注意:このプロジェクトを簡略化するために、fetchメソッドをモックします。ただし、本番向けの大規模なコードベースでは、より堅牢なソリューションであるモックサービスワーカー(MSW)などを使用することが推奨されます。

src/mocks/mockFetch.jsをエディターで開いて、mockFetchメソッドの動作を確認してください。

src/mocks/mockFetch.js
const breedsListResponse = {
    message: {
        boxer: [],
        cattledog: [],
        dalmatian: [],
        husky: [],
    },
};

const dogImagesResponse = {
    message: [
        "https://images.dog.ceo/breeds/cattledog-australian/IMG_1042.jpg ",
        "https://images.dog.ceo/breeds/cattledog-australian/IMG_5177.jpg",
    ],
};

export default async function mockFetch(url) {
    switch (url) {
        case "https://dog.ceo/api/breeds/list/all": {
            return {
                ok: true,
                status: 200,
                json: async () => breedsListResponse,
            };
        }
        case "https://dog.ceo/api/breed/husky/images" :
        case "https://dog.ceo/api/breed/cattledog/images": {
            return {
                ok: true,
                status: 200,
                json: async () => dogImagesResponse,
            };
        }
        default: {
            throw new Error(`Unhandled request: ${url}`);
        }
    }
}

mockFetchメソッドは、アプリケーション内のAPI呼び出しに対するfetch呼び出しの構造に密接に似ているオブジェクトを返します。 mockFetchメソッドは、Doggy Directoryアプリ内の2つの領域で非同期機能をテストするために必要です:犬の品種のリストを表示する選択ドロップダウンと、検索が実行されたときに犬の画像を取得するAPI呼び出し。

src/mocks/mockFetch.jsを閉じます。これで、テストでmockFetchメソッドがどのように使用されるかがわかったので、テストファイルにそれをインポートできます。 mockFetch関数は、mockImplementationメソッドに引数として渡され、fetch APIの模擬実装として使用されます。

src/App.test.jsに、mockFetchメソッドをインポートするためのハイライトされたコードを追加します。

src/App.test.js
import { render, screen } from '@testing-library/react';
import mockFetch from "./mocks/mockFetch";
import App from './App';

beforeEach(() => {
   jest.spyOn(window, "fetch").mockImplementation(mockFetch);
})

afterEach(() => {
   jest.restoreAllMocks()
});
...

このコードは、モック実装をセットアップして解除し、各テストがレベルなプレイイングフィールドから開始するようにします。

jest.spyOn(window, "fetch");は、DOMのグローバルwindow変数にアタッチされたfetchメソッドへの呼び出しを追跡するモック関数を作成します。

.mockImplementation(mockFetch);は、モックメソッドを実装するために使用される関数を受け入れます。このコマンドは元のfetchの実装を上書きするため、アプリコード内でfetchが呼び出されるときに実行されます。

完了したら、src/App.test.jsファイルを保存してください。

今、ターミナルでテストを確認すると、次の出力が表示されます:

Output
console.error Warning: An update to App inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): act(() => { /* fire events that update state */ }); /* assert on the output */ This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at App (/home/sammy/doggy-directory/src/App.js:5:31) 18 | }) 19 | .then((json) => { > 20 | setBreeds(Object.keys(json.message)); | ^ 21 | }); 22 | }, []); 23 | ... PASS src/App.test.js ✓ renders the landing page (429 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.178 s, estimated 2 s Ran all test suites related to changed files.

この警告は、予期しない状態の更新が発生したことを示しています。ただし、出力にはテストがfetchメソッドを正常にシミュレートしたことも示されています。

このステップでは、fetchメソッドをモックし、そのメソッドをテストスイートに組み込みました。テストは合格していますが、警告を解決する必要があります。

ステップ4 — actの警告の修正

このステップでは、ステップ3の変更後に表面化したactの警告の修正方法を学びます。

actの警告は、fetchメソッドをモックアウトしており、コンポーネントがマウントされると、APIコールが行われて犬種のリストを取得します。犬種リストは、option要素を選択入力内に入れるための状態変数に格納されます。

以下の画像は、犬種のリストをポピュレートするために成功したAPI呼び出しが行われた後の選択入力の外観を示しています。

警告が発生するのは、テストブロックがコンポーネントのレンダリングを終えた後に状態が設定されるためです。

この問題を修正するには、src/App.test.js内のテストケースに以下の変更を追加してください:

src/App.test.js
...
test('renders the landing page', async () => {
   render(<App />);
   
   expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
   expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
   expect(await screen.findByRole("option", { name: "husky"})).toBeInTheDocument();
   expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
   expect(screen.getByRole("img")).toBeInTheDocument();
});

asyncキーワードは、コンポーネントがマウントされる際に発生するAPI呼び出しの結果として非同期コードが実行されることをJestに伝えます。

A new assertion with the findBy query verifies that the document contains an option with the value of husky. findBy queries are used when you need to test asynchronous code that is dependent on something being in the DOM after a period of time. Because the findBy query returns a promise that gets resolved when the requested element is found in the DOM, the await keyword is used within the expect method.

変更を保存したら、src/App.test.jsで行った変更を保存してください。

新しい追加事項により、テストでのact警告が表示されなくなることが確認できます:

Output
PASS src/App.test.js ✓ renders the landing page (123 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.942 s, estimated 2 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

このステップでは、非同期コードを扱う際に発生するact警告を修正する方法を学びました。次に、Doggy Directoryアプリケーションのインタラクティブな機能を検証するための2番目のテストケースを追加します。

ステップ5 — 検索機能のテスト

最後のステップでは、検索および画像表示機能を検証するための新しいテストケースを記述します。適切なテストカバレッジを達成するために、さまざまなクエリとAPIメソッドを活用します。

エディターでsrc/App.test.jsファイルに戻ります。ファイルの先頭に、user-eventコンパニオンライブラリとwaitForElementToBeRemoved非同期メソッドを、以下のハイライトされたコマンドでテストファイルにインポートします。

src/App.test.js
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';import userEvent from '@testing-library/user-event'; 
...

後でこのインポートをこのセクションで使用します。

最初のtest()メソッドの後に、新しい非同期テストブロックを追加し、以下のコードブロックでAppコンポーネントをレンダリングします。

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
})

コンポーネントがレンダリングされたので、Doggy Directoryアプリのインタラクティブな機能を検証する関数を追加できます。

src/App.test.jsのまま、2番目のtest()メソッド内に以下のハイライトされたコードブロックを追加します。

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
   
   //オプションの選択をシミュレートし、その値を検証します
   const select = screen.getByRole("combobox");
   expect(await screen.findByRole("option", { name: "cattledog"})).toBeInTheDocument();
   userEvent.selectOptions(select, "cattledog");
   expect(select).toHaveValue("cattledog");
})

上記のハイライト部分は、犬種の選択をシミュレートし、正しい値が表示されていることを検証します。

getByRoleクエリは選択された要素を取得し、それをselect変数に割り当てます。

Step 4でact警告を修正した方法と同様に、さらなるアサーションを行う前に、cattledogオプションがドキュメントに表示されるのを待つためにfindByRoleクエリを使用します。

以前にインポートされたuserEventオブジェクトは一般的なユーザーの操作を模倣します。この例では、selectOptionsメソッドが前の行で待機していたcattledogオプションを選択します。

最後の行は、上記で選択されたcattledog値がselect変数に含まれていることをアサートします。

次に、Javascriptのtest()ブロックに追加する次のセクションでは、選択された品種に基づいて犬の画像を検索するリクエストを開始し、ローディング状態の存在を確認します。

以下のハイライトされた行を追加してください:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
    
   //...オプションを選択してその値を検証するシミュレーション

  //検索リクエストの開始をシミュレート
   const searchBtn = screen.getByRole("button", { name: "Search" });
   expect(searchBtn).not.toBeDisabled();
   userEvent.click(searchBtn);

   //結果が表示されるとローディング状態が表示され、削除されます
   await waitForElementToBeRemoved(() => screen.queryByText(/Loading/i));
})

getByRoleクエリは検索ボタンを探し出し、searchBtn変数に割り当てます。

toBeDisabled jest-domマッチャーは、品種の選択が行われたときに検索ボタンが無効になっていないことを検証します。

userEventオブジェクト上のclickメソッドは、検索ボタンをクリックする操作を模倣します。

waitForElementToBeRemoved async ヘルパー 関数は、Loading メッセージの表示と消失を待機し、検索 API コールが実行中である間に待機します。 waitForElementToBeRemoved コールバック内の queryByText は、エラーをスローせずに要素の不在を確認します。

以下は、検索が進行中の場合に表示されるローディング状態を示す画像です:

次に、次のJavascriptコードを追加して、画像と結果の数を表示を検証します:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />)
   
   //...オプションの選択とその値の検証をシミュレート
   //...検索リクエストの開始をシミュレート
   //...結果が表示されると、ローディング状態が表示され、削除されます
          
   //画像の表示と結果数を検証する
   const dogImages = screen.getAllByRole("img");
   expect(dogImages).toHaveLength(2);
   expect(screen.getByText(/2 Results/i)).toBeInTheDocument();
   expect(dogImages[0]).toHaveAccessibleName("cattledog 1 of 2");
   expect(dogImages[1]).toHaveAccessibleName("cattledog 2 of 2");
})

getAllByRole クエリはすべての犬の画像を選択し、それらを dogImages 変数に割り当てます。 クエリの *AllBy* バリアントは、指定された役割に一致する複数の要素を含む配列を返します。 *AllBy* バリアントは、単一の要素しか返せない ByRole バリアントとは異なります。

モックされた fetch 実装には、レスポンス内に2つの画像URLが含まれていました。 Jestの toHaveLength マッチャーを使用して、2つの画像が表示されていることを検証できます。

getByTextクエリは、適切な結果数が右上隅に表示されるかどうかを確認します。

toHaveAccessibleName マッチャを使用した2つのアサーションは、個々の画像に適切な代替テキストが関連付けられていることを検証します。

A completed search displaying images of the dog based on the breed selected along with the number of results found will look like this:

新しいJavaScriptコードのすべての部分を組み合わせると、App.test.jsファイルは次のようになります:

src/App.test.js
import {render, screen, waitForElementToBeRemoved} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import mockFetch from "./mocks/mockFetch";
import App from './App';

beforeEach(() => {
   jest.spyOn(window, "fetch").mockImplementation(mockFetch);
})

afterEach(() => {
   jest.restoreAllMocks();
});

test('renders the landing page', async () => {
   render(<App />);

   expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
   expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
   expect(await screen.findByRole("option", { name: "husky"})).toBeInTheDocument()
   expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
   expect(screen.getByRole("img")).toBeInTheDocument();
});

test("should be able to search and display dog image results", async () => {
   render(<App />);

   //オプションを選択してその値を検証するシミュレーション
   const select = screen.getByRole("combobox");
   expect(await screen.findByRole("option", { name: "cattledog"})).toBeInTheDocument();
   userEvent.selectOptions(select, "cattledog");
   expect(select).toHaveValue("cattledog");

   //検索リクエストを開始する
   const searchBtn = screen.getByRole("button", { name: "Search" });
   expect(searchBtn).not.toBeDisabled();
   userEvent.click(searchBtn);

   //ローディング状態が表示され、結果が表示されると削除される
   await waitForElementToBeRemoved(() => screen.queryByText(/Loading/i));

   //画像表示と結果数を検証する
   const dogImages = screen.getAllByRole("img");
   expect(dogImages).toHaveLength(2);
   expect(screen.getByText(/2 Results/i)).toBeInTheDocument();
   expect(dogImages[0]).toHaveAccessibleName("cattledog 1 of 2");
   expect(dogImages[1]).toHaveAccessibleName("cattledog 2 of 2");
})

src/App.test.jsで行った変更を保存してください。

テストを確認すると、ターミナルの最終出力は次のようになります:

Output
PASS src/App.test.js ✓ renders the landing page (273 ms) ✓ should be able to search and display dog image results (123 ms) Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 4.916 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

この最終ステップでは、Doggy Directoryアプリケーションの検索、ローディング、および表示機能を検証するテストを追加しました。最終アサーションが記述されたことで、アプリが動作することがわかります。

結論

このチュートリアルでは、Jest、React Testing Library、およびjest-domマッチャーを使用してテストケースを作成しました。段階的に構築しながら、ユーザーがUIとやり取りする方法に基づいてテストを作成しました。また、getBy*findBy*、およびqueryBy*クエリの違いや非同期コードのテスト方法について学びました。

上記のトピックについて詳しく学びたい場合は、JestReact Testing Library、およびjest-domの公式ドキュメントを参照してください。React Testing Libraryを使用する際のベストプラクティスについては、Kent C. DoddのCommon Mistakes with React Testing Libraryを読んでみてください。Reactアプリ内でスナップショットテストを使用する方法については、How To Write Snapshot Testsをチェックしてください。

Source:
https://www.digitalocean.com/community/tutorials/how-to-test-a-react-app-with-jest-and-react-testing-library