コンテンツにスキップ

React のテスト

React Testing Library とは

React Testing Library | Testing Library

React のテストは一般的には Jest テストフレームワークの中で React Testing Library というテストライブラリを使ってコンポーネントのテストを行うらしい。

create-react-appでプロジェクトを作成している場合、パッケージのインストールは不要。

試してみる

スクリプトを用意する

好みでスクリプトを編集しておく。

package.json
1
2
3
4
5
6
7
{
  "scripts": {
    "test_bak": "react-scripts test",
    "test": "react-scripts test --watchAll=false",
    "test:cov": "react-scripts test --coverage --watchAll=false"
  }
}

実行する

npm run testしてみると以下の警告が出る。

 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
 PASS  src/App.test.tsx
   Console

    console.error
      Warning: `ReactDOMTestUtils.act` is deprecated in favor of `React.act`. Import `act` from `react` instead of `react-dom/test-utils`. See https://react.dev/warnings/react-dom-test-utils for more info.

      4 |
      5 | test('renders learn react link', () => {
    > 6 |   render(<App />);
        |         ^
      7 |   const linkElement = screen.getByText(/learn react/i);
      8 |   expect(linkElement).toBeInTheDocument();
      9 | });

      at printWarning (node_modules/react-dom/cjs/react-dom-test-utils.development.js:71:30)
      at error (node_modules/react-dom/cjs/react-dom-test-utils.development.js:45:7)
      at actWithWarning (node_modules/react-dom/cjs/react-dom-test-utils.development.js:1736:7)
      at node_modules/@testing-library/react/dist/act-compat.js:63:25
      at renderRoot (node_modules/@testing-library/react/dist/pure.js:159:26)
      at render (node_modules/@testing-library/react/dist/pure.js:246:10)
      at Object.<anonymous> (src/App.test.tsx:6:9)
      at TestScheduler.scheduleTests (node_modules/@jest/core/build/TestScheduler.js:333:13)
      at runJest (node_modules/@jest/core/build/runJest.js:404:19)
      at _run10000 (node_modules/@jest/core/build/cli/index.js:320:7)
      at runCLI (node_modules/@jest/core/build/cli/index.js:173:3)

@testing-library/reactのパッケージを更新したら良いらしい。(ncuncu -uを使うことにする)

テスト対象のコンポーネントを作成する

src/Button.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import React from "react";

interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
}

export function Button(props: ButtonProps) {
  return <button onClick={props.onClick}>{props.children}</button>;
}

テストコードを書く

src/Button.test.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { fireEvent, render, screen } from "@testing-library/react";
import { Button } from "./Button";

test("Buttonのイベント発火を確認する", () => {
  // イベントのモック関数
  const handleClick = jest.fn().mockImplementation(() => {
    console.log("Mock called");
  });

  render(<Button onClick={handleClick}>Click Me</Button>);

  // テキストでHTML要素を取得する
  const element = screen.getByText(/Click Me/i);

  // 要素がドキュメント上に存在するか確認する
  expect(element).toBeInTheDocument();

  // クリックする
  fireEvent.click(element);

  // イベントが呼ばれたか確認する
  expect(handleClick).toHaveBeenCalled();
});

テストしてみる

 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
$ npm run test:cov

> react-test-example2@0.1.0 test:cov
> react-scripts test --coverage --watchAll=false

 PASS  src/Button.test.tsx
   Console

    console.log
      Mock called

      at src/Button.test.tsx:7:13

 PASS  src/App.test.tsx
--------------------|---------|----------|---------|---------|-------------------
File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files           |   15.38 |        0 |      50 |   15.38 |
 App.tsx            |     100 |      100 |     100 |     100 |
 Button.tsx         |     100 |      100 |     100 |     100 |
 index.tsx          |       0 |      100 |     100 |       0 | 7-19
 reportWebVitals.ts |       0 |        0 |       0 |       0 | 3-10
--------------------|---------|----------|---------|---------|-------------------

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.429 s
Ran all test suites.

網羅率も確認できることがわかった。

状態を確認する

useState()を使ったコンポーネントをテストしてみる。React Testing Library ではuseState()の中身にアクセスする方法を提供していないらしい。理由は React のテストがコンポーネントの出力を検証することに焦点を当てるためらしい。

状態を持つコンポーネントを作成する。

src/Counter.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount((c) => c + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}
src/Counter.test.tsx
 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 { fireEvent, render, screen } from "@testing-library/react";
import { Counter } from "./Counter";

test("Counterの動作を確認する", () => {
  render(<Counter />);

  // テキストでHTML要素を取得する
  const element = screen.getByText(/0/i);

  // 要素がドキュメント上に存在するか確認する
  expect(element).toBeInTheDocument();

  // クリックする
  fireEvent.click(element);

  // 状態が変わったか確認する
  expect(screen.getByText(/1/i)).toBeInTheDocument();

  // クリックする
  fireEvent.click(element);

  // 状態が変わったか確認する
  expect(screen.getByText(/2/i)).toBeInTheDocument();
});

