📖

軽量なStorybook駆動開発を支えるコンポーネント設計

2024/06/15に公開

こんにちは。この記事ではStorybook駆動開発をゼロから導入するために実践した内容をコンポーネント設計の側面から紹介します。合わせて、紹介した設計を元にどのようなテストを実装しているかについても紹介します。

簡単に実装できることが持続可能なテストへの鍵

Webフロントエンドの持続可能なテスト文化を醸成するためには、テストを開発フローの中で簡単に実装できることが何よりも重要です。テストの実装に機能開発以上のコストが掛かってしまう場合、「時間が無いのでテストコードを後から実装する(結果実装されない)」という典型的なアンチパターンに繋がってしまいます。

持続可能なテストを実現するためのアプローチ

フロントエンドにおいて、特にIntegration Testの実装容易性は、コンポーネント設計やツールの使い方によって大きく左右されます。

筆者が関わるプロジェクトでは軽量なStorybook駆動開発を採用したことで、機能開発と同時にテストを実装する文化が生まれつつあります。

Storybook駆動開発

Storybook駆動開発とは、UIコンポーネントをStorybook上で独立して開発・テストするための手法です。UIの状態やインタラクションの結果を「Story」と呼ばれる独立した単位で表現することで、複雑な仕様を個別に確認しながら開発を進めることができます。

UserSettingsForm.stories.tsx
export const InvalidBirthdayValidation: Story = {
  name: '生年月日に不正な日付を入力したとき',
  play: async (args) => {
    await playFillName(args);
    await playFillEmail(args);
    await playFillInvalidBirthday(args);
    await playSubmit(args);
  },
};

Storybook駆動開発のもう一つの大きな利点は、開発中の動作確認に使用したStoryを使用してIntegration Testを構築できることです。例えば、適切なフォームバリデーションが表示されるテストは次のように Play function を実行して得られる結果に対してアサーションするだけで実現できます。

UserSettingsForm.test.tsx
import { render, waitFor, within } from '@testing-library/react';
import * as stories from './UserSettingsForm.stories';
import { composeStories } from '@storybook/react';

const composedStories = composeStories(stories);

describe('UserSettingsForm', () => {
  describe('誕生日に不正な日付を入力したとき', () => {
    const { InvalidBirthdayValidation } = composedStories;
    test('バリデーションエラーが表示されること', async () => {
      // 1. テスト対象をレンダー
      const { container, getByRole } = render(<InvalidBirthdayValidation />);
      // 2. Play functionを実行して状態を再現
      await waitFor(async () => {
        await InvalidBirthdayValidation.play?.({ canvasElement: container });
      });
      // 3. 期待結果をアサーション
      const birthday = getByRole('group', { name: '生年月日' });
      await waitFor(() => {
        expect(birthday).toHaveAccessibleDescription('正しい日付を入力してください。');
      });
    });
  });
});

軽量なStoryを構築する

筆者の経験上、Storyをシンプルかつ簡単に実装できるようにすることが、Storybook駆動開発をゼロから導入する上で最も重要だと考えています。具体的には、Mock Service WorkerなどのAPIモックツールは必要になるまで極力使用せずに、UIレイヤーの責務だけにフォーカスしたStoryを構築することで実装容易性を高めます。

軽量なStoryを構築するための複合コンポーネントの設計

複合コンポーネントとは、ビジネスロジックとUIがセットになった機能的なコンポーネントレイヤーを指しています。「ユーザ情報変更ダイアログ」のような特定のユースケースに特化したコンポーネントが典型例です。featuresusecases または domainsと呼ばれるディレクトリで定義されることが多いでしょう。

複合コンポーネントは、グロース中の機能開発で一番多く実装されるコンポーネントレイヤーです。したがって、Storyやテストを含む複合コンポーネントに関連する実装容易性を向上することは、開発全体の品質や生産性の向上に直結します。

近年はSuspenseReact Server Componentsが登場したことにより、複合コンポーネント内で必要なネットワーク処理を実行する render-as-you-fetch が一般的になってきました。

