❤️‍🔥

React でテストを書く時に考えていること/大切にしていること

2023/12/25に公開

この記事は 2023 年 11 月 6 日に行われた ZOZO Tech Meetup - Web フロントエンドで発表した資料を記事にリライトしたものです。
資料だけでは伝わらない部分や、もっと詳細に触れたい部分もあったので記事にしました。
当時の発表資料は以下です。多くの部分では同様のことが記載されていますが、細部や扱う内容を若干変えています。

はじめに

3 年前にコンポーネントではなく Hook 自体をテストしたいというモチベーションから「React Hooks でテストをゴリゴリ書きたい」という記事を書きました。

この記事を書いた当時は Hook やコンポーネントで使われる関数がそれぞれ正しく動いていることが確認できれば、それらを組み合わせて作るコンポーネントもある程度正しいことが担保されるではないかと考えていました。
また、コンポーネントは JSX が書かれていて DOM 構造と結びついているため、壊れやすいテストになってしまうのでは?とも考えていました。

それから 3 年経って今どのようなことを考えて普段テストを書いているか、というのが今回の記事です。

アジェンダ

この記事ではおよそ以下のようなことを扱います。

  • コンポーネントを利用したテストを書く意義について
    • カスタムフックを利用したテスト
  • テストを書いていて感じた疑問について
  • テストが担保すべきこと、そうでないことについて

逆に言えば、今回の記事ではどのようなことを考えてテストを書くかがメインで、実際のテストの書き方にはほとんど触れません。

こんな経験はありませんか?

テストを書いていた時によく感じることに以下のようなものがありました。

  • Hook のテストは書いたがコンポーネントにあるロジックはテストしていない
    • コンポーネントに書かれたロジックや組み合わせ方が間違えていて不具合が出てしまうみたいな経験
  • 修正のたびにテストも直す必要がある
    • PR で修正コメントが入る度にテストも修正する必要が出てしまう
      • 修正のたびに毎回毎回治さなければならないので、テストに時間が取られている気持ちになりモチベーションが下がってしまう
  • 毎回直さなければならないので、そもそもテストを書くのが面倒に感じる、、、
    • 何度も同じようなテストコードを書いている気がする
      • 別のテストファイルで同じようなテストを書いた気がする、、、

自分で実装を行っている場面でもそうですし、あるいは誰かの PR をレビューしている場合でも同様のことを感じることがありました。

もちろん、テストはないよりはあった方が良いですが、せっかく書くのであればより効果的なテストを書きたいです。しばらくテストを書き続けてきて、上記の問題の多くはテストの責任範囲の切り分けがうまくできていないことから起きてしまっているのではないかと思いました。

public なメソッドをテストする

テストでは private なメソッドのテストは書かないという通説があります。
上記の原因はもしかすると実装の詳細をテストしてしまっていることに起因しているのではないかと考えました。

参考: t-wada 「プライベートメソッドのテストは書かないもの?」

private なメソッドは必ず public なメソッドから利用されているはずなので、public なメソッドを通してテストすることができるはずです。なので private なメソッドに対してテストを書くことは実装の詳細に対するテストを書くことになってしまうので書く必要はない、という話です。

React にとって public なメソッド

ただ、React を利用していると Class を書くことはほとんどありません。ファイルから export されていない関数や値に関しては private だと言えると思いますが、あまりそれ以外ではそれが private なのか public なのかを意識することは、少なくとも自分にとってはあまりありませんでした。

改めて React とテストを考えた場合、実際に画面に表示されて入出力を扱うのはコンポーネントです。つまり React にとって public なメソッドというのはコンポーネントではないかと考えました。
多くの場合、ある機能を満たす形でディレクトリが切られ、そこから 1 つコンポーネントを export する形をとっていると思います。実際に外から使われるこのコンポーネントこそ public な関数であって、これをテストすることで内部で使われているコードのテストができるはずではないかと考えています。

コンポーネントを通してテストを書くことでより効果的なテストを書くことができるというのが現在の考えです。

実際の例

例えば以下の様な構成のコンポーネントを考えます。

- Articles
  - index.ts // Articlesをexport
  - Articles.tsx
  - CategorySelect.tsx
  - ArticleList.tsx
  - Article.tsx
  - useArticles.ts
  - convertDate.ts

記事の一覧表示のコンポーネントで上部にSelectBoxがあり、カテゴリを選択すると記事の一覧を取得する様なコンポーネントです。
useArticlesuseStateを利用して状態を持っていたり、fetchを用いた処理などが行われる様な Hook で、convertDateでは日付を表示用の形式に変換します。
そしてindex.tsからArticlesというコンポーネントが export されています。

