🤝

tRPC と MSW の統合

2023/01/25に公開1

tRPC は Next.js プロジェクトの生産性を向上させるライブラリです。サーバー側定義の型推論が API Client にダイレクトに伝搬するだけでなく、Zod スキーマによる入力値制約が施せます。そのため、Client ⇄ API Routes 間の疎通がEnd-to-end typesafeになる、便利なライブラリです。

https://trpc.io/

tRPC と MSW の統合要点

筆者はテスト・Storybook をコミットする際に MSW をよく利用しています。次の様に任意の URL リクエストをインターセプトして、スタブを返却できます。

import { rest } from 'msw'

export const handlers = [
  rest.get('https://api.github.com/user/:login', (req, res, ctx) => {
    return res(ctx.json({ login: req.params.login }))
  })
  rest.post('/author/:authorId/:postId', responseResolver),
]

さて、tRPC は通常の REST API とは異なるため、MSW ハンドラー定義にも工夫が必要です。tRPC のドキュメント通りに定義すると、次の様なリクエストが発生します。何やら複雑なクエリが確認できますね。

tRPC クライアントのリクエスト内訳

tRPC API へのリクエストは http://localhost:3000/api/trpc/[trpc]の一箇所に集約されます。最初の設定が完了したら、src/pages/api/trpc/[trpc].tsのコードを編集することはありません。代わりに tRPC ルーターsrc/server/routers/**/*.tsの定義を追加することで、API を作り込んでいくというコーディングスタイルになります。

このルーター定義によって生成されるクエリを、MSW の rest API で紐解き、意図したスタブをレスポンスする、というのが全容になります。

目指すゴール

本稿で紹介するテストサンプルでは、次のように MSW 設定が可能です。setupMockServerという関数で MSW ハンドラーをセットアップしています。todosMock.getTodosは、MSW ハンドラーを返す関数です。今までのテストと遜色ない感じですね。

src/components/Todos.test.tsx
import { Todos } from "./Todos";
import { screen } from "@testing-library/react";
import * as todosMock from "@/server/routers/todos/mock";
import { setupMockServer, render } from "@/tests/jest";

setupMockServer(todosMock.getTodos());

test("スタブが3件表示される", async () => {
  render(<Todos />);
  expect(await screen.findAllByRole("listitem")).toHaveLength(3);
});

todosMock.getTodos()で作られる MSW ハンドラーは、このようなスタブを返します。

src/server/routers/todos/fixture.json
{
  "todos": [
    {
      "id": 0,
      "task": "Check remaining tasks."
    },
    {
      "id": 1,
      "task": "Pull request review."
    },
    {
      "id": 2,
      "task": "Document organization."
    }
  ]
}

Storybook も同様に、いままでと遜色ない感じです。テスト・Storybook で同じモックを使用しているので、運用も無理なさそうに思えますね。

src/components/Todos.stories.tsx
import type { ComponentMeta } from "@storybook/react";
import { Todos } from "./Todos";
import * as todosMock from "@/server/routers/todos/mock";

export default {
  component: Todos,
} as ComponentMeta<typeof Todos>;

export const Primary = {
  parameters: {
    msw: {
      handlers: [todosMock.getTodos()],
    },
  },
};

trpcMswHandlerFactory 関数

サンプルの todos ルーターは、簡易的に次のように定義しています。ここでは fixture.json を丸ごと返しているだけですが、実際は prisma クライアントを利用したり、外部 API サーバーとの中継を担う場所です。MSW ハンドラーは、このルーター定義内容に準拠したレスポンスを返す必要があります。

src/server/routers/todos/index.ts
import { router, publicProcedure } from "@/server/trpc";
import fixture from "./fixture.json";

export const todosRouter = router({
  getTodos: publicProcedure.query(() => fixture.todos),
});

ここで、trpcMswHandlerFactoryという MSW ハンドラーファクトリー関数を用意します。この関数は tRPC ルーター定義と齟齬がある場合、型エラーが発生するようになっています。つまり、MSW ハンドラー定義は tRPC 定義に追従できます(テスト・Storybook で使用していたtodosMock.getTodos関数はtrpcMswHandlerFactoryのラッパーです)

src/server/routers/todos/mock.ts
import { trpcMswHandlerFactory } from "@/server/trpc/mock";
import fixture from "./fixture.json";

