💬

createRoutesStubは、自動生成される型定義にマッチしないケースが多い

に公開

ReactRouter(v7 / Framework mode)を愛用しています。Vitestでのテストもめっちゃ速いです。

今回どーしたもんだこれって思ったのが、Route.ComponentPropsを受け取る形のComponentを作ると、createRoutesStubが実質使えない、です。ComponentPropsに必要な全てのパラメータを与える必要があります。ちょっと意味が。

export default function LoginComponent({ actionData }: Route.ComponentProps) {}

createRoutesStubの基本

その名前の通り、routes.tsに書かれたルートの構造をスタブするものです。これにより、route単位のテストがかけるようになります。

loader actionは、その都度指定しないと駄目です。loader: () => { return {items:[]}}という感じで適当なデータを返してもいいし、実際に使ってるloaderをそのまま差し込むこともできます。ルート単位のE2E、アツいと思うんですよね。どうっすかね。

以下は、公式にも書かれているログインページのテストコードをちょっと改修したものです。

login_text.tsx
import {
  render,
  screen,
  waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { it } from 'vitest';
import LoginComponent, { action } from "./login";

it("間違った情報でログインを実行", async () => {
  const LOGIN_ERROR_MESSAGE = "ユーザー名とパスワードの組み合わせが誤っているようです";

  const Stub = createRoutesStub([
    {
      path: "/login",
      Component: LoginComponent,
      // actionをそのまま差し込める
      // mockにしたいなら、action: () => ({ errors: "ERROR"}})みたいな感じ。
      action: action
    },
  ]);

  render(<Stub initialEntries={["/login"]} />);

  const emailInput = screen.getByLabelText('メールアドレス');
  const passwordInput = screen.getByLabelText('パスワード');
  await userEvent.type(emailInput, "test@example.com");
  await userEvent.type(passwordInput, "test");
  userEvent.click(screen.getByText("ログイン"));
  await waitFor(() => screen.findByText(LOGIN_ERROR_MESSAGE));
})

プロダクションで動かす loader actionのロジックをそっくりテストすることができます。ルート単位でE2Eを書くことができるので、最も効率が良いテストがかけると思っています。パラメーター飛んできたけど検索条件に入ってねぇとか、DBに値が入ってねぇ or バリデーション通ってないとかをチェックするために、ルート単位で何パターンかのテストを書いて、最後は、PlayWrightで正常系シナリオ一本書いて終わり、というやり方を取っています。

Mockでダミー返して見た目のレンダリングやロジックだけ確認するテストは、プロダクトのデザインシステムが作られていない限り、必須ではないでしょう。受託では不要の長物だと思います。

LoginComponentuseActionDataというHooksを使っているので、このように書きます。

export default function LoginComponent(){
 const lastResult = useActionData<typeof action>()
}

以下のようなactionDataを受け取る定義になると、コンパイルエラーがめっちゃでます。

export default function LoginComponent({ actionData }: Route.ComponentProps) {}
// Error
Type '({ actionData }: ComponentProps) => Element' is not assignable to type 'ComponentType<{}> | null | undefined'.
  Type '({ actionData }: ComponentProps) => Element' is not assignable to type 'FunctionComponent<{}>'.
    Types of parameters '__0' and 'props' are incompatible.
      Type '{}' is missing the following properties from type 'ComponentProps': params, loaderData, matches

params loaderData matchesの定義がないんだが?と言われてしまいます。ReactRouter v7が動的に差し込んでくれるものですけど、Vitest単体で見ると、「そんなもん知らん」という話のようです。

Routes.ComponentPropsを使ってpropsを受け取るコンポーネントを作ると、それらすべての値にMockが必要になるので、まじで耐えられへん。

全部にmockを差し込むってこういう感じのコードになる(動かないです)

仮に、ここまでやってRRv7の破壊的変更があったら差し替える可能性もある。Hooksでしれっと差し替えが効くのがよいし、Mockするなら、loader/actionの関数であるべきじゃん。

owata.tsx
it("HOME画面初期表示", async () => {
  const Stub = createRoutesStub([
    {
      path: "/",
      Component: () => (
        <HomeComponent
          loaderData={{
            categories: [
              { label: "test", value: 1 },
              { label: "test2", value: 2 },
            ],
            searchResult: {
              items: [],
              total: 1,
            },
          }}
          params={{}}
          matches={[
            {
              id: "root",
              params: {},
              pathname: "/",
              data: undefined,
              handle: undefined,
            },
          ]}
        />
      ),
    },
  ]);

参考スレ
https://github.com/remix-run/react-router/discussions/12730#discussioncomment-11904422

解決策:Hooksに戻した

テスト書きたいので、useLoaderDatauseActionDataにして、しれっと状態を注入するHooksスタイルに戻るしかない。

パスパラメータを伴うRouteだと型定義が合わない

/detail/:itemIdのような定義をすると、自動生成された Loader / Actionの型定義を参照しない。

  const Stub = createRoutesStub([
    {
      path: "/detail/:itemId",
      loader: loader,
      Component: DetailComponent,
    },
  ],);
  render(<Stub initialEntries={["/detail/658"]} />);

この時、loaderが型エラーをはく。Type 'LoaderFunctionArgs<any>' is not assignable to type 'LoaderArgs' となる。RRv7のcontext, paramsなどをタイプセーフにしてくれる型定義が全部<any>で終わってしまう。

Route.LoaderArgsLoaderFuntionArgsに書き換えればエラーは消えるけど、こんな素敵な型定義が消えてLoaderFunctionArgs<Context = any>になるのはさみしい。

type Route.LoaderArgs = {
    request: Request;
    params: {
        itemId: string;
    } & {
        [key: string]: string | undefined;
    };
    context: MiddlewareEnabled extends true ? unstable_RouterContextProvider : AppLoadContext;
}

参考スレ
https://github.com/remix-run/react-router/issues/12494

解決策:華麗にスルー

一旦、赤波線を甘受して進めています。おーん。

createRemixStubの流れを受け継いでいるためか、型定義が厳密になったRRv7が作る定義と、うまく整合性が取れていないようです。Route.LoaderArgsを使わないで実装できるので、なんともかんとも。時間が経てば解決されるような気もする。動きはするから。

何かあれば追記します。

Discussion