🔖

フロントエンドテスト戦略

2024/11/13に公開

Fivotでのフロントエンドテスト戦略について記載します。

前提

求められるQCD

テストをどこまで書くかは、プロダクトの性質(QCDのバランス)次第で大きく変わると考えてます。
弊社のQCDの捉え方としては、CとDは平均以上のものが求められ、Qは平均程度になります。
スタートアップということもあり、スピード重視で低コストでの開発が求められます。
品質に関しては、バグが許さないような厳しさはないものの、短期にクローズするプロダクトでないことから技術的負債を溜めないといった内部品質は求められます。

参画時点のプロダクト状況

テストはフォームのSubmit確認に対してのみ書かれていました。
また、コードの品質は低く、リファクタリングが求められる状況で、かつ仕様が複雑でデグレが頻発していました。

テスト戦略概要

以下のことを基本戦略としています。

  • 原則、テスティングトロフィーの思想に則っとる
  • コストは極力かけない
    • 重要箇所またはバグ可能性が高い箇所に注力してテストを書く
      • 追加開発、リファクタリングやライブラリのバージョンアップ等でのデグレを防ぐ
  • テストが仕様書となることを意識する

https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications

実施しているテスト

現在実施しているテストは以下になります。

静的テスト

コードを実行前にチェックする仕組みです。
TypeScriptをstrictモードで、Biomeをrecommendedをベースにいくつか加えてます。

単体テスト

関数レベルでのテストです。
ここはあまり重視していなく以下のような関数の場合にのみ、vitestを用いて書くようにしています。

汎用的な関数に対してのテスト

例として、日付関連のテストで以下のようなテストを書いています。

date.util.test.ts
describe("formatDate function", () => {
  it("年月日を引数に取り、「YYYY年MM月DD日」のフォーマットで返却されること", () => {
    expect(formatDate(2024, 2, 10)).toBe("2024年2月10日");
  });
});

デグレ防止の効果はあまり感じないですが、コストが低い(今ならAIですぐに生成してくれる)のと仕様書代わりになるため、作成することにしています。

パターン網羅のテスト

例として、以下のように分岐やパターンがある関数に対してテストを書いています。

validation.util.test.ts
describe("validateAccountNumber", () => {
  it.each([
    ["123456a", "口座番号は数字のみで入力してください"],
    ["123456", "口座番号が7桁未満の場合、番号の先頭に0を追加してください"],
    ["1234567", null],
  ])('設定値 %s, 結果 "%s"', (input, expected) => {
    expect(validateAccountNumber(input)).toBe(expected);
  });
});

後述するコンポーネント統合テストでは、validateAccountNumberを適用しているかを確認するテストを書いて、validateAccountNumberの中身の分岐は単体テストで担保するという切り分けにしています。
統合テストの方で1パターン確認できれば、網羅的な確認は単体テストで問題なく(大多数は)、コストがより低い単体テストを活用するという方針にしています。

コンポーネント(機能)統合テスト

モックを活用した機能テストです。
当プロダクトで最重視しています。

テスト対象

大きく2つのパターンのテストをstroybook/testで書いています。

フォームのテスト

フォームを入力してのSubmitのテストやバリデーションのテストなどを実施しています。

Form.stories.tsx
  play: async ({ args, canvasElement, step }) => {
    const canvas = within(canvasElement);
    await step("名前を入力する", async () => {
      await userEvent.type(canvas.getByLabelText('名前'), "鈴木");
    });
    await step("Submitボタンを実行", async () => {
      await userEvent.click(canvas.getByRole("button", { name: "更新" }));
    expect(args.onSubmit).toBeCalledWith({
      accountingSoftware: "鈴木",
    });
  },

Container/Presentationalパターンを用いて、submitは引数で受け取る形にしています。
フォームは重要度が高いと考え、テスト対象としています。

画面表示の切り替え

ステータスによって、画面表示を切り替えるなどの分岐確認を行なっています。

Examination.stories.tsx
export const SentBack: Story = {
  name: "審査が差戻しされた場合は、差戻画面を表示する",
  parameters: {
    apolloClient: {
      mocks: [
        examinationSentBackMock, // status=sentBackのデータを返却する
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    expect(
      await canvas.findByRole("heading", {name: "再度申込をお願いします。"}),
    ).toBeInTheDocument();
  },
};

こういった切り替えで不具合があると、ユーザーのアクションが止まってしまうため重要箇所と捉えています。
また、デグレの危険性も高いと考え、テスト対象としています。

テストは書きすぎない

このカテゴリーでは表示項目の確認など、他にも書けるテストはありますが、テストは書きすぎないようにしています。
開発時だけでなく、メンテナンス時やCIの時間など一度作成してしまうと各所でコストが発生することになります。
前提に記載した通り、当プロダクトで求められる品質は最高レベルではないため、テストは書きすぎない、を意識しています。

storybook/testの選定理由

Storybookでのテストは登場時点では不安定かつモックができないなど実現できないことも多かったのですが、v8以降は大きく改善されています。
testing/libraryを活用したテストと比較して、デメリットは実行速度が遅いくらいと考えています。
一方、メリットとしては、開発コストが低くなることで具体的には以下のような恩恵を受けられます。

  • testing/library用に別途ファイルを作成する必要がない
  • 画面を見ながらテスト作成できるので、デバッグなどが速い
  • 後述するビジュアルリグレッションのテストにもなるので、開発効率が良い
  • Storybookを仕様書代わりにできるため、仕様把握や連携が効率的になる
    • 見た目があるためより把握しやすい、デプロイ先のリンクを連携することでエンジニア以外にも連携しやすい

Storybookはテストの充実に対して注力しており、実行速度に関しても、Storybook Vitest pluginなどで解消しそうです。
まだまだ、storybook/testは流行ってない印象がありますが、今後シェアは伸びていくものと思います。

ビジュアルリグレッションテスト

Chromaticを用いて、デザイン差分を検知しています。
デザインデグレは結構な頻度で発生しており、検知が難しいという課題がありました。
コストはあまりかけられないため、Starterプランで顧客向けの画面でのみ導入しています。

E2E

こちらは未実施です。
最も信頼性の高い手法ではあるものの、コストも高いため、現状は未対応となっています。

テストを書き始めての所感

テストを書くことで品質向上やリファクタリングなどの心理的安全性は高まったと思います。
また、自動テストがあることで手動テストを減らせたり、テストが仕様書代わりになることでレビューがやりやすくなるなど、開発生産性にも寄与しています。
一方、一定のコストはかかるので、何に対してどこまでテストをやるのか、どうすれば効率的に行えるのかを考えていくのは重要と考えています。
今後も、プロダクトに求められているQCDが変わっていないか、技術的革新が起こってないかなどはウォッチしていき、より良いプロダクトにしていきたいと思います。

Fivot Tech Blog

Discussion