📘

フロントエンドのテストコードを充実させるためにやったこと

2023/03/13に公開

まえがき

(フロントエンドに限らずですが)フロントエンドの開発において、テストコードを充実させることはいいことづくめのように思えます。

  • 機能が壊れていないことを確認しながらプロダクションコードを変更できる
    • バグの発生に気づきやすくなる
    • 動作確認を省ける場面が増え、開発速度が向上する
  • テストを書きやすいように工夫してプロダクションコードを書くようになり、結果的にコンポーネントの設計やアクセシビリティを向上させることができる

とはいえ、フロントエンドのテストコードを充実させるにはそれなりの工数が必要です。また、テストコードの書き方によってはメンテナンスコストもかかり、最悪の場合壊れっぱなしで放置されがちです。

プロダクトの新規開発にあたって0からフロントエンドの設計・開発環境構築をする機会があったので、フロントエンドのテストコードの書き方について調査して内容を整理しました。

前提

React/TypeScriptです。

やったこと

メンテナンス性を重視したライブラリ選定をした

まず、フロントエンドのテストコードのメンテナンス性を高く保つ方法を考えました。メンテナンス性の低いテストとは、以下のような特性を持つものだと考えました。

  • テストコードの内容がわかりづらい
    • テストの構造や、テスト対象への操作・検証方法に一貫性がないため、何をテストできているのか/いないのか、がわからない
    • プロダクションコード追加時、どこにどのようなテストを書けばいいのかわからない
  • リファクタリングへの耐性がない
    • プロダクションコードを書き換えたことで、アプリケーションの機能は変わっていないのに、テストが落ちてしまう
    • テストコードが、「アプリケーションの機能が壊れていない」ことを確かめられないばかりか、プロダクションコードの変更の足を引っ張る存在になってしまい、誰もメンテナンスしたくなくなる

テストコードの内容がわかりづらい

まず、「テストコードの内容がわかりづらい」問題を避けるために、単体テストの記述パターンとして知られているAAAパターンに則ってテストコードを記述できるようにしました。AAAはArrange(準備)/Act(実行)/Assert(確認)の頭文字を並べたものです。AAAパターンに則ってテストコードを記述することで、テスト対象への操作・検証方法に一貫性が生まれ、テストコードが読みやすくなります。

これは、StorybookとJestを組み合わせてテストを書くことで実現できます。テスト対象のコンポーネントの特定の条件を再現する処理をStorybookで記述し(Arrange)、Jestのテストケースの中でStorybookコンポーネントをrender(Act)→結果を検証(Assert)することでAAAパターンのテストコードを書くことができます。

リファクタリングへの耐性がない

つぎに、「リファクタリングへの耐性がない」問題を避けるために、テストの単位を実装の目線(コンポーネント、カスタムフックなど)ではなくユーザーの目線(画面上の何を探してクリックするのか、何が表示されることを期待するかなど)にしてテストコードを書くことを目指しました。テストの単位を実装の目線ではなくユーザーの目線にすることで、プロダクションコードを書き換えても、アプリケーションの機能が変わっていなければテストは落ちなくなります。

testing-libraryは、ユーザーの操作を反映したクエリ・ユーティリティを提供しています(Guiding Principles)。testilng-libraryを適切に使用すれば、テストコードは自然とユーザー目線のものになりそうです。さらに、testing-libraryで再現したユーザーの操作をStorybookのplay関数の中に記述することで、ユーザーの操作を他のテスト等で再利用可能な資材として管理できます。

また、リファクタリングへの耐性ついて考える上で、APIなどの外部データの取得をテストでどのように再現するか、は悩ましい問題です。まず考えられるやり方として、コンポーネントをデータを取得する部分とそれを表示する部分とに分割して、表示する部分に対してテストする(Propsを外部データとする)方法があります。個人的に、このやり方はメンテナンス性を損ねる場面が増えてしまうと考えています。「テストするにはこういうコンポーネント分割じゃないといけないから…」というテスト都合でコンポーネントの分割が歪になってしまいますし、リファクタリング時のコードの変更量が多く(プロダクションコードとStorybook、さらには対象コンポーネントの親や子のコンポーネント、それぞれのPropsを書き換える必要があったりする)うんざりしてきます。

そのため、APIなどの外部データの取得をテストで再現するのは、コンポーネントの分割よりも、モックライブラリを利用するほうがいいと考えています。

MSWによるモックが便利です。MSWは、リクエストをインターセプトして任意のレスポンスを返すことができます。MSW Storybook Addonなど、MSWをStorybookでうまく使うためのaddonなどもあります。

