🐸

フロントエンドのテスト戦略について考える

2022/04/20に公開

こんにちは。株式会社スタメンでFANTSのフロントエンドを担当している@0906kokiです。

今回の記事では、FANTS におけるフロントエンドのテスト戦略について書きたいと思います。

🙋🏻‍♂️ はじめに

みなさんはフロントエンドのテストを書いていますでしょうか?
私が所属しているチームでは、今まで全体的なテスト指針が明文化されていなかったので、機能によってテストが書かれたり書かれなかったり、テストを書くにしても個人によって書く粒度にバラツキがありました。

直近でフロントエンドを書く人が増えていく / プロダクトがスケールしていくにつれて、そうしたバラツキによって生まれるコミュニケーションコストが大きくなってきたり、システム的な安全性を継続的に担保していくことが難しくなっていくように感じました。そのため、今まで方針を定めていなかったテスト戦略を、これから事業やプロダクト、チームがスケールしていくこのタイミングで明文化し、この指針に沿ってテストを書いていくことで、先程挙げた課題感を解決していきたいと考えました。

🤔 テストを書く意味

テストを書くにあたって、テストを書くメリットについてチームが同じ理解を持つことが大切です。もしテストを書く ROI が低いと感じる場合は、テストを書かないのも一つのテスト戦略とも言えます。

テスト書く理由は、基本的なことですが、安全で高速にリリースできるようにすることだと思います。例えば、ある機能のリリースの際に、その機能が正しく動作していることを担保するために、手動テストを実施したとします。その機能のテストがパスしたとしても、全く別のところで不具合が発生している可能性もあるため、全体的に手動テストを行いたいですが、手動であるのでスケーラビリティはなく、機能が増えていくにつれてテスト実施のコストは肥大化していきます。次第に、事業成長のためにテスト工数よりも機能追加が優先され、次第にテストが書かれていかなくなることが予想されます。その結果として安全性が担保されなくなっていき、事業成長のためにテストを書かない選択が、逆に事業成長の足枷になる可能性があります。

手動でのテストではなく、自動テストを書くことで、ある一つの変更によって不具合を混在させていないことを事前に検知でき、変更のコストが下がることで、結果として品質の高いコードをリリースできるようになります。

もちろん、テスト自体を書く・メンテナンスしていくコストも発生するので、プロダクトが当たるかどうか分からないので MVP を作ってまずは検証したい場合は、テストを書かないことも一つの手かもしれません。
また、正しい箇所に正しくテストを書かないと、テスト自体もソフトウェアであるので、負債を生むことになり、そうした点もテストを書くにあたって考慮しなくてはいけません。

👽 フロントエンドのテストの種類

ここまでで、テストを書く意味を書きました。概ねテストを書くことに意味はありそうですが、テスト戦略を考える前に、フロントエンドのテストにどういった種類があるか見ていきたいと思います。いくつかあるテストを組み合わせて、適切に取捨選択する必要があるからです。

フロントエンドのテストは大きく分けて 4 つのテストがあると思います。

  • 静的テスト

    • eslint 等を使った静的解析や、TypeScript 等を使った静的型チェック、typo チェックなど
    • 現代のフロントエンドにおいては、ベースラインとして必須
  • 単体テスト

    • jest, mocha 等を使った最小単位の関数やコンポーネントのテスト
    • 統合テストや UI テストと比較して、最も結果のフィードバックが早く、実装コストも低い
  • 結合テスト

    • React Testing Library, Enzyme 等を使い、コンポーネント、hooks を組み合わせて正しく動作するかをテストする
    • 実行速度や実装コストは単体テストと UI テストの中間に位置する
  • UI テスト(E2E)

    • ブラウザ上でユーザーと同じユースケースを想定したシステム全体のテスト
    • 実行時間が最も遅く、実装コストも他のテストと比較すると高いが、ユーザーと近い環境でテストを行うため、信頼性が高い

💸 トレードオフ

4 つのテストのそれぞれを選択してテストの方針を立てるわけですが、それぞれのテストには実行速度や実装コスト、テストに対する信頼性などの観点で、メリット・デメリットが存在します。
以下は React Testing Library の開発者である Kent C. Dodds が提唱しているテストトロフィーです。

単体テストや結合テストによってこのテストトロフィーをレイヤリングしていますが、レイヤーにおいて以下の特徴を持っています。

  • 上のレイヤーに行くほど、フィードバックの速度は遅い
  • 上のレイヤーに行くほど、実装コストは高い
  • 上のレイヤーに行くほど、信頼性は向上する

