カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

フロントエンドのテスト戦略を考える - AIで変わったこと、変わらないこと

こんにちは。カミナシで「カミナシ 設備保全」サービスの開発を行っている澤木です。 今回はフロントエンドのテスト戦略についての話を書いていこうと思います。

フロントエンドのテストはどこに何をどれだけ書くかが悩ましい領域です。 ユニットテストは書きやすく実行も速いですが全部組み合わせたときに動く保証はないし、E2Eテストは本物に近い保証が得られるけれど書くのもメンテナンスするのも工数がかかります。

特にE2EテストはAIでコーディングができるようになる前は工数が高く、書きたくても書けなかった部分がありました。 それがAIコーディングエージェントの普及で書くコストが下がり、E2Eもだいぶ書きやすくなって、どのテストをどれくらい書くのかという配分も改めて考え直したくなりました。

今回はテストトロフィーをベースに私たちがどういう考え方でテストの設計を行なっているか、またAIによって何が変わったのか、何が変わらなかったのかという話を書いていきたいと思います。

テストトロフィーをおさらいする

まずトロフィーの形を確認しておきます。

テストピラミッド(ユニットが最大)と違って、インテグレーションテストが一番大きいのがトロフィーの特徴です。

「テストがソフトウェアの実際の使われ方に似ているほど、その動作に自信を持てる」というのが根拠で、一番ユーザーに近いE2Eが一番自信を持てるけれど、書くのが大変で遅くて壊れやすい。なので、得られる自信とコストの釣り合いが一番良いインテグレーションテストに重心を置いて費用対効果の最適点を取りに行くという発想だと捉えています。

私たちのテスト配分の形は、ここから少し組み替えてこのような形になっています。

トロフィーで一番大きかったインテグレーションテストを、専用の層としては書いていません。その役割を上のE2Eと下のユニットに引き取らせて、上下から挟む形にしています。この組み替えのうち、E2Eの方へインテグレーションを寄せるというのはAIでテストの記述コストが下がったことで変わった部分で、ユニットテストへの切り出しはAIに関係なく変わっていない部分です。それぞれ順に見ていきましょう。

変わったこと、インテグレーションをE2Eに寄せる

変わったのは各層を書くコストです。

まずトロフィーで真ん中に置かれているインテグレーションテストは、Doddsの定義だと「複数のユニットを組み合わせて動かし、モックはネットワークリクエスト程度に最小限にする」テストになります。 フォーム画面に当てはめると、ページをレンダリングして、APIはモックし、ユーザー操作をシミュレートする、こんな形になります。

test("フォームに入力して送信するとAPIが呼ばれる", async () => {
  server.use(rest.post("/assets", () => HttpResponse.json({ ok: true })));
  render(<AssetFormPage />);

  await userEvent.selectOptions(screen.getByLabelText("設備種別"), "機械");
  await userEvent.type(screen.getByLabelText("設備名"), "第1ライン");
  await userEvent.click(screen.getByRole("button", { name: "保存" }));

  await waitFor(() => expect(/* APIが期待した引数で呼ばれた */).toBe(true));
});

書いてみると気づくのですが、これはやっていることがE2Eとほぼ同じです。違いはAPIがモックか本物か、ブラウザかjsdomか、くらいの差しかありません。書く手間もそんなに変わらず、むしろ「どこをモックするか」「モック値と本物のズレをどう管理するか」を考える分こちらのほうが悩ましかったりします。

だったら同じ手間で本物を通すほうが結合に対してより自信を持てるので、ページ単位のインテグレーションテストはAPIをモックする形では書かず、E2Eとして書くことにしています。とはいえ、何をインテグレーションテストと呼ぶかはそもそも曖昧で(Dodds自身もunitとintegrationの区別にはこだわらないと書いています)、正確には「ページ全体をレンダリングしてAPIはモックする」というあの特定の形のテストを書かない、と言ったほうが近いかなと思います。

この判断ができるようになったことがAIで変わった部分です。E2Eの弱点とされてきたもののうち、書くコストも、壊れたときに直すコストも、AIでだいぶ下がりました。トロフィーが真ん中を厚くしたのは「E2Eはコストが高すぎるから、ユーザーに近いテストをもう少し手軽に書きたい」という需要があったからで、E2Eを書くコストが下がるならわざわざモックを挟んだ中間のテストを用意する理由は薄れていく、という見方もできるかなと思っています。

しかしE2Eが簡単に書けるからといって何でもかんでもE2Eに詰め込み始めると、結局トロフィーで避けたかった「E2Eが重くて回らない」状態に逆戻りしてしまいます。 なので基本は、全画面について主要な操作フローを通す、というルールにしています。

test("設備を登録できる", async ({ page }) => {
  await page.goto("/assets/new");
  await page.getByLabel("設備名").fill("第1ライン コンプレッサー");
  await page.getByLabel("設備種別").selectOption("機械");
  await page.getByRole("button", { name: "保存" }).click();

  await expect(page.getByText("第1ライン コンプレッサー")).toBeVisible();
});

一覧から詳細を開いて編集する、登録する、削除する、といった主要な経路を画面ごとに1〜数本。加えて、バリデーションエラーが表示されるという代表ケースや、未ログイン時のリダイレクトのような全画面共通のガードまではE2Eで見ています。

逆に細かい分岐の網羅はE2Eテストでは行わないようにしています。「設備名が101文字でエラーになる」「種別ごとに必須項目が違う」のような個々のルールの組み合わせはE2Eでは追わず、これは後述するユニットテストの担当にしています。E2Eはあくまで全体を組み合わせた際の動作チェックとして機能するイメージです。