具体的な実装

これらのライブラリを具体的にどうやって組み合わせるのかについてですが、今回のプロジェクトでは、Storybookのpreview.jsでMSWの設定をし、それをjest.setup.tsでも利用するという形で設定しています。

.storybook/preview.js
.storybook/preview.js
import { ChakraProvider } from "@chakra-ui/react";
import { initialize, mswDecorator } from "msw-storybook-addon";
import { createClient, Provider } from "urql";
import fetch from "cross-fetch";

// Initialize MSW
initialize();

const client = createClient({
  url: process.env.MOCK_ENDPOINT,
  fetch,
});

export const decorators = [
  (Story) => (
    <Provider value={client}>
      <ChakraProvider>
        <Story />
      </ChakraProvider>
    </Provider>
  ),
  mswDecorator,
];
jest.setup.ts
jest.setup.ts
import { setGlobalConfig } from "@storybook/testing-react";

import * as globalStorybookConfig from "./.storybook/preview";

import "@testing-library/jest-dom";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
setGlobalConfig(globalStorybookConfig as any);

詳細な説明は割愛しますが、Reactコンポーネントの実装とStorybook・Jest・testing-library・MSWによるテストコードは以下のようになります。初めてのGraphQLにサンプルとして出てくる「スキー場の職員がコースとかリフトの状態を確認・管理するためのGraphQL API」の一部をGUIアプリケーションとして実装したものです。

Reactコンポーネントの実装
export const Details: FC<DetailsProps> = (props) => {
  const [query] = useGetLiftDetailsQuery({
    variables: {
      id: props.id,
    },
  });
  const [mutationState, mutate] = useSetLiftStatusMutation();
  const { isOpen, onOpen, onClose } = useDisclosure();
  const setStatus = useCallback<ComponentProps<typeof LiftForm>["setStatus"]>(
    (status) => {
      (async (): Promise<void> => {
        await mutate({
          id: props.id,
          status,
        });
        onClose();
      })();
    },
    [mutate, onClose, props.id]
  );

  if (query.fetching) {
    return <Loading description="リフトのデータを取得しています" />;
  }

  if (query.error || !query.data) {
    return <p>リフトのデータの取得に失敗しました</p>;
  }

  return (
    <Stack direction="column">
      <Heading size="md">名前</Heading>
      <Text>{query.data.Lift.name}</Text>
      <Heading size="md">ステータス</Heading>
      <Stack direction="row">
        {mutationState.fetching ? (
          <Spinner />
        ) : (
          <Text>{query.data.Lift.status ?? "UNKNOWN"}</Text>
        )}
        {query.data.Lift.status === undefined ? (
          <Button isDisabled>ステータスを変更</Button>
        ) : (
          <LiftForm
            defaultValues={{
              status: query.data.Lift.status,
            }}
            setStatus={setStatus}
            isOpen={isOpen}
            onOpen={onOpen}
            onClose={onClose}
          />
        )}
      </Stack>
      <Stack direction="column">
        <Heading size="md">行き先</Heading>
        {query.data.Lift.trailAccess.map((trail) => {
          return <Text key={trail.id}>{trail.name}</Text>;
        })}
      </Stack>
    </Stack>
  );
};
Storybook
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { LiftStatus } from "@/libs/graphql-codegen/types";

import {
  mockGetLiftDetailsQueryMsw,
  mockSetLiftStatusMutationMsw,
} from "@/features/lift/details/index.generated";

import Page from "./index.page";

