storybook-vue3-router で URL クエリパラメータが絡む挙動をするコンポーネントをテストする

## やりたいこと

検索画面では検索時に検索条件を URL クエリパラメータに反映させたり、画面を開いた時に URL クエリパラメータを検索条件に反映させたりします。

ルーティング自体はコンポーネントの責務ではないものの、ルーティングされた後にクエリパラメータの読み書きをするのはコンポーネントの責務なので、これを storybook 上で確認できるようにしたいです。

## バージョンなど

以下のバージョンで動作を確認しています。

* vue: 3.3.11
* vue-router: 4.2.5
* storybook: 7.6.5
* storybook-vue3-router: 5.0.0

一応、 package.json 丸ごと載せておきます。

<details>
<summary>package.json 全文</summary>

```package.json
{
  "name": "vue-router-test",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "storybook": "storybook dev --port 6006"
  },
  "dependencies": {
    "@vueuse/core": "^10.7.0",
    "lodash": "^4.17.21",
    "storybook": "^7.6.5",
    "vue": "^3.3.11",
    "vue-router": "^4.2.5"
  },
  "devDependencies": {
    "@storybook/addon-essentials": "^7.6.5",
    "@storybook/addon-interactions": "^7.6.5",
    "@storybook/addon-links": "^7.6.5",
    "@storybook/addon-styling": "^1.3.7",
    "@storybook/jest": "^0.2.3",
    "@storybook/testing-library": "^0.2.2",
    "@storybook/vue3-vite": "^7.6.5",
    "@vitejs/plugin-vue": "^4.5.2",
    "autoprefixer": "^10.4.16",
    "postcss": "^8.4.32",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "storybook-addon-vue-slots": "^0.9.27",
    "storybook-vue3-router": "^5.0.0",
    "tailwindcss": "^3.3.6",
    "typescript": "^5.2.2",
    "vite": "^5.0.8",
    "vue-tsc": "^1.8.25"
  }
}

```
</details>

## テスト対象のコンポーネントを作る

URL の `name` パラメータと検索条件が対応するページを作りました。今回はこのページについてのテストを書いていきます。
（`page`パラメータも使うつもりでしたが、今回のテストではクエリパラメータの読み書きが確認できれば十分だったので使いません。）

![デモ画像スクショ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/656409/eaf35602-d208-1d57-6464-68bec46fc9c9.png)

このページのコンポーネントは以下のように実装しています。
ざっくり
* ページを開いた時に URL クエリパラメータを読んでフォームを作って検索している
* 検索が成功した時にその条件で URL クエリパラメータに書き込んでいる

ということをしています。

```DemoPage.vue
<script lang="ts" setup>
import { UserSearchForm } from "@/forms/user_search_form";
import { useApi } from "@/hooks/use_api";
import { User } from "@/models/user";
import { Routes } from "@/router/routes";
import { Ref, onMounted, ref } from "vue";

import UserSearchInput from "@/components/UserSearchInput.vue";
import UserTable from "@/components/UserTable.vue";
import { useRouter } from "vue-router";

const router = useRouter();

const form = ref(new UserSearchForm());
const api = useApi();
const users: Ref<User[]> = ref([]);
const isSearchExecuting = ref(false);

onMounted(async () => {
  const query = router.currentRoute.value.query;
  form.value = UserSearchForm.fromQuery(query);

  await onSearch();
});

const onSearch = async () => {
  try {
    isSearchExecuting.value = true;

    users.value = await api.getUsers(form.value);

    await router.push({
      name: Routes.Demo,
      query: form.value.buildQuery(),
    });
  } finally {
    isSearchExecuting.value = false;
  }
};
</script>

<template>
  <div class="p-4 flex flex-col space-y-4">
    <UserSearchInput class="w-64" v-model="form.name" :on-search="onSearch" />
    <UserTable :users="users" :is-loading="isSearchExecuting" />
  </div>
</template>
```

