Nuxt3/Vitest環境でのContainerコンポーネントのユニットテスト

## はじめに

今回はNuxt3/Vitest環境でのContainerコンポーネントのユニットテストについて紹介します。
弊社のフロントエンドではContainer/Presentational設計を採用しています。
Containerコンポーネントでユニットテストを実施するための実装で手間取った部分があるため、まとめたいと思います。
もっとスマートな実装などがあるかもしれませんが、少しでも参考になれば幸いです。
※この記事の内容は[テックブログ](https://uniforce.co.jp/media/blog/xk680kjvn4d/)でも公開しています。

### 環境

Nuxt.jsの3系を利用しているため、Vueも3系、TypeScriptとなります。
```bash
"nuxt": "3.11.2"
"vitest": "2.1.4"
"@nuxt/test-utils": "3.14.2"
"@vue/test-utils": "2.4.6"
```

## 前提条件

ひとくちにContainerコンポーネントといってもプロジェクトにより細かい差異があるかと思いますので、Containerコンポーネントの実装イメージを前提条件として記載します。

今回のContainerコンポーネントには下記の特徴があります。
- 外部アクセスがある
    - API呼び出し・CookieやLocalStorageへのアクセス・Composableへのアクセス等
- 初回表示時に非同期でAPI通信を行う
    - `<script setup>`内で`await useAsyncData`をしているため非同期setupになっている
- 単一のコンポーネント(Presentational)を描画する
    - テスト対象となるアウトプットはPresenationalのpropsが主になるイメージ
        - それ以外ではAPIのリクエストやComposableの関数呼び出しの引数など
    - Containerコンポーネントのロジックのテストを目的とするため、Presentationalによる描画の結果は考慮しない

### 実装イメージ

```vue
<script lang="ts" setup>
import Presentational from './Presentational.vue'

// NuxtのComposableを利用
const route = useRoute()
// 自作のCompoasbleを利用
const utils = useUtils()

// Presentaionalに渡すデータ
const contents = ref()

// Presentaitonalに渡す関数
const onClickButton = () => {}

// 非同期でAPI通信
const { data, error } = await useAsyncData(...)
if (data.value) {
  // API成功時処理
  contents.value = data.value
}
if (error.value) {
  // API失敗時処理
}
</script>

<template>
  <!-- 描画用コンポーネントに値を渡す -->
  <Presentational
    :contents="contents"
    :on-click-button="onClickButton"
   />
</template>
```

## 設定

Vitestの設定ファイル(`vitest.config.mts`)は下記のようになっています。
このあたりはプロジェクトにより設定が変わる場合もあると思いますので、参考までにそのまま記載しておきます。
```ts
import path from 'path'
import { defineVitestConfig } from '@nuxt/test-utils/config'
import AutoImport from 'unplugin-auto-import/vite'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    globals: true,
  },
  plugins: [AutoImport({ imports: ['vue', 'vue-router'] })],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './'),
      '~': path.resolve(__dirname, './'),
    },
  },
})
```

## ポイント

空でテストを実行して成功するかなどのセットアップは割愛し、Containerコンポーネントでユニットテストを書くにあたり特徴的だった実装をピックアップします。

### Containerコンポーネントをマウントする

コンポーネントをマウントする際には[mount](https://test-utils.vuejs.org/api/#mount)関数が便利ですが、今回のコンポーネントは非同期コンポーネントになっているため、`Suspense`で囲んであげる必要があります。

> If the component you want to test uses an asynchronous `setup`, then you must mount the component inside a `Suspense` component (as you do when you use it in your application).

参考：https://test-utils.vuejs.org/guide/advanced/async-suspense#Testing-asynchronous-setup

```ts
import SampleContainer from '@/components/sample/Container.vue'

const suspenseContainer = defineComponent({
  components: { SampleContainer  },
  template: `<Suspense><SampleContainer  /></Suspense>`,
})

const container = mount(suspenseContainer)
```

また、非同期を待つための仕組みとしてPromiseを発火してくれる[flushPromises](https://test-utils.vuejs.org/api/#flushPromises)や、動的インポートのロードを待ってくれる[vi.dynamicImportSettled](https://vitest.dev/api/vi.html#vi-dynamicimportsettled)など、非同期な処理をサポートする関数を状況に応じて利用することが出来ます。

### Presentationalコンポーネントをモックする

今回のContainerコンポーネントは単一のPresentationalコンポーネントを持ちます。

非同期ではないコンポーネントであれば[shallowMount](https://test-utils.vuejs.org/guide/advanced/stubs-shallow-mount.html)することで子コンポーネントをスタブ化出来ますが、非同期コンポーネントは`Suspense`で囲んでしまっているためそのまま利用するとテスト対象のContainerコンポーネントがスタブになってしまいます。

そのため、パスを指定してモックにします。

```ts
vi.mock('@/components/sample/Presentational.vue', () => {
  return {
    default: () => {
      return h('div')
    },
  }
})
```

### Presentationalコンポーネントに渡されたpropsを取得する

Containerコンポーネントのアウトプットとして、Presentationalに渡されたpropsを取得します。

色々と試行錯誤した結果、Presentationalをモックとして定義する時に**ダミーのコンポーネントを渡し、そのコンポーネントのattrsを参照する**ことでテストすることが出来ました。

```ts
// モックしたいPresentational
import type SamplePresentational from '@/components/sample/Presentational.vue'
// ダミーコンポーネント
import DummyComponent from '@/test/helper/DummyComponent.vue'

// Presentationalのpropsの型を取得しておく
type PresentationalProps = InstanceType<typeof TaskPresentational>['$props']

// モックするときにダミーコンポーネントを描画し、propsを渡す
vi.mock('@/components/sample/Presentational.vue', () => {
  return {
    default: (props: PresentationalProps) => {
      return h(DummyComponent, props)
    },
  }
})
```

ダミーコンポーネントは下記のように簡単なものになっています。

```vue
<template>
  <div v-bind="$attrs">Dummy</div>
</template>
```

ダミーコンポーネントにpropsを渡すことが出来たら、今度はattrsを取得してみます。
下記はテスト中に何度も初期化することになるので関数化したものです。

```ts
const resetContainer = async (): Promise<{
  container: VueWrapper
  // attrsを経由するとキャメルケースだったPresentationalPropsがケバブケースになってしまうため
  // ケバブケースにしてくれる型を戻り値にする
  getCurrentAttrs: () => CamelToKebabObject<PresentationalProps>
}> => {
  // Containerコンポーネントをマウント
  const container = mount(suspenseContainer)
  
  // 非同期処理を待つ
  await flushPromises()
  await vi.dynamicImportSettled()

  return {
    container,
    getCurrentAttrs: () => {
      // Containerの子コンポーネントになっているダミーコンポーネントのattrsを取得する
      return container.getComponent(DummyComponent).vm
        .$attrs as CamelToKebabObject<PresentationalProps>
    },
  }
}
```

おまけですが、上記コードにコメント記述している通り、attrsを経由するとpropsはケバブケースになってしまいます。

そうするとテストコードを書く時の補完が効かず実装しにくいため、キャメルケースのObjectのキーをケバブケースにしてくれる型を利用しました。

```ts
type CamelToKebab<S extends string> = S extends `${infer T}${infer U}`
  ? U extends Uncapitalize<U>
    ? `${Uncapitalize<T>}${CamelToKebab<U>}`
    : `${Uncapitalize<T>}-${CamelToKebab<U>}`
  : ''
export type CamelToKebabObject<O extends object> = {
  [key in keyof O as CamelToKebab<string & key>]: O[key]
}
```

これらを組み合わせることで、Presentationalに渡されたpropsのテストをしています。

```ts
// Containerの持つコールバック関数を呼び出したり
getCurrentAttrs()['on-click']?.()

// Containerの渡したpropsの中身をテストしたり
expect(getCurrentAttrs().contents).toEqual([])
```

### Composableなどをモックする

ロジックのテストをする時のためにComposableの関数の戻り値を変えたり、逆にComposableの関数を正しい引数で呼び出しているかのテストをしたりといったユースケースがあります。

Nuxtの持つCompoasbleである[useRouter](https://nuxt.com/docs/api/composables/use-router)を例にします。

```ts
// useRouter.pushの引数をテストするために関数のモックを用意しておく
const routerPushMock = vi.fn()

const { useRouterMock, useHogeMock } = vi.hoisted(() => {
  return {
    useRouterMock: vi.fn().mockImplementation(() => {
      return {
        // pushが呼び出されたら用意しておいたモックを返す
        push: routerPushMock
        // backはテスト時に利用しないので適当に設定する
        back: vi.fn()
      }
    }),
    // 参考用に自作Composable
    useHogeMock: vi.fn()
  }
})

// useRouterをモックする
vi.mock('vue-router', () => {
  return {
    useRouter: useRouterMock,
  }
})
// @nuxt/test-utilsにmockNuxtImportというものもあります。
mockNuxtImport('useHoge', () => {
  return useHogeMock
})

...

// useRouter.pushの引数をテストしたり
expect(routerPushMock).toHaveBeenCalledWith('/sample')

// 戻り値を変えることでテストのインプットとしたりする
useRouterMock.mockImplementationOnce(() => {
  return {
    ...
  }
})
```

参考：[mockNuxtImport](https://nuxt.com/docs/getting-started/testing#mocknuxtimport)

### APIレスポンスをモックする

APIのレスポンスをモックする時は`@nuxt/test-utils`の[registerEndpoint](https://nuxt.com/docs/getting-started/testing#registerendpoint)を利用しました。

```ts
const articleIndexHandler = vi.fn().mockImplementation(() => {
  return { articles: ['article1', 'article2'] }
})
registerEndpoint('/articles', { method: 'GET', handler: articleIndexHandler })
```

`/atricles`にGETリクエストが来ると、`['article1', 'article2']`を返却してくれるようになります。

他のレスポンスをテストしたい時は、モックの戻り値を変更してあげれば良いです。

```ts
// レスポンスを変えたり
articleIndexHandler.mockImplementationOnce(() => {
  return { articles: [] }
})

// エラーにしたり
articleIndexHandler.mockRejectedValueOnce(new Error(''))
```

注意点として、**パスパラメータの有無両方のAPIをモックする場合は、パスパラメータのある方から先に宣言**しておかないと一方の定義が無視されてしまうことがありました。

関連しそうなIssueはありましたが、2024/11時点で解決されていませんでした。うまく動作しない場合は宣言の順番を変更してみても良いかもしれません。

### APIリクエストをテストする

PUTやPOSTなどのAPIの場合、リクエストボディやパスパラメータ、クエリなどをテストしたい場合があります。

その場合はregisterEndpointに渡すhandlerの関数にテスト用のモックの呼び出しを仕込むことでテストが出来ます。

```ts
// リクエスト内容を与えるためのモック
const articleUpdateHandlerRequest = vi.fn()
const articleUpdateHandler = vi
  .fn()
  .mockImplementation((args: { node: { req: { body: string } } }) => {
    // Handlerの引数に情報が入ってくるため、取得してモックに流す
    articleUpdateHandlerRequest(JSON.parse(args.node.req.body))
    return {}
  })
registerEndpoint('/article/1', { method: 'PUT', handler: articleUpdateHandler })

// どんな引数で呼び出されたかでテストする
expect(articleUpdateHandlerRequest).toHaveBeenCalledWith({
  content: 'text'
})
```

上記のようにHandlerの引数にリクエストに関する情報が入っているので、他のパターンについても同様にテストが可能です。

## おわりに

今回はContainerコンポーネントのユニットテストにおけるポイントについてまとめました。

コンポーネントの設計次第なためそのまま参考に出来ることはあまりないとは思いますが、どれかひとつでも助けになることがあれば嬉しいです。

今後、他のテストやもう少し踏み込んだ実例も紹介出来たらと思います。

ここまでご覧いただき、ありがとうございました！