このコンポーネントのテストを書く場合は、export されているのがArticlesコンポーネントになるので、Articles.test.tsxを作成してテストを書きます。この export されているコンポーネントこそがここでの public な関数に当たるはずです。

test("初期表示時、reactの記事を取得するリクエストが送信されること", async () => {
  // ...
});

test("投稿日時が意図した形式で表示されていること", async () => {
  // ...
});

ここでは初期表示で意図したリクエストが呼ばれていることと投稿日時が表示用の形に変換されていることをテストで確認しています。
記事の取得はuseArticlesが持っている機能ですし、投稿日時の変換はconvertDateが持っている機能です。この様にしてArticlesをテストすることで別途useArticles.test.tsxなどを作らなくてもArticlesを通してテストすることが可能です。

「こんな経験はありませんか?」のコンポーネント内のロジックについても、コンポーネントをテストすることでクリアできると思います。

コンポーネントを通してテストを書くポイント

コンポーネントを通してテストを書く時に意識していることは以下です。

責任範囲を意識して、小さめの機能ごとにテストを書いて下位のコンポーネントから動作を担保する

小さめの機能ごとにテストを書いて下位のコンポーネントから動作を担保するイメージで僕は書いています。各コンポーネントの責任範囲を考えてテストを行い、テストされたコンポーネントを組み合わせることで上位コンポーネントの動作を担保したいという狙いです。
大きいコンポーネントでテストを書くと検証項目が多くなってしまいテストが非常に難しくなります。その点、小さいコンポーネントでは網羅的にテストを書きやすいです。
上位のコンポーネントではそのコンポーネントが担保すべき範囲のテストを書くといいと思います。下位のコンポーネントの内容を再度テストする必要はありません。それぞれの責任範囲を意識するのが大切だと思っています。

状態を持つ位置を下げて、コンポーネントの責任範囲を制限する

以前は fetch するコンポーネントが状態を多く持ちがちでした。

  • まとめて fetch することで fetch する回数を低減すること
  • fetch や状態と繋がっているとテストや Storybook で扱いにくくなる

などが大きな理由だったかと思います。
この辺は別の「React でのデータの取り扱いを振り返ると Next.js の App Router は意外としっくりくるかもしれない」という記事で詳細に触れていますが、現在はswrなどを利用することで fetch のタイミングをある程度コントロールできるようになりました(もちろんReact Server Component(RSC)もそうです)。
また、mswで API のモックが容易になり、多くの Provider もcustomRenderを作成することで注入できるため、およそどのようなコンポーネントでも意識せずにテストや Storybook で扱えるようになりました。現在RSCの非同期コンポーネントは Storybook や Testing library で上手く扱えない問題がありますが、コンセプトとしては状態をより末端に持っていけるようにするという部分は変わらないと思います。

こういった傾向から、状態をより下方で持つことで 1 つ 1 つのコンポーネントの責務を小さくまとめることができるようになったと思います。
各コンポーネントの責務が制限されていればもちろんテストの書きやすさにも繋がります。

全ての状態を網羅するのが難しい場合

ただ、そうは言っても状態が複雑など、全ての状態を網羅するのが難しい場合もあります。責務を持ちすぎていてリファクタリングをした方が良い場合もあるかもしれませんが、単純に状態が複雑なコンポーネントもあるかと思います。
そういう場合はrenderHookなどを利用して個別にテストを書いてそこで担保してしまっても良いと思います。この場合はコンポーネントを通したテストでは代表的なケースのみにするなどバランスをとっても良さそうです。public な関数をテストするという観点からは逸れますが、テストがわかりにくくなってしまったり、テストしにくくなるよりはずっといいと思います。

共通で使われるカスタムフック

コンポーネントに紐づいた Hook はコンポーネントと同じディレクトリに配置されると思うので、そのコンポーネントを通じてテストすると思います。ですが、大抵のプロジェクトでは、複数箇所から利用される Hook を集めたsrc/hooksみたいなディレクトリにカスタムフックが集められているのではないでしょうか。
こういう Hook に対して 3 年前の時点ではもちろんrenderHookを利用してテストをしていましたが、今はコンポーネントをつかってテストを書くことにしています。

理由は 2 つあって、1 つ目は Hook は必ずコンポーネントで使われることです。コンポーネントで使われるのであれば、コンポーネントを利用してテストする方が良いと考えています。
もう 1 つはテストでコンポーネントを利用することで、その Hook の使い心地がわかります。思ったより使いにくいとか、意図した interface になっていないとかに気づきやすいです。もちろん Hook の使い方の例示にもなります。

実際にはテスト用に以下のようなコンポーネントを作成しています。

