🤖

msw-trpcのハンドラー定義を型安全に育てたい

2024/12/09に公開

はじめに

弊社では一部プロダクトにNext.js(Pages Router)でBFFとの通信をtTRPCで実装し、Storybookを整備しているプロダクトがあります。

msw-trpcを利用することでStorybook上でのtRPC通信をMSWで定義したハンドラーに置き換え、Storyを記述できます。

https://github.com/maloguertin/msw-trpc

実装の基本的な流れは以下の記事が分かりやすいです。

https://zenn.dev/silverbirder/articles/0741c1a2f01380

Storyの数が増加すると多数のハンドラー定義が必要になります。これらのハンドラーを場当たり的に記述しているとAPIの型変更時に多くのStoryを修正する必要が生じます。

この記事では、ハンドラーの定義を少しでも快適に、型安全に育てていくために取ったアプローチについて解説します。

ハンドラーを直接Storyに記述する方法

最初に、何も考えずにStory内にハンドラーの定義を直接記述した場合にどのようになるかを説明します。基本的なStoryは次のようになるでしょう。

import { Meta, StoryObj } from "@storybook/react";
import { trpcMsw } from "@/utils/trpcMsw";

const meta: Meta<typeof SomeComponent> = {
  component: SomeComponent,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Basic: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [{ id: 1, email: "foo@example.com" }];
        }),
      ],
    },
  },
};

このquery内では返り値の型が読み込まれているため、特に困らずに記述していくことができます。

続いて、パターンが増えた時にはどうでしょうか。

// ...前述のimport文は省略

export const Basic: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [{ id: 1, email: "foo@example.com" }];
        }),
      ],
    },
  },
};

export const MultipleUser: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [
            { id: 1, email: "foo@example.com" },
            { id: 2, email: "bar@example.com" },
          ];
        }),
      ],
    },
  },
};

この程度のコード量であれば管理は容易です。しかし、Userの構造が複雑になると、スキーマの変更時にすべてのStoryを修正する必要があります。

データ生成の関数を共通化してみる

「よし、じゃあ共通化だ!」ということで、Userデータを生成する関数を共通化します。

// ...前述のimport文は省略

const createUser = (params) => {
  id: 1,
  email: 'foo@example.com',
  ...params
}
// ...前述のimport文は省略

export const Basic: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [createUser()];
        }),
      ],
    },
  },
}

export const MultipleUser: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [
            createUser(),
            createUser({id: 2, email: 'bar@example.com'}),
          ];
        }),
      ],
    },
  },
}

Userの構造に変更があった場合の修正はいくらかマシになりましたが、他のStoryファイルでも user.findAll のレスポンスを置き換えたい場合、コピペでの実装が無限に増えていってしまいます。

これでは辛いので、共通のモックデータをすべてのStoryから参照でき、かつqueryの型定義に準拠した値が得られる という設計を目指します。

Storyから切り出しつつ型安全に書いていくには

trpcでは inferRouterInputs<TRouter>, inferRouterOutputs<TRouter> を提供しており、AppRouterの型定義を参照できます。今回はqueryをモックで置き換えていきたいので、RouterOutput を参照します。

https://trpc.io/docs/client/vanilla/infer-types

user.findAll というqueryが定義されているとした場合、そのレスポンスの型は次のように参照できます。

type RouterOutput = inferRouterOutputs<AppRouter>;

type UserFindAllOutput = RouterOutput["user"]["findAll"];

よって、テスト用のハンドラーが返す値の生成では、routerとqueryを指定すると、RouterOutput[router][query] 型を返す実装をすれば良さそうです。

追加の要求として、バリエーションを定義したい

user.findAll というqueryでも、ユーザー数が異なる場合や、ユーザーに紐づくデータが異なる場合など、複数のStoryを提供したいケースが出てきます。このようにバリエーションを簡単に定義できる仕組みがあると便利です。

モック作成用の関数に切り出す

RouterOutputsの型に従ってハンドラーが返す値を記述できる、モック生成用の関数を定義してみます。

// ファイルパスは適宜読み替えてください
import { RouterOutputs } from "@/server/routers/_app";

type Router = {
  [K in keyof RouterOutputs]: {
    [P in keyof RouterOutputs[K]]: RouterOutputs[K][P];
  };
};

// procedureのバリエーションを記述する型
type MockVariations<T extends keyof Router, P extends keyof Router[T]> = Record<
  string,
  () => Router[T][P]
>;

// router, procedureごとにバリデーションを記述する型
type MockDefinition = {
  [T in keyof Router]?: {
    [P in keyof Router[T]]?: MockVariations<T, P>;
  };
};

// モックデータ生成用のユーティリティ関数を必要に応じて定義
const createUser = (params) => {
  return {
    id: 1,
    email: "foo@example.com",
    ...params,
  };
};
export const handlers: MockDefinition = {
  user: {
    findAll: {
      default: () => {
        return [createUser()];
      },
      multiple: () => {
        return [
          createUser({ id: 1, email: "bar@example.com" }),
          createUser({ id: 2, email: "foo@example.com" }),
        ];
      },
    },
  },
};
export const createMock = <T extends keyof Router, P extends keyof Router[T]>(
  router: T,
  procedure: P,
  variant = "default",
): Router[T][P] => {
  const mock = handlers[router]?.[procedure]?.[variant];

  if (mock == null) throw new Error("mock handler not found.");
  return mock();
};

queryごとにバリエーションを定義できるようになっています。handlers に記述している各バリエーションは RouterOutputs[router][procedure] を満たさない場合は型エラーになるので、不正な値の定義を防止できます。

Storyでの読み込みも、createMock(router, procedure, variation) で呼び出せばOKです。variation指定がなければdefaultが呼ばれるようにしています。 createMock(...) と入力時に補完が機能します。

// ...前述のimport文は省略

// 先ほど定義したcreteMockをimport。パスは適宜読み替えてください
import { createMock } from "@/mock/handlers";

export const Basic: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [createMock("user", "findAll")];
        }),
      ],
    },
  },
};

export const MultipleUser: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.user.findAll.query(() => {
          return [createMock("user", "findAll", "multiple")];
        }),
      ],
    },
  },
};

以上の実装により、queryが返す値の定義を共通化し、型安全性を保ちながら複数のバリエーションを提供できるようになりました。

まとめ

Storybookでtrpc-mswを利用しているケースで、ハンドラーの定義を外出して型安全に書いていけるアプローチを紹介しました。

ChromaticでVRTを運用しているため、差分が発生しないようにFakerなどで動的にデータを生成することは避けています。jsonでモックを定義する方法も可能ですが、オブジェクトの一部分のみを定義して再利用したいケースもあるため、今回紹介した方法を採用しています。重複したコードが各Storyに存在していたのが改善されました。

他にも良い方法あるよという方がいましたらぜひコメントください!よきStorybookライフを!

スタフェステックブログ

Discussion