export const getTodos = () =>
  trpcMswHandlerFactory({
    path: ["todos", "getTodos"], // ルーター定義に存在するパスのみ許容
    type: "query",
    response: fixture.todos, // ルーターが返す値と互換性のあるスタブのみ許容
  });

このtrpcMswHandlerFactoryが勘所となっており、こちらの gistを参考にさせていただきました。tRPC は root ルーターインスタンス(appRouter)からプロジェクトの全 tRPC 定義の型推論を吸い上げることが可能で、import しているRouterInput型とRouterOutput型を経由して型推論を施します。

src/server/trpc/mock.ts
import type { RouterInput, RouterOutput } from "@/server/routers/_app";
import { rest } from "msw";

export const jsonRpcSuccessResponse = (json: unknown) => ({
  id: null,
  result: { type: "data", data: { json } },
});

export const trpcMswHandlerFactory = <
  K1 extends keyof RouterInput,
  K2 extends keyof RouterInput[K1], // object itself
  O extends RouterOutput[K1][K2] // all its keys
>(endpoint: {
  path: [K1, K2];
  response: O;
  type?: "query" | "mutation";
}) => {
  const fn = endpoint.type === "mutation" ? rest.post : rest.get;
  const route = `${process.env.BASE_URL || ""}${"/api/trpc/"}${
    endpoint.path[0]
  }.${endpoint.path[1] as string}`;
  return fn(route, (req, res, ctx) => {
    return res(ctx.json(jsonRpcSuccessResponse(endpoint.response)));
  });
};

注意点

Batching をオフにすること

tRPC は同じタイミングで発生したリクエストを一つのリクエストに結合する「Batching」という機能が備わっています。MSW でも Batching リクエストに対応しようとするとなると、かなり大変です。

そのためサンプルではプロダクションコードとは別に、テスト向けに用意したsrc/tests/trpc.tsxという Provider を利用しており、この Provider を利用する限りは Batching が発生しないようにしています。

もしテストで検証したい内容が Batching に直結するなら、本稿で紹介しているアプローチは再考した方がよいでしょう。

ルーターのネストに対応できていない

trpcMswHandlerFactoryの内訳を見れば察せるのですが、ルーターのネスト構造に制約があります。endpoint.pathが深くなるような定義はカバーできていません(筆者としてはルーターをネストしたいモチベーションが思いつかないので、課題の温度感は低いです)。

src/server/routers/_app.ts
export const appRouter = router({
  greeting: greetingRouter,
  todos: todosRouter,
});

エラーレスポンスが返せていない

trpcMswHandlerFactoryはエラーレスポンスがまだ返せていません。こんな感じに MSW ハンドラーファクトリー関数に引数を与えることで、エラーを再現できると良さそうに思います。

const err = { message: 'Internal Server Error' }
setupMockServer(todosMock.getTodos({ err }));

紹介したサンプルコードのリポジトリは以下です。もう少し工夫の余地があるので、後日アップデートするかもしれません。

https://github.com/takefumi-yoshii/trpc-testing-ecosystem

Discussion

nishinohinishinohi

T3Stackでの開発で同じくmswをどうするかを検討していたのでとても参考になりました🙇‍♀。同じようなtrpcMswHandlerFactoryを実装して試したところ、データの取得のmockは正常に動作したのですがエラーケースのためにモックで以下のようなレスポンスを返してもclient側でエラーをハンドリングできません(React Queryのデータ取得でisLoading: true, isError: falseとなる)
この問題と近いような話がmsw-trpcのissuesにあがっており、この問題の修正としてjsonをシリアライズする実装がPRで出されていたので同じ実装を試したところそれでも同じ状態でした。
この問題の解決方法や何かしら知見などあれば教えていただきたいです。

return HttpResponse.json(
      {
        error: {
          json: {
            message: 'some message',
            code: -32603,
            data: {
              code: 'INTERNAL_SERVER_ERROR',
              httpStatus: 500,
              stack: 'stack contents',
              path: 'router.path', // ルーターのパス
            }
          }
        }
      },
      { status: 500 }
    )

// シリアライズの例
return HttpResponse.json(
      {
        error: superjson.serialize({
          message: 'some message',
          code: -32603,
          data: {
            code: 'INTERNAL_SERVER_ERROR',
            httpStatus: 500,
            stack: 'stack contents',
            path: 'router.path'
          }
        })
      },
      { status: 500 }
    )