一番上のレイヤーに位置する E2E テストですが、E2E テストは皆さんもご存知の通り、実際にブラウザやサーバーを起動するため、他のテストと比較すると非常に実行時間が長くなります。
また、E2E がカバーする領域は非常に広く、実際のユーザーシナリオ上でテストを行うため、テストによる信頼性は高くなる一方で、エラーが発生したときの原因特定に時間がかかったり、テスト結果も不安定になったりします。

一方で下のレイヤーにある単体テストは、他の結合テストや E2E テストと比較すると、局所的なユニットにおけるテストであるため、テストが失敗したときのデバッグがしやすかったり、テスト結果のフィードバックが非常に早いのでイテレーションを回しやすいです。一方で、1 つのモジュール、コンポーネントのテストであるからこそ、それらを組み合わせたときに正しく動作するかは保証しないため、外部品質は担保できません。

信頼性という観点だけで言うと、E2E にテストリソースをすべてかけることで信頼性は上がるかもしれませんが、その E2E の実装、実行、メンテナンス自体に非常にコストがかかるため、E2E だけにテストを集中させることは現実的に難しいです。

つまり、それぞれのテストには上記に挙げた観点でメリット・デメリットが存在するので、それぞれを比較・検討した上で、どのテストを、どこの機能に、どの粒度でテストを書くか、テスト戦略を立てる必要があります。

🧠 テスト戦略

あくまでテスト戦略というものは、所属しているチームやプロダクトで変わるものなので、今から紹介する戦略がベストプラクティスではありませんし、立てたテスト戦略自体も事業のフェーズやプロダクトの拡張、テストツール自体の進化によって変わっていくものだと思います。

私が所属しているチーム / プロダクトは以下のような特徴を持ちます。

- サーバーサイドを Rails、フロントエンドで React を使用している
- チームのエンジニアの数は合計 7 名(業務委託、インターンを除く)
- そのうち、フロントエンドを専任で書く人は自分 1 人で、バックエンドの人がフロントエンドを書いたりもする
- 開発するプロダクトは、ローンチから 2 年ほど経過している

上記のトレードオフとプロダクトの性質、チームリソースを考えて、以下のテスト方針を立てています。


  1. トレードオフの観点でバランスのよい結合テストを厚めに書く
  2. E2E テストは、課金導線やタイムラインなどの、不具合が発生するとビジネス上のネガティブインパクトの大きい箇所だけ書く
  3. 単体テストは、明らかにテストしなくても自明なロジックに対しては書かない。複雑性が高いビジネスロジックの関数に関しては書く
  4. 静的テストはベースラインとして必ず引く。導入が後回しになればなるほど導入コストが跳ね上がるので、プロジェクトの最初に必ず入れる

1. トレードオフの観点でバランスのよい結合テストを厚めに書く

上記でトレードオフについて書いたように、結合テストは単体テストと比較して信頼性が高く、E2E と比較してコストも低く抑えることができ、ROI が一番高いと思います。

ひとえに結合テストといっても、様々な切り口でフロントエンドの結合テストを書く箇所はあると思います。私が所属しているチームでは、Container / Presentational Component パターンでコンポーネントを分割しており、React Testing Library を使って、Container Component に結合テストを書くようにしています。
Container Component では、外部 API と連携する hooks や何かしらのビジネスロジックが注入される箇所であり、最も依存性が集中する箇所であります。
この Container Component に対して、実際のユーザーシナリオベースにテストを書くことで、テストに対して自信を持つことができます。

例えば、以下のような、フェッチした API を使って何かしらのテキストを表示するコンポーネントがあったとします。

import { VFC } from "react";

export const Container: VFC = () => {
  const { data, isLoading, error } = useFetchSomething();

  if (isLoading) {
    return <div data-testid="loading">...loading</div>;
  } else if (error) {
    return <div data-testid="error">{error}</div>;
  }

  return <Component text={data.text} />;
};

type ComponentProps = {
  text: string;
};

export const Component: VFC<ComponentProps> = ({ text }) => (
  <div>
    <h1 data-test-id="text">{text}</h1>
  </div>
);

こうした Container Component に対して、以下のようなテストを書いていきます。(あくまでサンプルなので、少し雑に書いています)