ローカル環境では、トップレベルのコンポーネントである `App.vue` で API を実行するクライアントを provide します。

```App.vue
<script lang="ts" setup>
import { UserSearchForm } from "./forms/user_search_form";
import { provideApi } from "./hooks/use_api";
import { User } from "./models/user";
import { sleep } from "@/utils/sleep";

const allUsers: User[] = [...Array(100)].map((_, i) => ({
  id: i + 1,
  name: `ユーザー ${i + 1}`,
}));

const api = {
  // 今回はデモなのであらかじめ作ったユーザー一覧からユーザーを返却するが、実際は API サーバーにリクエストを飛ばしたりする
  getUsers: async (form: UserSearchForm) => {
    await sleep(100);

    return allUsers.filter((user) => user.name.includes(form.name));
  },
};

provideApi(api);
</script>

<template>
  <router-view />
</template>
```

router の初期化に使う設定です。ローカル環境の router からも、 storybook の router からも参照するので routes を共通化して export しておくといいです。

```routes.ts
import { RouteRecordRaw } from "vue-router";
import DemoPage from "@/pages/DemoPage.vue";

export const Routes = Object.freeze({
  Demo: "Demo",
});

export const routes: Array<RouteRecordRaw> = [
  {
    path: "/demo",
    name: Routes.Demo,
    component: DemoPage,
  },
];
```

## story を書く

