🔋

[React] コンポーネントのインテグレーションテストって何を書けばいいの?

2023/08/16に公開

こんにちは。tackmeと申します。

職場でプロジェクトのテストを整備する動きがあり、フロントエンドのテストにReact Testing Library(以下、RTL)を導入することになりました。
テストの作成に際して、ライブラリの作者であるKent C. Dodds氏の記事"Write tests. Not too many. Mostly integration."を参考にし、インテグレーションテストに主眼を置く方針を取りました。
しかし実際にテストの作成を始めてみると「ユニットテストとインテグレーションテストの境界線がどこにあるか」という疑問や、「具体的にはどのようなテストケースを作成すれば良いか」という課題が出てきました。
これらの問題について調査して自分なりの考えをまとめたので、記事として残しておきたいと思います(あくまで個人的な意見なので間違っている場所があるかもしれません)。

何をインテグレーションテストとするか

ユニットテストとインテグレーションテストの違いはなにか、Kent C. Dodds氏は"The Testing Trophy and Testing Classifications"の中で以下のように分類しています。

  • Unit tests are those which test units which either have no dependencies (collaborators) or which have those mocked for the test.
  • Integration tests are those which test multiple units integrating with one another.

訳)

  • ユニットテストとは、依存関係(コラボレーター)がないか、テストのためにモックされた依存関係を持つユニットをテストするものです。
  • インテグレーションテストとは、複数のユニットが互いに統合されることをテストするものです。

つまり複数のユニット(コンポーネントや関数など)をモック化せずに統合した状態でテストするものがインテグレーションだということです。

とはいえ、ほとんどのコンポーネントは他のコンポーネントや関数に依存しており、内部では互いに連動しあっています。そのため、ほぼすべてのコンポーネントのテストがインテグレーションテストになりえるのではないかという疑問が生じます。

では何をもってインテグレーションテストとするか?これについて個人的に納得のいく説明をしている記事を見つけました。

The notion of “what is a unit? VS “what is an integration?” depends of the point of view from which you are looking at the code.

If we take the example of an application, using a Dropdown belonging to component library:

  • From a component library perspective; an internal Dropdown’s function is a unit. And the Dropdown as a whole is an integration.
  • From an application perspective; The Dropdown becomes a Unit. And the page’s Form is an Integration.

訳)「ユニットとは何か?」「インテグレーションとは何か?」という概念は、コードをどの視点で見ているかによって異なります。
アプリケーションの例を取ってみましょう。コンポーネントライブラリに属するドロップダウンを使用する場合:

  • コンポーネントライブラリの視点では、ドロップダウンの内部機能がユニットであり、ドロップダウン全体がインテグレーションです。
  • アプリケーションの視点では、ドロップダウン自体がユニットであり、ページのフォームがインテグレーションです。

つまり、プロジェクトによってインテグレーションテストの粒度は異なるということです。実際にRTLのサンプルコードを読んでみると、アプリケーション全体をレンダリングしてテストしているコードがある一方で、部分的にレンダリングしてテストしているコードも見かけます。

アプリケーション全体を描画してテストすることが理想的ですが、プロジェクトの規模や実装によってはそれが難しい場合もあります。それらを考慮して事前にどの粒度でテストを行うかを決めておくと良いでしょう。

何をテストするか

テストするコンポーネントの粒度を決めたら、次はそれに対して何をテストするべきか考える必要があります。
これについてもKent C. Dodds氏の記事"How to know what to test"で詳しく説明されていますが、中でも最も重要な部分は次の部分でしょう。

Test use cases, not code.

内部の具体的なロジックをテストするのではなく、ユーザーが実際にアプリケーションを使って行うことをテストするべき、ということですかね。
また同記事内でコンポーネントのテストでは以下のような項目を確認することが有用だと言っています。

  • ユーザーのインタラクション
  • propsの変更
  • Contextの変更
  • サブスクリプションの変更

ただ記事の説明だと具体的に何をテストすればいいのかイメージしづらかったので、それぞれについてもう少し掘り下げて考えてみます。