it('テキストをフェッチして、フェッチが完了するとレンダリングされる', async () => {
  render(<Container />)

  expect(screen.getByTestId('loading'))
  await waitForElementToBeRemoved(() => screen.getAllByTestId('loading'));
  expect(screen.getByTestId('text'))
}

it('テキストのフェッチが失敗すると、エラーメッセージがレンダリングされる', async () => {
  server.use(fetch500handler) // msw
  render(<Container />)

  expect(screen.getByTestId('loading'))
  await waitForElementToBeRemoved(() => screen.getAllByTestId('loading'));
  expect(screen.getByTestId('text'))
}

上記では、実際にこのコンポーネントがマウントしたときのユーザーシナリオベースでテストを書いていきます(フェッチ処理はMSW を使ってモックしています)。
このように、Container Component はフェッチ処理やユースケースロジックが注入し、Presentational Component へ渡すコンポーネントであるからこそ、ユースケースに沿ったテスト書きやすく、ユースケースカバレッジも担保することが可能になります。

また、内部実装に依存しないユースケースに沿ったテストを書くことで、リファクタリングに対しても動作保証を行うという観点でとても有効なものになります。

このように、結合テストは実装自体のコストやカバーする領域の広さ、テストの実行時間などの観点で ROI はとても高いものなると確信しています。

2. E2E テストは、課金導線やタイムラインなどの、不具合が発生するとビジネス上のネガティブインパクトの大きい箇所だけ書く

上記の結合テストでも、実際にデータベースへのアクセスやブラウザ上でのテストを実行するわけではないため、ビジネス上不具合が発生すると大きな損害を受ける箇所に対しては、E2E テストを書いて信頼性を担保します。私が開発しているプロダクトでいうと、ユーザーの課金周りと一番アクセスが集中するタイムラインが該当します。

今後フロントエンドを書く人がチームとして増えてきた場合や、E2E テストツールの向上により実装コストが低くなる場合は、E2E テストの適用範囲を広げていくことが考えられますが、現状エンジニアリソースが少ない + まだまだ機能追加にリソースを使いたいので、最低限必要な箇所にのみ E2E を書く方針を立てています。

3. 単体テストは、明らかにテストしなくても自明なロジックに対しては書かない。複雑性が高いビジネスロジックの関数に関しては書く

現代のフロントエンドにおいては、無数にあるコンポーネントを組み合わせて一つのアプリケーションを作りますが、それらのコンポーネントを一つひとつテストし、変更があれば修正するのにはコストが高すぎます。また、ロジックを持たない・ロジックが自明である箇所にテストを書いてもほどんど意味がありません。
フロントエンドにおける単体テストは沢山の文脈を持つ(関数ロジックのテストなのか、コンポーネントのスナップショットテストなのか、ビジュアルリグレッションテストなのか)と思いますが、私のチームでは、複雑性の高い関数ロジックのみ単体テストを書いています。
例えば、税率を計算するロジックや、並び替えを行うための order を計算するロジック、複雑な状態を更新する reducer / hooks のロジックなどです。こうした複雑なビジネスロジックに対するテストは、動作保証の自信となりますし、何より単体テスト自体が仕様書となります。

ビジュアルリグレッションテストに関しては、現状では導入していませんが、今後プロダクトがスケールしていきコンポーネントの数が増えてきた場合や、コンポーネントを共通ライブラリとして管理する場合、チームメンバーが増えてきた場合には導入するかもしれません。

4. 静的テストはベースラインとして必ず引く。導入が後回しになればなるほど導入コストが跳ね上がるので、プロジェクトの最初に必ず入れる

現状触ることのあるフロントエンドのコードは、すべて TypeScript で実装されており、husky + lint-staged で prettier と eslint を実行することで、問題のあるコードがコミットされないようにしています。
こうした静的解析ツールを使うことで、最低限防ぐべき不具合の回避やコードの記述方法の統一をある程度担保することができますが、プロダクトの拡大に比例して、導入するコストも上がっていきます。
例えば、eslint を後から導入すると、大量の eslint のエラーが出て、大量のコードを修正する必要があるので、こうした静的解析ツールはプロダクトが始まる一番最小に入れることが、費用対効果が一番高いと思うので必ず入れたいですね。

😊 最後に

今まではテストの方針がなく、テストの粒度がバラバラであったりしましたが、今回書いたテスト戦略を導入して、改めてテストの重要性を振り返る良い機会になりましたし、チームとしてテストの方針が定まったことで、フロントエンドのコードをより堅牢なものにでき、コミュニケーションコストも下げることができたと思います。
繰り返しにはなりますが、こうしたテスト戦略はチーム、プロダクト、事業フェーズなどによって変化するものなので、定期的にチームの中で運用方法を見直していきたいと思います。

最後まで読んでいただきありがとうございました!

参考記事

Discussion