`DemoPage.vue` の挙動の確認ですが、ルーティングが絡むので `App.vue`に対するストーリーになります。 ここで、storybook上でルーティングの確認をしたいので [storybook-vue3-router](https://storybook.js.org/addons/storybook-vue3-router)を導入します。

使い方としては decorator に vueRouter を追加するだけです。

今回は `DemoPage.vue` のテストをしたいので、 setup で `/demo` に遷移するようにします。

```App.stories.ts
import { vueRouter } from "storybook-vue3-router";
import { routes } from "@/router/routes";
import App from "./../App.vue";
import type { Meta, StoryObj } from "@storybook/vue3";
import { User } from "@/models/user";
import { UserSearchForm } from "@/forms/user_search_form";
import { provideApi } from "@/hooks/use_api";
import { useRouter } from "vue-router";


type Story = StoryObj<typeof App>;

const meta: Meta<typeof App> = {
  component: App,
  decorators: [vueRouter(routes)],
  argTypes: {
    // prop にないものを指定すると型エラーになるらしい
    // https://github.com/storybookjs/storybook/issues/23352
    getUsers: { action: true },
  },
};

const allUsers: User[] = [...Array(100)].map((_, i) => ({
  id: i + 1,
  name: `ユーザー ${i + 1}`,
}));

export const basic: Story = {
  render: (args) => ({
    components: { App, CurrentRouteFullpathText },
    setup() {
      const router = useRouter();
      const api = {
        getUsers: async (form: UserSearchForm) => {
          args.getUsers(form);
          return allUsers.filter((user) => user.name.includes(form.name));
        },
      };
      provideApi(api);
      router.push("/demo");
    },
    template: `
      <App />
    `,
  }),
};

export default meta;
```

いい感じに表示されました。

![DemoStory01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/656409/581a6ead-9a38-09a0-55d3-4c49b81b7dad.png)

とはいえこのままだとクエリパラメータに反映されているかどうかが画面からわからないので、現在のパスを表示するだけのコンポーネント `CurrentRouteFullPathText.vue` を作ってストーリーで表示するようにします。

```CurrentRouteFullPathText.vue
<script lang="ts" setup>
import { useRouter } from "vue-router";

const router = useRouter();
</script>

<template>
  <div data-testid="CurrentRouteFullPathText">
    {{ router.currentRoute.value.fullPath }}
  </div>
</template>
```

```App.stories.ts
import CurrentRouteFullPathText from "./../components/CurrentRouteFullPathText.vue";

/*
  省略
*/

export const basic: Story = {
  render: (args) => ({
    components: { App, CurrentRouteFullPathText },
    setup() {
      const router = useRouter();
      const api = {
        getUsers: async (form: UserSearchForm) => {
          args.getUsers(form);
          return allUsers.filter((user) => user.name.includes(form.name));
        },
      };
      provideApi(api);
      router.push("/demo");
    },
    template: `
      <CurrentRouteFullPathText />
      <App />
    `,
  }),
};
```

検索パラメータが反映されていることが確認できました。

![DemoStory02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/656409/9730e32c-3c80-1324-fe04-ad30dd80720f.png)


### interaction test を書く


書きました。インタラクションを別ストーリーにしているのは、自分がストーリーを見る時にインタラクションテスト実行後の画面になっているのが個人的に嫌だからで、特に深い意味はないです。

特筆するところとしては、クエリパラメータに反映されることの確認は useRouter して fullPath を確認するようにしたかったのですが、うまくいかなかったので先ほどの `CurrentRouteFullPathText.vue` のテキストを確認することで代替しています。

```App.stories.ts

import { cloneDeep } from "lodash";
import { expect } from "@storybook/jest";
import { screen, userEvent, waitFor } from "@storybook/testing-library";

/*
  省略
*/

export const basicInteraction: Story = cloneDeep(basic);

basicInteraction.play = async ({ args }) => {
  // 画面を開いた時には条件なしで検索される
  await waitFor(() => expect(args.getUsers).toHaveBeenCalledTimes(1));
  await waitFor(() =>
    expect(args.getUsers).toHaveBeenCalledWith(
      expect.objectContaining({
        name: "",
      })
    )
  );

  // 検索文字列を入力してクリックする
  const userSearchInput = await screen.findByTestId("UserSearchInput");
  const userSearchButton = await screen.findByTestId("UserSearchButton");

  const searchString = "test";
  await userEvent.type(userSearchInput, searchString);
  await userEvent.click(userSearchButton);

  // 入力した文字列で検索される
  await waitFor(() => expect(args.getUsers).toHaveBeenCalledTimes(2));
  await waitFor(() =>
    expect(args.getUsers).toHaveBeenCalledWith(
      expect.objectContaining({
        name: searchString,
      })
    )
  );

  // 検索条件がクエリパラメータに反映される
  // クエリパラメータの順序まで気にしたくないので、部分一致の確認にしている
  const CurrentRouteFullPathTextDiv = await screen.findByTestId(
    "CurrentRouteFullPathText"
  );

  expect(CurrentRouteFullPathTextDiv.innerText).toContain(
    `name=${searchString}`
  );
};

```

テストが通りました。

![DemoInteraction01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/656409/77bd5de6-4656-a015-f164-81da9ff55002.png)


上記は検索内容がクエリパラメータに反映されることの確認でしたが、 setup でクエリパラメータ付きのパスを指定することで、クエリパラメータが検索パラメータに反映されることも確認できます。

```App.stories.ts
export const withQuery: Story = {
  render: (args) => ({
    components: { App, CurrentRouteFullPathText },
    setup() {
      const router = useRouter();
      const api = {
        getUsers: async (form: UserSearchForm) => {
          args.getUsers(form);
          return allUsers.filter((user) => user.name.includes(form.name));
        },
      };
      provideApi(api);
      router.push("/demo?name=aaa");
    },
    template: `
      <CurrentRouteFullPathText />
      <App />
    `,
  }),
};

export const withQueryInteraction: Story = cloneDeep(withQuery);

withQueryInteraction.play = async ({ args }) => {
  // 画面を開いた時にクエリパラメータがある場合はその条件で検索される
  await waitFor(() => expect(args.getUsers).toHaveBeenCalledTimes(1));
  await waitFor(() =>
    expect(args.getUsers).toHaveBeenCalledWith(
      expect.objectContaining({
        name: "aaa",
      })
    )
  );
};
```

![DemoInteraction02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/656409/902283bf-1539-33bb-0176-d3ec84df3cbd.png)