ユーザーインタラクション

これはわかりやすいですね。ユーザーの操作によって発生するコンポーネントの挙動が正しいかどうかテストします。ざっくり列挙すると以下のような項目を確認するべきでしょうか。

  • コンポーネントが期待通り描画されること
    • APIを呼び出している場合はモックして確認する
    • propsによって初期化処理や描画内容が変わる場合はそれぞれ確認する
      • ユースケースとして存在しないpropsの組み合わせは確認不要
  • ユーザーの操作による処理が期待通り動くこと
    • ユーザーが観測可能な挙動を中心に確認する
    • テスト対象のコンポーネント外に影響する操作は間接的にテストする必要あり
      • コールバック関数が呼ばれることを確認したり、Reduxのアクションがディスパッチされることを確認したり

props/Contextの変更

コンポーネントのpropsやContextが変わった際に期待通り再描画されるかどうかをテストします。すべてのパターンをテストする必要はなく、あくまでアプリケーションのユースケースで発生しうるprops/Contextの変更のみをテストすればOKかなと。テスト対象のコンポーネントを操作してテスト対象のコンポーネントのprops/Contextが変化するパターンがあれば、それによる再描画が期待通り動くかどうか確認するイメージです。

例えばショッピングカートのコンポーネントがあり、追加されている商品がカートの上限に達すると警告メッセージを出す仕様があるとします。この場合、以下ようなテストを書いて警告メッセージがでることを確認します。(ユニットテスト寄りの例ですが…)

test("カートの商品が上限に達した場合に警告メッセージが表示される", () => {
    const items = ["item1", "item2"]
    const { rerender } = render(<Cart items={items} />);

    expect(screen.getByText("上限に達しました")).not.toBeInTheDocument();

    rerender(<Cart items={[...items, "item3"]} />);

    expect(screen.getByText("上限に達しました")).toBeInTheDocument();
});

サブスクリプションの変更

コンポーネント外のデータをサブスクライブ(監視)して動作しているコンポーネントは、その挙動もテストしたほうがいいです。
例えばFirestoreを使ったリアルタイムチャットアプリを例に取ると、Firestoreに新しく会話が追加された際にチャット画面に反映されるかをテストする、といったイメージですね。
記事ではコンポーネント外のリソースとして、Firebase、Redux Store, Router, Media Query, ブラウザベースのサブスクリプション(localStorageとか?)が挙げられています。

参考になりそうなリポジトリ

比較的大きなプロダクトでRTLを使ってテストが書かれているリポジトリをいくつか紹介します。
1つ目のexcalidrawはインテグレーションテスト、それ以外はユニットテスト寄りの印象です。世の中のプロダクトが実際にどんなテストを書いているのか読んでみるのも参考になります。

まとめ

要点をざっくりまとめると、以下のような感じですね。

  • インテグレーションテストは、ある程度の粒度でコンポーネントを描画(結合)してテストする方法
    • コンポーネントの描画粒度はプロジェクトによって異なる
    • 依存するユニットは極力モック化せずにテストする
  • コンポーネントのテストは内部のロジックではなくユースケースをテストする
  • コンポーネントのテストは次の点にフォーカスすることが重要
    • ユーザー操作によるコンポーネントの挙動
    • propsやContextの変更に伴う再描画の挙動
    • サブスクライブしているコンポーネント外のデータ変更時の挙動

今回の調査を通じて、コンポーネントテスト/インテグレーションテストに関する理解が少し深まりました。ただ、具体的なテスト方針やベストプラクティスについてはまだ完全に把握しきれていない部分もあります。あるいは、それらはプロジェクトごとに適切な解決策があり、テストを進めながら最適なアプローチを模索していく必要があるのかもしれません。

すこしふわっとしたまとめになってしまいましたが、今後の経験を通じてより具体的なアドバイスができるようになれば、改めて記事にまとめたいと思います。

参考

Thinkingsテックブログ

Discussion