ネットワーク処理を実行するコンポーネントでUIレイヤーの責務だけにフォーカスした軽量なStoryを構築するためには、コンポーネント設計を工夫する必要があります。

技術スタック

以降の説明では、SPAとして構成された業務アプリケーションを想定した技術スタックを中心に扱います。類似の機能を持つ別のライブラリやフレームワークでも代替可能です。

  • react
  • @tanstack-query/react
  • react-hook-from

Container/Presentationパターンによる関心の分離

Container/Presentation(C/P)パターンは、コンポーネントの責務を分離する設計手法です。具体的には、1つの複合コンポーネントを次の2つに分割します。

  • Containerコンポーネント: 主にネットワーク処理の実行を担当する
  • Presentationコンポーネント: UIに関する状態管理と表示を担当する

例えば、ユーザ情報変更フォームをC/Pパターンで分割した場合の全体像は次のような構成になります。

src/components/usecases/user/UserSettingsForm
├── UserSettingsForm.container.tsx    # Containerコンポーネント
├── UserSettingsForm.schema.ts        # フォームのスキーマ
├── UserSettingsForm.selector.test.ts # ビジネスロジックのテスト
├── UserSettingsForm.selector.ts      # ビジネスロジック
├── UserSettingsForm.stories.tsx      # PresentationコンポーネントのStorybook
├── UserSettingsForm.test.tsx         # Presentationコンポーネントのテスト
├── UserSettingsForm.tsx              # Presentationコンポーネント
└── index.ts

データ取得処理の分離

データ取得処理の分離はC/Pパターンの定石です。次の例では Containerコンポーネントで useSuspenseQuery を実行して得られた結果を Presentationコンポーネントに渡すことでデータ取得処理の分離を実現しています。

UserSettingsForm.container.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { userQueries } from '@/queries/user';
import { UserSettingsForm as Presenter } from './UserSettingsForm';
import { selectUserSettingsForm } from './UserSettingsForm.selector';

export type UserSettingsFormProps = {
  userId: string;
};

export function UserSettingsForm({ userId }: UserSettingsFormProps) {
  const { data: initialValues } = useSuspenseQuery({
    ...userQueries.details({ userId }),
    select: selectUserSettingsForm,
  });

  return (
    <Presenter initialValues={initialValues} />
  );
}

データ取得結果の整形処理はContainerコンポーネントが担当します。ここでは、useSuspenseQueryselect にselector関数の selectUserSettingsForm を渡すことで実現しています。

selector関数は純粋関数としてContainerコンポーネントと切り離して定義することで、単体テストが可能になります。

UserSettingsForm.selector.ts
export function selectUserSettingsForm(user: User): UserSettingsForm {
  const [year, month, day] = user.birthday.split('-');
  const birthday = year && month && day ? { year: year, month: month, day: day } : userSettingsFormDefault.birthday;

  return {
    familyName: user.familyName,
    givenName: user.givenName,
    email: user.email,
    birthday,
  };
}

データ更新処理の分離

データ更新処理についても分離することでPresentationコンポーネントが持つインタラクションの範囲を広げることができます。

具体的には、PresentationコンポーネントでResult型を返す非同期関数をPropsとして受け取ることでデータ更新処理の分離を実現します。

次の例では Propsの updateUserSettings がResult型を返す非同期関数にあたります。onSubmit内で updateUserSettings を実行し、その結果を元にUIの状態を制御しています。

UserSettingsForm.tsx
type MutateResult = { success: true } | { success: false; reason?: string | undefined };

export type UserSettingsFormProps = {
  initialValues?: UserSettingsForm;
  updateUserSettings: (values: UserSettingsForm) => Promise<MutateResult>;
};