const Component = () => {
  const { onCategoryChange, isLoading, error, articles } = useArticles({
    initialCategory: CATEGORY.react,
  });
  const changeAngular = () => {
    onCategoryChange(CATEGORY.angular);
  };
  return (
    <div>
      <button
        type="button"
        data-testid="changeAngular"
        onClick={changeAngular}
      />
      {isLoading && <div data-testid="loading" />}
      <ul>
        {articles.map((article) => (
          <li data-testid="article" key={article.id}>
            {article.title}
          </li>
        ))}
      </ul>
      {error != null && <div data-testid="error" />}
    </div>
  );
};

Hook を利用した、動作確認に必要な最小限のコンポーネントになっています。中身としては、例えば、isLoadingが true の時だけ表示されるコンポーネントを用意したり、articlesの中身を確認するためのlistを用意したりしています。基本的にはどれもtestidで要素を掴める様にしています。
testidで要素を掴む理由としてはrole だと遅いらしいということと、テスト用のコンポーネントなのでアクセシビリティなどにこだわる必要がないという理由があります。

このコンポーネントを操作してテストを行います。

test("記事情報の取得中isLoadingはtrueになり、取得後はfalseになる", async () => {
  const articles = generateMockArticles({ category: "react", length: 3 });
  server.use(
    rest.get(buildEndpoint("react"), (req, res, ctx) => {
      return res(ctx.json(articles));
    })
  );
  render(<Component />);
  expect(screen.getByTestId("loading")).toBeInTheDocument();
  expect(await screen.findByTestId("loading")).not.toBeInTheDocument();
  expect(screen.getAllByTestId("article").length).toBe(3);
});

getByTestIdを利用しているところは違いますが、コンポーネントのテストの書き方とほとんど一緒です。renderHookを利用したテストと比較するとコンポーネントを作る手間がありますが、そこまで大きな差はないと思います。
また、renderHookを利用した場合は fetch などを呼ぶときに await で待つ必要があるのでisLoadingのテストが書きにくかった印象があるのですが、コンポーネントを利用したテストであればその辺も問題なく書くことができます。

テストを書いていて感じた疑問

ここではこんな経験はありませんか?や 3 年前に考えていたことに対してどう考えているのかを記載します。

Q: 同じようなテストを何回も書いている気がする

A: どこで何を担保するのかを考えてテストを書くことで解消できそう

「こんな経験はありませんか?」の同じ様なテストを何回も書いている気がするという話についてです。
例えば、先述のArticlesの例で、もしconvertDateuseArticlesで使われている場合はそれぞれにテストを書くとかなり似てしまうことが想像できるかと思います。実装の詳細のテストになっているパターンです。その場合は export されるコンポーネントに対してテストを書くことで解決ができそうです。

ただ、fetch するコードはプロジェクトの規約的に一箇所にまとめている場合も多いと思います(src/serviceなど)。となると通常 fetch する関数のテストはそのディレクトリで書くことになります。その場合、ArticlesでもSelectBoxを変更した時にリクエストを確認するようなテストを書くことになり同じ様なテストを書くことにつながります。
ですが、そもそも同じ様なテストを書いてはいけないということはないと思っています。
fetch 側で書くテストとコンポーネントで書くテストはそれぞれの責任範囲が異なるはずです。なのでテストで担保したいこともそれぞれ異なります。
逆にいえばディレクトリ構成を考えることで、より効果的なテストを書くことができるようになるかもしれません。例えばすべての Hook をsrc/hooksに入れてしまうと Hook とそれを使う両方のコンポーネントでテストが必要になり、それぞれで重複する範囲が出てしまうかもしれません。しかしもしそれが 1 箇所でしか使われないのであれば、コンポーネントのそばに Hook をおくことでコンポーネントのみにテストを書けば済むケースもあると思います。
各ディレクトリごとにどんなことをテストすべきなのかや、ディレクトリ構成を考える時にテストについても考慮することで責任範囲を考慮できると良さそうだと思います。

Q: コンポーネントは DOM 構造と結びついているのでテストが壊れやすいのでは?

A: そんなことはなく、むしろ a11y への意識向上に繋がります

3 年前にコンポーネントのテストを避けていた時に感じていた疑問に、コンポーネントは DOM 構造 と結びついているのでテストが壊れやすいのでは?というのがありました。あるいはアクセシビリティはテストすべき項目であってテストに利用すべきものではないのではないか?という気持ちもありました。
しかし実際に今までテストを書いてきた感じでは、確かにラベルなどのテキストで要素を掴んだりする場合にはテキストを変更すればテストが落ちるというのはありますが、DOM 構造と結びついているからテストが壊れやすいというのはありませんでした。
コンポーネントのテストではgetByRoleのようなアクセシビリティの属性を利用して要素を取得することが多いです。roleを利用することで実装(コンポーネント)の詳細を意識せずににコンポーネントを扱うことができるので、案外 DOM 構造の変更の影響を受けにくいと感じました。

