0からフロントエンドにテストを導入した話
こんにちは。株式会社EVERSTEELで業務委託のソフトウェアエンジニアとして参画している日野原です。
主にフロントエンドを担当しており、技術としてはNext.jsを使用しています。(詳しい技術内容はこちらを参照)
少し前の話になりますが、ゼロからテストを導入したので、その過程や戦略について話していこうと思います。
フロントエンドのテストを検討している方や、テストの運用方法を迷っている方の参考になるかと思います。
Reactアプリにおけるテスト戦略と実践ガイド
一昔前はフロントエンド開発においてテストはあまり重要視されていませんでした。
しかし、フロントエンドの複雑さが増したため、最近ではテストが重要視されており、テストを書くことが求められています。
ただ、なぜテストが必要なのか、どのようなテストを書けば良いのか、どの程度のバランスでテストを構成すべきなのかは、十分に理解されていない場合も多いかと思います。
なのでまず、フロントエンドのテストを書く際の考え方を整理していきます。
なぜテストを書くのか
テストを書くことには、デグレを防いでリファクタリングや機能追加の際に安心してコードの変更が行えるというメリットがあります。
ただ、全てのコードにおいてテストを書けばいいというわけではありません。
テストを書くことには相応のコストが伴います。
具体的には以下の点が挙げられます。
- 工数: テストを書くために工数が必要になる。
- 実行時間: テストが増えすぎると実行時間も長くなる。
- 管理コスト: テストを書くことによって管理コストも増える。
そのため、闇雲にテストを増やすのではなく、「どの部分にどれだけテストを書くべきか」を戦略的に考える必要があります。
EVERSTEELの例
私たちのプロジェクトにおいては、全体的にUIを刷新したタイミングがあり、自動遷移などが増えてフロントエンドの複雑性が増しました。
また、古い方と新しい方のUIを工場ごとに出し分けていたため、両方のUIの動作確認をする必要がありました。
その結果、不具合の見落とし等が発生するようになってしまったため、テストを導入することにしました。
テスト戦略の指針
フロントエンドのテスト戦略を考える上で非常に参考になるのが、Testing Libraryの作成者であるKent C. Dodds 氏が提唱した テスティングトロフィーという考え方です。これは、どの種類のテストにどれだけのリソースを投入すべきかを、トロフィーの形で視覚的に表現したフレームワークです。
テスティングトロフィーは下から「Static」「Unit」「Integration」「End to End」の4層で構成されています。各テストにかける労力のバランスがトロフィーのような形になることから、このように呼ばれています。
各層の特徴と役割
Static テスト(静的解析)
TypeScript、ESLint、Prettier などによる静的解析
- 特徴: 実行コストが最も低く、即座にフィードバックが得られる
- 配分: 一度設定すればほぼコストをかけずにテストを維持できるため、開発体験の土台となる
Unit テスト(単体テスト)
個別のコンポーネントや関数の動作を検証
- 特徴: 高速で安定、コストも比較的軽い
- 配分: 複雑なロジックやクリティカルなロジック、再利用性の高い部分に重点的に適用
Integration テスト(結合テスト)
複数のコンポーネントやモジュールが連携した状態での動作を検証
- 特徴: 実際の使用に近い状況をテストしつつ、適度なコストで実施が可能
- 配分: テスティングトロフィーのメイン部分として最も重要視される層
End to End テスト(E2Eテスト)
Cypress や Playwright を使用した実ブラウザでの全体シナリオ検証
- 特徴: 最も実環境に近く信頼性が高い一方、実行コストと維持コストが最大
- 配分: クリティカルなユーザーパスに絞って実施
なぜ Integration テストが中心なのか
テスティングトロフィーの大きな特徴は、Integration テストの層が最も厚く描かれている点です。
これは以下の理由によります:
- 実用性とコストのバランス: Unit テストよりも実際の使用に近く、E2E テストよりも高速で安定
- React の特性: Reactのアプリでは複数コンポーネントを組み合わせて使用する
- ユーザー視点: 実際のユーザーは単一コンポーネントではなく、画面全体として機能を利用するため
Kent C. Dodds 氏は「テストはソフトウェアの使い方に似ているほど、その価値は高まる」と述べています。
Integration テストは、その思想を体現し、理想と実用性を両立する最適解とされているのです。
フロントエンド、特にコンポーネントベースでUIを構築するReactの性質上、テスティングトロフィーの考え方を適用することで、コストパフォーマンスに優れたテスト戦略を立てることができます。
EVERSTEELのフロントエンドにおけるテスト戦略
EVERSTEELのプロジェクトにおいても、基本的にはこのテスティングトロフィーの考え方をテスト戦略の指針としています。
その際の鍵になっているのが、ディレクトリ構成です。
ディレクトリ構成
まずはディレクトリ構成を簡単に紹介します。
src/
├── hooks/ # 共通で使用するhooks: テスト対象(単体テスト + 結合テスト)
├── utils/ # 共通で使用する関数: テスト対象(単体テスト + 結合テスト)
└── components/
├── pages/ # ページコンポーネント: テスト対象(結合テスト)
| └── Profile/
└── uis/ # 共通で使用するコンポーネント: テスト対象(単体テスト + 結合テスト)
└── Icon/
├── hooks/ # このコンポーネントでしか使用しないhooks: テスト対象外
├── utils/ # このコンポーネントでしか使用しない関数: テスト対象外
└── uis/ # このコンポーネントでしか使用しないコンポーネント: テスト対象外
このように、複数の箇所で再利用される共通モジュールと、特定の箇所でのみ利用されるモジュールをディレクトリレベルで明確に分離しています。
このルールにより、テストを書くべき対象を重要度の高い共通モジュールに絞り込むことができ、テストの範囲を限定的に保てます。
例えば、Atomic Designのような粒度でコンポーネントを管理している場合、どのコンポーネントがどの範囲で使われているかを把握するのが難しく、テストを書くべきかどうかの判断が曖昧になりがちでテストの数が増えてしまいます。
しかしこのアプローチでは、むやみに単体テストを増やすことを避け、テスティングトロフィーが示すようなバランスの取れたテスト構成を目指すことができます。
ただ、「共通で利用されないが、複雑なロジックを持つ関数やクリティカルな部分」は例外的にテストを書いても良いかと思います。今回のプロジェクトはそれほど大きくなく、そのような関数も無かったので特に例外は設けてないです。
また、テストを書くかどうかの判断を明確にできるので、チーム内の他メンバーとテストを書くべきかどうかの基準を簡単に共有することができるのもメリットとなります。
特に私たちのプロジェクトにおいては、フロントエンドのテストを書くという文化がなかったため、このような明確なルールを設けるのは良い判断だったと思います。
単体テスト
テストを導入するにあたって、まずは単体テストから作成していきました。
単体テストや結合テストはJestとReact Testing Libraryを使用しています。
最近ではAIを活用すれば、単体テストは比較的容易に作成できます。
そのため、導入コストは以前に比べて格段に低くなっています。
もし単体テストを未導入のプロジェクトがあれば、そのコストパフォーマンスの高さを考えて導入することをおすすめします。
結合テスト
単体テストとは違い、結合テストは複数のファイルにまたがって内容が少し複雑になったり、ビジネスロジックが関わったりするので、導入当時のAIでは自動生成することが難しかったです。
そのため、結合テストの実装にはある程度の工数が必要でした。
なのでここはタスクが落ち着いているときに集中して作成に取り組みました。
このように導入のハードルは少し高いですが、テスティングトロフィーが提唱している通り効果の高いテストです。
私たちのプロジェクトではページコンポーネントを必ずテスト対象としていますが、こうすることで共通化されてないがクリティカルなロジックをテストすることができます。
導入を検討しているプロジェクトはまとまった時間を作るか、テスト作成に集中するメンバーを割り当てる方法がおすすめです。
今はAIの性能も上がっており、結合テストでもテストの骨組みまで作れるようになっているため、多少導入コストはかからなくなっていると思います。
E2Eテスト
E2EテストはPlaywrightを使用しています。
Cypressとどちらを使用するか悩みましたが、テストの並列実行が可能だったりビジュアルリグレッションテストも簡単に導入できたりと、機能的にPlaywrightの方が便利な点がいくつかあったり、導入の2024年当時にPlaywrightの方がダウンロード数の面でも勢いがあったのでそちらを選択しました。
E2Eテストにおいては、ユーザーにとってクリティカルな部分しかテストを実施していません。
E2Eテストはユーザーの動作に近い分、最も効果の高いテストを行えますが、複雑性が増す分壊れやすく管理コストがかかってしまいます。
なので、クリティカルな部分にテストを絞って実施しています。
テストを絞ることで導入コストも抑えることができました。
Playwrightは今回初めて使用しましたが、書き心地もJestと同じように書けるので学習コストもほぼかけることなく導入することができました。
ビジュアルリグレッションテスト
ビジュアルリグレッションテストは、テストを実行した際のスクリーンショットと、テストを実行する前のスクリーンショットを比較して、差分があるかどうかを確認します。
このテストを実施することで、意図していないUIの変更がないかを確認することができます。
Playwrightを使うとビジュアルリグレッションテストも比較的簡単に導入できるため、一通り作成してみました。
しかし、私たちのプロダクトはまだデザインの変更が頻繁に発生するフェーズです。そのため、テストを維持・管理するコストがメリットを上回ると判断し、現在は本格的な運用を見送っています。
逆に、デザインがある程度安定しているプロダクトでは、意図しない表示崩れを防ぐために非常に有効な手段となるかと思います。
手動で全ページのデザイン崩れをチェックするのは大変で、どうしても見落としが発生しがちです。テストで自動的に担保できるのであれば、それに越したことはありません。
フロントエンドのテストにおいて意識すべきこと
最後に、フロントエンドのテストを書く際に意識しておいた方が良いことを解説しようと思います。
テストは「ユーザーが達成したいこと」を守るために書くべきです。
ユーザーは内部のステートや関数呼び出しに関心がありません。
関心があるのは、ボタンを押したら画面がどう変わるか、必要な情報が見えるか、入力エラーが正しく案内されるかといった「体験」です。
したがって、テストも実装の詳細ではなく、ユーザーの操作とUIの振る舞いにフォーカスするべきです。
つまり、ユーザー目線のテストを書くことが重要となります。
具体的には以下のことを意識すると良いです。
- 操作と結果で検証する: クリック・入力などの操作を再現し、その結果として画面に何が見えるかを検証する。
- 実装詳細のテストを避ける: 内部関数の呼び出し回数・コンポーネントの state・内部的なユーティリティへの依存をアサートしない。
-
アクセシビリティに沿ったクエリを使う:
getByRole
/getByLabelText
/getByText
を優先し、data-testid
は最後の手段にする。
具体例: ログインフォームのテスト
これらの原則を実際のテストコードにどう落とし込むか、ログインフォームを例に見てみましょう。
悪い例:実装の詳細をテストする
// 悪いテストの例
it('ログインボタンをクリックしたら、handleSubmitが呼ばれること', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// ❌ 原則3違反: `data-testid` を使用
await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
await userEvent.click(screen.getByTestId('submit-button'));
// ❌ 原則1違反: 画面の結果ではなく、内部関数の引数を検証している
// ❌ 原則2違反: `handleSubmit`という内部関数の呼び出しをテストしている
expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});
このテストの問題は、ユーザーが最終的に目にする結果(例:ログイン成功メッセージの表示)ではなく、onSubmit
プロパティの呼び出しという「実装の詳細」を検証している点にあります。
このようなテストは、リファクタリングによって容易に壊れる可能性があります。
例えば、onSubmit
に渡す引数の順序やデータ形式を変更しただけで、ユーザーから見た挙動は何も変わらないにもかかわらず、テストは失敗してしまいます。
さらに重大な問題は、ユーザー体験を保証できないことです。onSubmit
が正しく呼び出されたとしても、その後の処理が失敗すれば、ユーザーにとっては機能が壊れていることになります。
しかし、このテストは成功してしまうため、バグを見逃す原因となります。
また、ユーザーはtestIdを見て入力する内容を考えているのではなく、ラベルなどを見て入力する内容を判断しています。なので、testIdを使用することで実際のユーザーの振る舞いとは差異ができてしまいます。
その結果、誤ってラベルの文言を変更してしまってもテストでエラーが出ずに、バグに気づけないテストとなってしまっています。
良い例:ユーザーの振る舞いをテストする
it('有効な情報を入力して送信すると、成功メッセージが表示される', async () => {
render(<LoginForm />);
// ⭕️ 原則3: 適切な**クエリを使用する**
const emailInput = screen.getByRole('textbox', { name: /メールアドレス/i });
const passwordInput = screen.getByLabelText(/パスワード/i);
const submitButton = screen.getByRole('button', { name: /ログイン/i });
// ⭕️ 原則1: **操作と結果で検証する**
// ⭕️ 原則2: **実装詳細のテストを避ける**
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(passwordInput, 'password123');
await userEvent.click(submitButton);
expect(await screen.findByText('ログインしました')).toBeInTheDocument();
});
良いテストは、ユーザーが実際に行う操作(入力、クリック)を再現し、その結果として画面に何が表示されるかを検証します。
これなら、内部実装がどう変わろうと、ユーザー体験が維持されている限りテストは成功し続けます。
つまりユーザー目線で書かれたテストは、実装の自由度を保ちながら体験の品質を強く守ります。
結果としてリファクタリングに強く、長期的な保守コストを下げることにつながります。
まとめ
今回は、ゼロからテストを導入した事例とその際に意識したテスト戦略について紹介しました。
テスト戦略の指針として「テスティングトロフィー」を意識しつつも、最も重要なのは「テストがユーザーの視点になっているか」という視点です。
実装の詳細ではなく、ユーザーの振る舞いに焦点を当てることで、コードの変更に強く、保守しやすいテストを書くことができます。
現在はポストモーテムを実施していますが、テスト導入前は特にそのような定期的な障害の振り返りを行っておらず、厳密な比較はできないですが、体感や最近のポストモーテムの内容的にテスト導入前に比べてフロントエンドにおけるクリティカルな障害はかなり減ったかなと思います。
今回の記事がこれからテストの導入を検討しているプロジェクトや、既存のテストに課題を感じている方の参考になれば幸いです。
Discussion