テスト用に環境変数を差し替える

以下のようにテスト実行前にdotenv等で環境変数をセットするようなスクリプトを組めば良いと思う。(動作未確認)

1
2
3
4
5
{
  "scripts": {
    "test": "dotenv -e .env.test -- react-scripts test --watchAll=false"
  }
}

fetch()とか外界をモックする

fetch()についてはモックサーバーを用意する方法か、fetch()をモック関数に置き換える方法が考えられる。

モックサーバーを用意するのは面倒だ。テストコードを実行する前に、サーバーを起動しないといけない。

テストコードでfetch()のモックはしない想定で考える。理由は以下である。

  • 複数のコンポーネントで URL の異なる fetch()を呼んでいるとき、どの fetch()をどのモックにつなげるかをうまく実装する必要があるが、これが難しそうである。
  • コンポーネントが 1 つであったとしても、URL の異なる fetch()を呼ぶなら同様の問題がある。
  • そもそもコンポーネントが HTTP 通信という具象に依存しているのは設計の点でイケてない。外部とのやり取りは専用のモジュールなり関数なりに任せる設計になるはず。

そこで、実装は以下を想定する。

src/fetch-data.ts
1
2
3
export function fetchData(): Promise<Response> {
  return fetch("http://localhost:8080");
}
src/Request.tsx
 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
import { useEffect, useState } from "react";
import { fetchData } from "./fetch-data";

export interface Data {
  message: string;
}

export function Request() {
  const [data, setData] = useState<Data | null>(null);

  useEffect(() => {
    (async () => {
      try {
        const response = await fetchData();
        if (!response.ok) {
          setData({ message: "ステータスコードが200番台じゃない" });
        }
        const body = (await response.json()) as Data;
        setData(body);
      } catch (error) {
        setData({ message: "サーバーに繋がらない" });
      }
    })();
  }, []);

  if (!data) {
    return <div>読み込み中</div>;
  }

  return <div>{data.message}</div>;
}
src/Request.test.tsx
 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
import { render, screen, waitFor } from "@testing-library/react";

import { Request } from "./Request";

import * as fetchDataModule from "./fetch-data";

test("Requestの動作を確認する", async () => {
  const spy = jest.spyOn(fetchDataModule, "fetchData").mockResolvedValue({
    ok: true,
    status: 200,
    json: () => new Promise((resolve) => resolve({ message: "OK" })),
  } as Response);

  render(<Request />);

  // waitForを使うとリトライすることができる
  await waitFor(
    () => {
      expect(screen.getByText("OK")).toBeInTheDocument();
    },
    {
      timeout: 3000, // 3sでタイムアウトする
      interval: 100, // 100ms間隔でリトライする
    }
  );

  spy.mockRestore();
});

テスト対象のコンポーネントの命令網羅は満たされていないが、これはテストケースを別途作成して、それぞれモックを色々用意してやれば網羅できる。

その他

Jest did not exit one second after the test run has completed.

テストの終了が早すぎたために怪しまれている。無視して良い。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Jest did not exit one second after the test run has completed.

This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.
One of your dependencies, babel-preset-react-app, is importing the
"@babel/plugin-proposal-private-property-in-object" package without
declaring it in its dependencies. This is currently working because
"@babel/plugin-proposal-private-property-in-object" is already in your
node_modules folder for unrelated reasons, but it may break at any time.

babel-preset-react-app is part of the create-react-app project, which
is not maintianed anymore. It is thus unlikely that this bug will
ever be fixed. Add "@babel/plugin-proposal-private-property-in-object" to
your devDependencies to work around this error. This will make this message
go away.
1
2
3
4
5
6
7
Jest はテスト実行が完了してから 1 秒経っても終了しませんでした。

これは通常、テストで停止されなかった非同期操作があることを意味します。この問題のトラブルシューティングを行うには、Jest を `--detectOpenHandles` で実行することを検討してください。

依存関係の 1 つである babel-preset-react-app が、依存関係で宣言せずに「@babel/plugin-proposal-private-property-in-object」パッケージをインポートしています。これは現在機能していますが、これは「@babel/plugin-proposal-private-property-in-object」が無関係の理由で既に node_modules フォルダーにあるためですが、いつでも機能しなくなる可能性があります。

babel-preset-react-app は create-react-app プロジェクトの一部であり、メンテナンスは行われていません。したがって、このバグが修正される可能性は低いです。このエラーを回避するには、devDependencies に「@babel/plugin-proposal-private-property-in-object」を追加します。これにより、このメッセージは表示されなくなります。

Warning: An update to Home inside a test was not wrapped in act(...).

element.click()ではなくfireEvent.click(element)を使う。

1
2
import { fireEvent } from "@testing-library/react";
fireEvent.click(button);