export function UserSettingsForm({ initialValues, updateUserSettings }: UserSettingsFormProps) {
  const form = useForm<UserSettingsForm>({
    values: initialValues ?? userSettingsFormDefault,
    resolver: valibotResolver(userSettingsForm),
  });
  const [state, setState] = useState(initialState);

  return (
      <form
        onSubmit={form.handleSubmit(async (values) => {
          setState(initialState);
          const result = await updateUserSettings(values);
          if (result.success) {
            setState((prev) => ({
              ...prev,
              status: 'success',
            }));
          } else {
            setState((prev) => ({
              ...prev,
              status: 'error',
              errorMessage: result.reason ?? '原因不明のエラーです。',
            }));
          }
        })}
      >
        {/** 省略 **/}
      </form>
  );
}

Containerコンポーネントでは updateUserSettings に実際のネットワーク処理を定義します。

UserSettingsForm.container.tsx
import { selectUpdateUserRequest } from './UserSettingsForm.selector';

export function UserSettingsForm({ userId }: UserSettingsFormProps) {
  const { mutateAsync: updateUser } = useUpdateUserMutation({ userId });

  return (
    <Presenter
      updateUserSettings={useCallback(
        async (values) => {
          try {
            const request = selectUpdateUserRequest(values);
            await updateUser(request);
            return {
              success: true,
            };
          } catch (e) {
            const reason = e instanceof Error ? e.message : undefined;
            return { success: false, reason };
          }
        },
        [updateUser],
      )}
    />
  );
}

フォームをsubmitした結果をAPIのリクエストパラメータに整形する処理はContainerコンポーネントに記述します。上記の例では selectUpdateUserRequest がリクエストパラメータのselector関数です。データ取得時と同様に、純粋関数で切り出しておくことでテスト容易性が向上します。

UserSettingsForm.selector.ts
export function selectUpdateUserRequest({
  birthday: { year, month, day },
  email,
  familyName,
  givenName,
}: UserSettingsForm): UpdateUserRequest {
  return {
    email,
    familyName,
    givenName,
    birthday: `${year}-${month}-${day}`,
  };
}

テストの実践例

今回紹介した設計手法を実践すると、Integration TestとUnit Testを軽やかに記述することが可能になります。MSWのようなモックツールに依存しないことで軽量かつ安定したテストになります。

主に次のパッケージを使用しています。

  • vitest
  • jsdom
  • @testing-library/react
  • @storybook/react
  • @storybook/test

Storyに対するIntegration Test

はじめにStoryを構築します。デフォルトのargsには通信に成功したときの updateUserSettings の動作をモックします。Containerにネットワーク処理を分離したことにより、ピュアなTypeScriptコード4行でモックを実装できます。

UserSettingsForm.stories.tsx
import { UserSettingsForm } from './UserSettingsForm';

const meta = {
  title: 'usecases/user/UserSettingsForm',
  component: UserSettingsForm,
  args: {
    updateUserSettings: async () => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      return { success: true };
    },
  },
} satisfies Meta<typeof UserSettingsForm>;

export const Success: Story = {
  name: '更新に成功したとき',
  play: async (args) => {
    await playFillAll(args);
    await playSubmit(args);
  },
};

失敗したときのケースは以下のように updateUserSettings を上書きすることで再現できます。

UserSettingsForm.stories.tsx
export const Failed: Story = {
  name: '更新に失敗したとき',
  args: {
    updateUserSettings: async () => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      return { success: false, reason: '処理がタイムアウトしました。' };
    },
  },
  play: async (args) => {
    await playFillAll(args);
    await playSubmit(args);
  },
};

テストは composeStories でStoryを再利用します。
StorybookでPlay functionの実行結果を見ながらアサーションを実装するだけなので非常に簡単です。

UserSettingsForm.test.tsx
import { render, waitFor, within } from '@testing-library/react';
import * as stories from './UserSettingsForm.stories';
import { composeStories } from '@storybook/react';

const composedStories = composeStories(stories);