また、テストで状態を検証するために WAI-ARIA などを意識することが増えます。例えばtabの切り替えを検証するためにaria-selectedを用いて状態を表現することで、UI の状態をテストで扱えるようになります。WAI-ARIA を用いることでテストがしやすくなるため、結果としてアクセシビリティ への意識が高まるという側面もあると感じました。
アクセシビリティはテストすべき項目ではあるのですが、role などの属性が HTML を非常にうまく抽象化しているので、HTML を DOM と切り離した概念として扱うのに扱いやすい項目でもあったということがわかりました。

ちなみに、妥当な属性がない場合はdata-selectedの様な data 属性を利用したりもします。
少し話はそれますが、data 属性や WAI-ARIA の属性を利用してスタイリングすることで class 名を付け替えたりせずにスタイルを切り替えることもできて便利です。

Q: カスタムフックではない関数のテストもコンポーネントでテストするべき?

A: 共通の関数などはコンポーネントを利用してテストする必要はない

カスタムフックではない関数はコンポーネントを利用してテストする必要はないと考えています。コンポーネントと密接に結びついているのであればコンポーネントからテストできるのでコンポーネントからテストするべきですが、そうでないならコンポーネントを使う必要はないと思います。
Hook が含まれているイコール React の文脈が入っているということなので、その場合は特別な理由がない限りはコンポーネントを利用してテストを書く方が良いのではないかという考えです。

Q: 状態を持たないコンポーネントもテストはいらない?

A: 何をテストすべきかはディレクトリなどの責任範囲による

いわゆる Atom 層などのロジックを持たないコンポーネントをテストすべきかは、そのコンポーネントが担保すべき責任範囲によると考えています。
例えば共通で使われるコンポーネントで、アクセシビテリティ的に問題がないことを示しておきたいみたいな場合ではテストを書くことは有用だと思います。特にコンポーネントライブラリではあったほうが安心かもしれません。

テストが担保すべきこと、そうでないこと

テストを行う意味としてはいくつかあると考えています。

  • 自分が書いたコードが意図通りに動いているかを確認するため
  • 意図しない変更がないことをコード上保証するため
    • 例えばライブラリのアップデートでや、大きなリファクタリングを行った時にテストのありがたみがわかります
      • ライブラリと繋ぐ境界ではテストで動作を縛っておくとより安心だと思います
  • コードの仕様を把握しやすくするため
    • テストケースは 1 つのことを扱い、「〇〇の時は xx になる」のような書き方にすると把握しやすいです
    • コンポーネントの振る舞いを通じてテストを記述することで、テストコードを仕様書に近づけることができるかもしれません

しかしながら、テストコードを書いたからといって不具合が 0 になるかというとそうではありません。
テストコードではあくまでテスト環境での実行が保障されているだけですし、Visual Regression Test を入れたとしても全ての画面を、サポートする全てのブラウザで確認することは難しいでしょう。E2E テストももちろん全てのケースを網羅することはできません。結局最終的には現状は手動でテストを行うことが必要になってくると思います。
ただ、不具合は 0 にならず、手動テストも不要にはなりませんが、意図しない不具合を減らすことは可能です。いろいろな手法を掛け合わせながら、今書いているテストでは何が担保されているべきかを念頭にテストを書くことでより良いテストにすることができると考えています。

まとめ

  • React のテストすべき public な関数はコンポーネント
    • export されているコンポーネントを通してテストを書く
  • 小さめの機能ごとにテストを書いて下位のコンポーネントから動作を担保するイメージでテストを書くと良さそう
    • どこで何を担保するのかを考えてテストを書くとより良いテストにつながる
    • コンポーネントを利用したテストを書く副次的な利点として アクセシビリティへの意識向上に繋がる
  • カスタムフックも基本的にはコンポーネントを利用してテストを書く
    • Hook は必ずコンポーネントで使用される
    • 使用感がわかり、使用方法の例示になる
  • 今書いているテストでは何が担保されているべきかを念頭にテストを書く

というわけで

長くなりましたが、どんなテストであってもテストがまったくないよりはあった方が良いと思っています。テストはプロダクションコードには影響がないので、不要になれば捨てたら良いくらいの気持ちで書いてもいいものだと思います。
この先RSCの登場などもあってまた考えが変わっていくこともあると思いますが、実際に書かないと掴めない部分も大いにあると思っています。

テストは楽しいのでどんどん書いていきましょう🔥

株式会社ZOZO

Discussion