はじめに
こんにちは、開発1部で出張訪問アプリケーションVisitの開発担当をしている望月です。
今回は、Visitチームでフロントエンドのテスト戦略を考え運用を始めたことについてお話しします。
VisitはReactをベースにしたクライアントサイドレンダリングのみをサポートする純粋なSPAです。
私たちのチームでは機能が増えるにつれQAチームの手動テストの負担が増加していました。
このことから、より早くより安全に開発をし続けるためにフロントエンドテストの戦略を考えました。
フロントエンドテストを考えるプロセスや結果が参考になれば幸いです。
なお、本記事ではパフォーマンス・アクセシビリティテストや具体的なテストコードの解説は記載しません。
背景と課題
私たちのチームではリリース前の最終検証をQAチームが手動テストとE2Eテストで実施しています。
しかし、E2Eテストは最重要な動線のみでプロダクトの機能追加によって手動テストの割合が増加し負担も増えていました。
特に、複雑な既存機能のリグレッションがQAチームの手動テストで検知されることがしばしばありました。
また、フロントエンドにはユニットテストやコンポーネントのビジュアルリグレッションテストが導入されていましたが、明確な方針などはなくReact Hooksのテストやインタラクションテストなどはありませんでした。
この状態のまま開発を進めるべきか自信を持てず、フロントエンドテストのあるべき戦略を考えることにしました。
あるべき姿
まずは何のためにテスト戦略を考えるのか? テスト戦略を考えた先にどのような効果を期待するのか? を言語化しました。
開発チームの重要な目標の1つは「より価値の高いものをより早く提供し続けること」だと考えています。
しかし、現状ではQAチームの手動テストの割合が大きいため下記のデメリットがあります。
- 品質担保に時間がかかるため、価値提供が遅れる
- リグレッションの検知が遅れると、価値提供も遅れやすい
- QAチームのスキルに依存するため、リグレッションに気付けない可能性がある
これらを踏まえてフロントエンドテストの理想的な状態は下記を満たしている状態だと定義しました。
- リグレッションを開発中に素早く検知できる
- テストのメンテナンスコストを小さく抑えること
- QAチームがより専門的なユーザ視点の確認など付加価値の高い作業にリソースを割ける状態
プロダクトについて理解を深める
プロダクトに合ったテスト戦略を考えるためには、プロダクトについての理解を深める必要があると考えました。
私たちのプロダクトでは画面単位で考えると、次のような処理フローになっています。
- 画面アクセス時にGetAPI等へリクエスト
- 正常系であれば、APIレスポンスをフォームなどが要求する形式に計算や変換
- フォーム入力時に入力値のバリデーションや、他の画面項目を更新する
- フォーム入力値を更新系APIのリクエストに変換
- 更新系APIにリクエスト
3の「他の画面項目を更新する」は、フォーム入力に応じて他のフォームを自動補完したり画面項目の表示や活性状態を更新する、という機能を指しています。
これをフローチャートにすると次のようになります。
フローチャート図を見るとかなりシンプルなアプリケーションに見えますが、実際には複雑な仕様や実装が含まれています。
複数のAPIレスポンスからフォームを生成・複数のフォーム項目の複雑な依存関係やバリデーション・非同期なバリデーション・複数の状態によるAPIの呼び分けなどです。
同時にこの作業を通じて、仕様や実装が複雑な画面でも構造上はシンプルであるという点にも気づきました。
このフローをいくつかのレイヤーに分けて役割を整理すると下記に分類できました。
フローチャート図には表現してませんが、全てのコンポーネントは見た目を包含してるのでビューレイヤーとし、utility関数や汎用的なReact Hooksなどのコンポーネントに依存しないロジックをロジックレイヤーとして追加しています。
レイヤー | 役割 |
---|---|
データ取得 | GetAPIやCDNなどにリクエストし、レスポンスに応じて表示内容を切り替える |
レスポンスデータの変換 | データフェッチした結果を、フォームなどのコンポーネントが要求する形式に変換する |
入力フォーム項目 | フォーム入力に応じて自身や他の表示項目の表示内容を更新する |
フォームバリデーション | フォーム入力に応じてバリデーションを行う |
フォーム入力値の変換 | フォームの入力値を、保存などのmutateAPIのリクエスト形式に変換する |
更新系APIリクエスト | 「保存」などのアクションでmutate系のAPIリクエストを実行し、そのレスポンスに応じて画面更新や画面遷移する |
ビュー | 状態ごとの表示内容を定義する |
ロジック | 特定のコンポーネントに依存しない汎用的なロジック |
テスト観点
レイヤーを整理したことでレイヤーごとに何をテストすべきか、という観点を検討しやすくなりました。
結果としてテスト観点はほとんどレイヤーの役割と対になっています。
レイヤー | テスト観点 |
---|---|
データ取得 | APIレスポンスに応じた表示内容が意図通りであること |
レスポンスデータの変換 | APIレスポンスからフォームへの変換ロジックが意図通りであること |
入力フォーム項目 | フォーム入力時に自身や他画面項目の振る舞いが意図通りであること |
フォームバリデーション | フォーム入力に対してバリデーションロジックが意図通りであること |
フォーム入力値の変換 | フォームの入力値からAPIリクエストの変換ロジックが意図通りであること |
更新系APIリクエスト | APIレスポンスに応じた画面の振る舞いが意図通りであること |
ビュー | 状態ごとの表示内容が意図通りであること |
ロジック | ロジックが意図通りであること |
テスト分類
レイヤーごとのテスト観点は「ロジックが意図通りであること」「表示内容が意図通りであること」「振る舞いが意図通りであること」の3種類に大別できました。
これらを検証する方法としてロジックを検証するために「ユニットテスト」、表示内容を検証するために「ビジュアルリグレッションテスト」振る舞いを検証するために「インタラクションテスト」の3種類のテストをすることにしました。
それぞれの役割と特徴は下記の通りです。
- ユニットテスト
- 純粋関数やHooksなどのユーザの目に触れない内部ロジックの正しさを検証するテスト
- 単位:関数、hooksなどのロジック
- 対象:データ取得 / レスポンスデータの変換 / フォームバリデーション / ロジック
- ビジュアルリグレッションテスト
- コンポーネントの状態ごとの表示を実装前後で比較し差分を検出するテスト
- APIモックと合わせることでAPIレスポンスごとの表示内容も検証する
- 単位:コンポーネント
- 対象:ビュー
- インタラクションテスト
- 画面操作などユーザイベントに対して、アプリが正しく反応するかを検証するテスト
- 単位:画面
- 対象:入力フォーム項目
これでプロダクトに必要なフロントエンドテストを分類できました。
テストの優先順位
テストの分類を決めることができたので、次にテストの優先順位について考えていきます。
ユニットテスト・ビジュアルリグレッションテストはすでに導入済みであり、メンテナンスもしやすいため最も重要と位置付けました。
また、これらは既に導入済みのため、今後の拡充も容易だと判断しています。
一方で、インタラクションテストについてはすべての画面に導入すると 「全ての画面に導入するのは非効率ではないか」「全ての振る舞いに対してテストするのは非効率ではないか」という声が上がりました。
現状プロダクトにはおよそ50画面あり、これら全ての画面にインタラクションテストを導入することは現実的ではありません。
また、全ての振る舞いに対してテストを書こうとすると、軽微な画面項目の追加の度にインタラクションテストを改修しなければなりません。
そこで、インタラクションテストは全ての画面に導入するのではなく、特に複雑な画面と項目のみに導入することに決めました。
その理由は以下の通りです。
- プロダクトの核となる画面は仕様が複雑になりやすく、頻繁なエンハンスが求められる
- 複雑な画面ほど、QAチームによる手動テストの時間がかかりやすい
- 複雑な画面ではリグレッションの発生頻度が高い傾向にある
ここまでの内容を図にするとこのようになります。
テスト戦略を決めた効果
テスト戦略をチームで決めてから執筆までおよそ1ヶ月が経過しました。
まだ十分な期間ではありませんが、いくつかの初期的な成果が見えてきています。
例えば、React Hooksに対するテストカバレッジが向上していること、最も複雑度の高い画面にインタラクションテストを導入できていることです。
React Hooks に対するテストは 3%(1/33) -> 27%(11/41) に向上しています。
インタラクションテストはPlaywrightを採用しました。採用理由は別途公開する機会を作れたらと思います。
インタラクションテストでは業務的に意味のあるまとまりでテストしています。
例えば「商材の種類と状態(キズがないかなど)から価格を計算する」という機能単位でテストしています。
// APIや静的ファイルなどをモック test.beforeEach(async ({ page }) => { await page.route("**/api/path", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mockedResponse), }); }) await page.route("**/api/path/other", async (route) => { // 省略 }) await page.goto("page-url"); }) test.describe("画面名", () => { test("商材の種類がA・状態がBの時、価格が自動補完されること", async ({ page }) => { // 画面表示まで待機 await page.getByRole('heading', { name: '画面名' }).waitFor(); // フォーム入力 await page.getByTestId("商材種類").fill("着物"); await page.getByTestId("状態").fill("美品"); // 自動補完される値が正しく、上書きできてないことを検証 const authInputtedElement = page.getByTestId("価格") await expect(authInputtedElement).toHaveValue(''); await expect(authInputtedElement).toBeDisabled();; }); })
今後の展望
カバレッジの向上など初動の効果は見えているものの、最終的なゴールの評価はまだできていません。
そのため今後は、手動テスト工数の削減率やリリースサイクルへの効果検証など定量的な効果測定をしていきたいと考えています。
また、インタラクションテストも現在テスト対象としている最も複雑な画面に加え、次点で複雑な3画面にも導入予定です。
まとめ
今回の取り組みから得た最大の学びは、一般的なテスト戦略モデルを鵜呑みにするのではなく、自分たちのプロダクトの特性に合ったテスト戦略を考えることの重要性でした。
このプロセスで我々が実践した3つのステップをまとめると次のとおりです。
- プロダクトの特性を深く理解する
- テストすべき内容とその方法を明確にする
- リソースを考慮した現実的なバランスを設計する
今後は定量的な評価を継続しながら、テスト戦略自体も必要に応じて改善していく予定です。
この記事が皆さんのフロントエンドテスト戦略の検討材料になれば幸いです。
バイセルではエンジニアを随時募集しております。興味のある方はぜひ以下の採用サイトをご覧ください。