ちなみにバックエンドはE2E中もモックせず、そのまま動かしています。モックを用意して本物とのズレを管理し続けるのも大変ですし、できるだけ実プロダクトに近い操作でテストしたいので、フロントとバックエンドの結合まで含めて1本でそのまま通しています。 バックエンドを本物で動かすぶん、テストごとの状態はきれいに保てるように工夫しており、E2Eは専用のDocker環境で各テストの前にDBをスナップショットから復元し、毎回同じシードデータの状態から始まるようにしています。本物を通しつつ、データの状態だけは毎回そろえることでバックエンドをモックしなくてもテストが安定して回せています。

変わらないこと、ロジックを切り出してユニットで網羅する

ここまではAIでE2Eのコストが下がったことで変わった部分でした。逆に、AIが来ても変わっていないのがここから書くユニットテストの設計です。

E2Eは実行速度が遅いので、あらゆる条件分岐をE2Eで網羅するのは現実的ではありません。さきほど分岐の網羅をユニットに回したのもこのためで、「設備名が101文字のとき」「APIが409を返したとき」のような細かい分岐はユニットテストの担当にしています。

ただ、ユニットテストを手軽に書くには、コードがそれに適した構造になっている必要があります。 コンポーネントに全部のロジックが詰まったままだと、様々な分岐条件や状態を一つのテストで追うことになり、テスト自体の保守性が落ちてしまいます。 そこで、コンポーネントからhooksへ、hooksから純粋関数へとロジックを下へ下へ切り出して、それぞれを適切な粒度でテストできるようにしています。 (こうしてロジックを切り出してユニットで網羅する設計は、AIが来ても変わらない部分だと考えています。)

まず、状態と副作用(API呼び出し・遷移・エラー保持など)が絡むロジックはコンポーネントからhooksに切り出します。 こうすることでコンポーネント全体のレンダリングを行うことなく、hooks単体で振る舞いをテストできるようになります。

test("送信に成功したら一覧へ遷移する", async () => {
  // createAsset は成功するようモック
  const { result } = renderHook(() => useAssetForm());
  await act(() => result.current.submit());
  expect(navigate).toHaveBeenCalledWith("/assets");
});

ただ、「101文字でエラー」「種別ごとに必須項目が違う」のような細かい分岐までrenderHook経由で全部試そうとすると、テストが急に煩雑になります。 なのでバリデーション、データ変換、出し分けロジックなど、状態を持たない純粋なロジックは純粋関数に切り出してテストを行うようにします。 こうすることで、table driven testのような形で、様々な条件の組み合わせを網羅的にテストできるようになります。

test.each([
  [{ name: "" },                       { name: "設備名は必須です" }],
  [{ name: "あ".repeat(101) },          { name: "設備名は100文字以内です" }],
  [{ type: "機械", modelNumber: "" },   { modelNumber: "型番は必須です" }],
])("validateAssetForm(%j)", (input, expected) => {
  expect(validateAssetForm({ ...validValues, ...input })).toMatchObject(expected);
});

最後に、hooksや純粋関数を切り出した後のコンポーネントに残るのは基本的に「受け取った値を描画する」「操作をハンドラに渡す」だけなので、テストも値に対して正しいUIが出ているか、操作が正しいハンドラに渡っているかを確認するだけになります。

test("errors.name があるとエラーメッセージを表示する", () => {
  render(<AssetForm errors={{ name: "設備名は必須です" }} {...rest} />);
  expect(screen.getByRole("alert")).toHaveTextContent("設備名は必須です");
});

逆にコンポーネントのテストが複雑になってきたら、それは「ロジックがまだコンポーネントに残っているサイン」だと考えるようにして、切り出せないかを検討するようにしています。

原則をベースに、必要なところは厚く書く

ここまで、インテグレーションをE2Eに寄せるということと、コンポーネントからのロジックの切り出しを、AIによって変わったことと変わらないことに分けて書いてきました。ただ、このどちらもあくまで原則で、実際の運用ではテストの厚みを状況に応じて変えています。

たとえば、純粋関数やhooksへ綺麗に切り出せない複雑なコンポーネントを触るときは、ピンポイントで統合テストを書くこともあります。原則からするとインテグレーションは持たない方針なのですが、自信が足りないと感じるならコストとリターンを天秤にかけて書くという判断です。原則だから書かないではなく必要であると判断したら書く、という柔軟な運用をしています。

もう一つよくあるのが、既存のコンポーネントが今の構造のままだとユニットに落としきれない、というケースです。リファクタしないとhooksにも純粋関数にも切り出せないけれど、いきなりリファクタすると壊しそうで怖いということはよくあると思います。こういうときは、まず主要フローのE2Eを厚めに用意して結合動作の安全網を作ってから、内部を切り出していくという順序にしています。こういった場合はリファクタ期間中だけ主要フロー以外のE2Eテストも厚めに書いて、リファクタが終わって切り出しが進んできたら徐々に減らしていく、というやり方もありだと思います。

テスト戦略の目的は「ルールに従う」ことではなくて、「自信を持って変更できる」状態を作ることかなと思っています。この目的も、AIが来ても変わっていないところです。 なので原則は一定の指針として置きつつ、ここは自信が足りない、ここは安全に手を入れたい、と感じたところを厚くしていく。原則と厚みの両方を持って運用するというのが大事かなと思っています。

おわりに

AIのおかげで、E2Eでも巨大なコンポーネントのテストでも書こうと思えばたいていのものはあまり工数をかけずに書けるようになりました。 ただし戦略を持たずに無作為にテストを追加していくと、保守性の低いテストやすぐ壊れるテストが増えてしまい、結局テストが足かせになってしまうこともあると思います。 当たり前ですが、テストもアプリケーションコードと同様に設計が大事で、戦略を持って運用していくことが重要だと思います。

今回の内容は我々のチームの一例ですが、テスト戦略について考えるときの一つの参考になれば幸いです。