import type { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import type { ComponentProps, FC } from "react";

type Meta = ComponentMeta<typeof Page>;
type Props = ComponentProps<typeof Page>;
type Story = ComponentStoryObj<typeof Page>;

const componentMeta: Meta = {
  component: Page,
};
export default componentMeta;

const Wrapper: FC<Partial<Props>> = (props) => {
  const defaultProps: Props = {
    id: "id-l-1",
  };

  return (
    <Page
      {...{
        ...defaultProps,
        ...props,
      }}
    />
  );
};

const Template: Story = {
  render: (props: Partial<Props>) => {
    return <Wrapper {...props} />;
  },
  parameters: {
    msw: {
      handlers: [
        mockGetLiftDetailsQueryMsw((req, res, ctx) => {
          return res(
            ctx.data({
              Lift: {
                id: "id-l-1",
                name: "リフト1",
                status: LiftStatus.Open,
                trailAccess: [
                  {
                    id: "id-t-1",
                    name: "コース1",
                  },
                  {
                    id: "id-t-2",
                    name: "コース2",
                  },
                ],
              },
            })
          );
        }),
        mockSetLiftStatusMutationMsw((req, res, ctx) => {
          return res(
            ctx.data({
              setLiftStatus: {
                id: "id-l-1",
              },
            })
          );
        }),
      ],
    },
  },
};

export const Default: Story = {
  ...Template,
};

export const ステータス変更ポップオーバーを開く: Story = {
  ...Template,
  play: async () => {
    const openPopoverButton = await screen.findByRole("button", {
      name: "ステータスを変更",
    });

    await userEvent.click(openPopoverButton);
  },
};

export const ステータスをClosedに変更して更新: Story = {
  ...Template,
  play: async () => {
    const openPopoverButton = await screen.findByRole("button", {
      name: "ステータスを変更",
    });
    await userEvent.click(openPopoverButton);

    const radioInputClosed = await screen.findByRole("radio", {
      name: "CLOSED",
    });
    await userEvent.click(radioInputClosed);

    const submitButton = screen.getByRole("button", {
      name: "更新",
    });
    await userEvent.click(submitButton);
  },
};

export const ステータスをClosedに変更して閉じる: Story = {
  ...Template,
  play: async () => {
    const openPopoverButton = await screen.findByRole("button", {
      name: "ステータスを変更",
    });
    await userEvent.click(openPopoverButton);

    const radioInputClosed = await screen.findByRole("radio", {
      name: "CLOSED",
    });
    await userEvent.click(radioInputClosed);

    const closeButton = screen.getByRole("button", {
      name: "ステータス変更ポップオーバーを閉じる",
    });
    await userEvent.click(closeButton);
  },
};
Jest
import { composeStories } from "@storybook/testing-react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import * as stories from "./index.stories";

describe("lift-id", () => {
  const Stories = composeStories(stories);

  describe("初期状態", () => {
    beforeEach(() => {
      render(<Stories.Default />);
    });

    test("リフト詳細を表示できる", async () => {
      const liftName = await screen.findByText("リフト1");
      const trailName1 = await screen.findByText("コース1");
      const trailName2 = await screen.findByText("コース2");

      expect(liftName).toBeInTheDocument();
      expect(trailName1).toBeInTheDocument();
      expect(trailName2).toBeInTheDocument();
    });
  });

  describe("ステータス変更ポップオーバーを開く", () => {
    describe("ステータスをClosedにする", () => {
      describe("更新ボタンをクリック", () => {
        // MEMO: スパイの注入が必要なため、lift-form.test.tsxで実施
        test.todo("ステータスの変更が実行される");
      });

      describe("閉じるボタンをクリック", () => {
        beforeEach(async () => {
          const { container } = render(
            <Stories.ステータスをClosedに変更して閉じる />
          );

          await Stories.ステータスをClosedに変更して閉じる.play({
            canvasElement: container,
          });
        });

        test("フォームの値がリセットされてOPENに戻っている", async () => {
          // 確認のため再度ポップオーバーを開く
          const openPopoverButton = await screen.findByRole("button", {
            name: "ステータスを変更",
          });
          await userEvent.click(openPopoverButton);

          const radioButtonOpen = screen.getByRole("radio", { name: "OPEN" });

          expect(radioButtonOpen).toBeChecked();
        });
      });
    });
  });
});

自動生成できる部分は自動生成した

Jest、Storybook、testing-library、MSWなどの便利ライブラリがたくさんあるとはいえ、コンポーネントを実装するたびに大量の定型文を書かなければならないのはしんどいです。テストを書くのがめんどくさくて誰もテストを書かなくなった、といった事態を避けるために、自動生成できる部分は自動生成したいものです。

まず、コンポーネントとStorybookとjestの3つのファイルを同時に生成できるスクリプトを用意しました。ファイルの自動生成にはscaffdogを使いました。

.scaffdog/component.md
.scaffdog/component.md
---
name: 'component'
root: 'src'
output: '{features/*,ui}'
ignore: ['{src,src/features}']
questions:
  name: 'Please enter component name'
---

# `{{ inputs.name | kebab }}/{{ inputs.name | kebab }}.tsx`

```tsx
import { FC } from "react";

export type {{ inputs.name | pascal }}Props = {
};
export const {{ inputs.name | pascal }}: FC<{{ inputs.name | pascal }}Props> = (props) => {
  return (
  )
};
```

# `{{ inputs.name | kebab }}/{{ inputs.name | kebab }}.stories.tsx`

```tsx
import { action } from "@storybook/addon-actions";
import { within } from "@storybook/testing-library";
import userEvent from "@testing-library/user-event";

import { sleep } from "@/utils/sleep";

import { {{ inputs.name | pascal }} } from "./{{ inputs.name | kebab }}";

import type { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import type { ComponentProps, FC } from "react";

type Meta = ComponentMeta<typeof {{ inputs.name | pascal }}>;
type Props = ComponentProps<typeof {{ inputs.name | pascal }}>;
type Story = ComponentStoryObj<typeof {{ inputs.name | pascal }}>;

const componentMeta: Meta = {
  component: {{ inputs.name | pascal }},
};
export default componentMeta;

const Wrapper: FC<Partial<Props>> = (props) => {
  const defaultProps: Props = {
  }

  return <{{ inputs.name | pascal }} {...{
    ...defaultProps,
    ...props
  }} />
}

const Template: Story = {
  render: (props: Partial<Props>) => {
    return <Wrapper {...props} />;
  },
  parameters: {
    msw: {
      handlers: [
      ]
    }
  }
};

export const Default: Story = {
  ...Template,
};
```

# `{{ inputs.name | kebab }}/{{ inputs.name | kebab }}.test.tsx`

```tsx
import { composeStories } from "@storybook/testing-react";
import { render, screen } from "@testing-library/react";

import * as stories from "./{{ inputs.name | kebab }}.stories";

describe("{{ inputs.name | pascal }}", () => {
  const Stories = composeStories(stories);

  describe("初期状態", () => {
    beforeEach(() => {
      render(<Stories.Default />);
    });

    test.todo("テスト");
  });
});
```

↑のようなmarkdownを用意して、package.jsonのscriptに"gen:component": "yarn run scaffdog generate component"など用意しておけば、yarn gen:componentコマンドを実行して対話形式でディレクトリの場所や名前などを指定するだけでコンポーネントとStorybookとjestの3つのファイルを同時に生成できるようになります。

また、MSWのhandlerも自動生成するようにしました。今回のプロジェクトではAPIへリクエストを送信するhooksなどをGraphQL Code Generatorで自動生成するようにしています(テストと関係ないので詳細は省きます)。@graphql-codegen/typescript-mswというGraphQL Code Generatorのpluginを使用することで、hooksなどといっしょにMSWのhandlerも自動生成してくれるようになります。自動生成されたhandlerがあると、StorybookでMSWをつかってリクエストをモックするとき、リクエストのクエリを書いたりレスポンスの型を指定する手間が省けます具体的な実装の節に書いたStorybookの中にあるmockGetLiftDetailsQueryMswmockSetLiftStatusMutationMswは、自動生成されたMSWのhandlerです。

すぐに動作確認できるプレビュー環境を用意した

強力なライブラリを使ってテストコードを書いたり自動生成で手間を省いたりしても、そもそもテストコードで再現・検証するのが難しい機能も多くあります。そういった機能を、無理やりテストコードで再現しようとして必要以上に時間をかけてしまうのは避けたいです。ひとつのテストを書くのに必要以上の時間を書けてしまうと、すぐに書ける他のテストに着手できず結果的にテストコードを充実させることはできなくなります。また、開発チームが「テストにすごく時間かかってるみたいだから、いったんテスト書くのはやめてプロダクションコードの開発に集中しよう」みたいな雰囲気になると最悪です。多くの場合、もうテストが書かれることはなくなります。

テストコードで再現するのが難しい機能は、いさぎよく「これテスト書けなかったので動作確認お願いします」とコードコメントやプルリクエストに書けばよいと思っています。そうすることで、テストに必要以上の時間を書けることが避けられますし、単にテストの書き方を知らないだけだった場合、知っている人から知っていない人への知見の共有が行われます。

とはいえ、「各自ローカルでこのブランチをcheckoutして再現して確認してください」が多くなってくると、「コードレビューしなきゃだけどローカルの変更stashしたりするのめんどくさいな、いまやってる実装終わったら見るか」みたいになってコードレビューがボトルネックになりがちです。

そこで、VercelとGitHubを連携させて、プルリクエストごとにプレビュー環境がデプロイされ、ワンクリックで動作確認ができるようにしました。

やってみてどうだったか

まだ開発が始まったばかりの段階なので、実際にこのやり方でワークするかどうかはわかりません。やってみて気づきがあれば、また記事にします。

GitHubで編集を提案
PrAha

Discussion