describe('UserSettingsForm', () => {
  describe('更新に成功したとき', () => {
    const { Success } = composedStories;
    test('成功メッセージが表示されること', async () => {
      const { container, queryByRole } = render(<Success />);
      await waitFor(async () => {
        await Success.play?.({ canvasElement: container });
      });
      await waitFor(() => {
        expect(queryByRole('alert', { name: 'ユーザ設定を保存しました' })).toBeInTheDocument();
      });
    });
  });
  describe('更新に失敗したとき', () => {
    const { Failed } = composedStories;
    test('エラーメッセージが表示されること', async () => {
      const { container, getByRole, queryByRole } = render(<Failed />);
      await waitFor(async () => {
        await Failed.play?.({ canvasElement: container });
      });
      await waitFor(() => {
        expect(queryByRole('alert', { name: /ユーザ設定に失敗しました/ })).toBeInTheDocument();
        const alert = getByRole('alert', { name: /ユーザ設定に失敗しました/ });
        expect(alert).toHaveAccessibleDescription('処理がタイムアウトしました。');
      });
    });
  });
});

ビジネスロジックに対するUnit Test

Storyを使ったIntegrationテストではContainerコンポーネントのビジネスロジックをテストすることはできません。
Containerコンポーネントが持つビジネスロジックは、大半をselector関数に切り出しているため、Unit Testでシンプルに実現可能です。

UserSettingsForm.selector.test.ts
import { User } from '@/boudary/api/generated';
import { selectUpdateUserRequest, selectUserSettingsForm } from './UserSettingsForm.selector';
import { UserSettingsForm } from './UserSettingsForm.schema';

describe('UserSettingsForm.selector', () => {
  describe('selectUserSettingsForm', () => {
    test('ユーザ情報からフォームの初期値を生成する', () => {
      const user: User = {
        // 省略
      };
      const actual = selectUserSettingsForm(user);
      expect(actual).toEqual({
        // 省略
      });
    });
  });

  describe('selectUpdateUserRequest', () => {
    test('フォームの値からユーザ情報更新リクエストを生成する', () => {
      const form: UserSettingsForm = {
        // 省略
      };
      const actual = selectUpdateUserRequest(form);
      expect(actual).toEqual({
        // 省略
      });
    });
  });
});

まとめ

今回は、筆者の所属するチームで実践しているコンポーネント設計とテストの実装方針について紹介しました。

Container/Presentationでネットワーク処理を完全に分離することで、APIモックツールに依存しない軽量なStorybookとそれを活用したテストを構築している点がポイントです。

軽量なStorybookは実装コストが低いため、Storybook駆動開発をチームに定着させることを助長します。
さらに、動作が安定するため持続可能なテスト文化の醸成にも繋がることでしょう。

おわりに

筆者の所属するチームで今回紹介した方針がうまく機能している要因の一つとして、チーム構成が「フロントエンドエンジニア」のように職能別ではないことが挙げられます。各々がマルチスタックに開発する環境では、APIの実装後にテストのためだけにAPIモックコードを実装するのは開発工数が膨らむため、時間がないときには実装しないという選択を生み出してしまっていました。

職能別でAPIモックコードの実装を前提とした開発をしているチームでは、素直にその資材を活用する方が生産的かもしれません。

今回紹介したコンポーネント設計は、C/Pの構造がネストすると追加のテクニックが必要になってきます。長くなってしまったので詳細は別の記事として公開する予定です。

参考

この記事に掲載しているサンプルコードは次のリポジトリで公開しています。全体像を把握したい方は是非ご覧になってください。

https://github.com/YTakahashii/frontend-articles-code/tree/main/apps/lightweight-storybook/src/components/usecases/user/UserSettingsForm

Storybook駆動開発の詳細については、次の記事が非常に参考になります。

https://zenn.dev/takepepe/articles/storybook-driven-development

持続可能なWebフロントエンドのテスト戦略については、次の記事が非常に参考になります。

https://zenn.dev/overflow_offers/articles/20240209-testing-strategy

Container/Presentationをテストへ応用する詳細については、次の記事が非常に参考になります。

https://quramy.medium.com/react-server-component-のテストと-container-presentation-separation-7da455d66576

Discussion