Nuxt3 - 単体テスト後編 - モック・スタブ用のユーティリティを使う

| 11 min read
Author: noboru-kudo noboru-kudoの画像

前回はNuxt3の単体テスト方法として以下の内容を見てきました。

  • Nuxtのテストユーティリティ(@nuxt/test-utils)をセットアップする
  • テスト用のNuxt環境上でNuxtコンポーネントをマウントしてテストを書く

後半となる今回は、テストユーティリティが提供するモック・スタブに関する機能について見てみたいと思います。

Information

ここではVitest自体のモック機能について詳細は触れません。
少し昔のものですが、以下の記事でVitestの概要をご紹介してます。

上記の記事では、モックについてはあまり触れていませんが基本的な使い方はJestと同じです。
Jestのモックは以下記事でご紹介しています。

Composableのモック化(mockNuxtImport)

#

この機能が最も使用頻度の高いと思います。
Nuxt3の代表的な機能の1つとして自動インポートが挙げられます。
これを使うとNuxt/VueのコアAPIに加えて、composablesディレクトリに配置したComposableはimportなしで使えるようになります。
プロダクトコードが簡潔になりますので、Nuxtを使っている多くのプロジェクトで採用していると思います。
これを簡単にモック化するのがmockNuxtImportです。

ここでは、Nuxtが提供するComposableのuseRouteをモック化するケースを考えます。
テスト対象のプロダクトコードは以下です。

<script setup lang="ts">
const route = useRoute();
</script>

<template>
  <div v-if="route.params.id">{{ route.params.id }}</div>
</template>

Vue RouterのRouteにアクセスしてパスパラメータ(id)を表示するページです。
mockNuxtImportを使ってuseRouteをモック化すると以下のようになります。

import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime';
import testPage from '~/pages/route/[id].vue';

mockNuxtImport('useRoute', () => () => ({
  params: {
    id: '999'
  }
}));

test('using mockNuxtImport', async () => {
  const wrapper = await mountSuspended(testPage);
  expect(wrapper.get('div').text()).toBe('999');
});

テストファイルのトップレベルでmockNuxtImportを記述します。
第1引数はモックにする自動インポート対象のComposableです。vi.mockを使う場合はComposableのパス(この例では#app/composables/router)を指定する必要がありますが、この変換はmockNuxtImportがやってくれます。

第2引数はモックのFactory関数です。モックにするuseRouteは関数なのでここで記述するファクトリ関数も関数を返す必要があります。

コラムの方に詳細は記述していますが、mockNuxtImportはAPIとしての実態はなくマクロ(Viteプラグイン)として動作し、ソースコードはvi.mockに書き換えられます。
このため、vi.mock同様にファイル最上部に巻き上げ(hoisting)られます。テスト(test関数)ごとに複数のmockNuxtImportを配置しても最後の1つで上書きされます。
以下vi.mockのドキュメントからの引用です。

vi.mock is hoisted (in other words, moved) to top of the file. It means that whenever you write it (be it inside beforeEach or test), it will actually be called before that.
(以下DeepL訳)
vi.mockはファイルの一番上に引き上げられる(言い換えれば、移動させられる)。つまり、(beforeEachの中であれtestの中であれ)これを書くと、実際にはその前に呼び出されることになる。

とはいえ、テストごとにモックの振る舞いを変えたいことは当然あるかと思います。
テストユーティリティのドキュメントにも言及されていますが、このようなケースではvi.hoistedを使ってモックを初期化します。

以下はvi.hoistedを使って各テストでモックの振る舞いを記述するサンプルです。

import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime';
import testPage from '~/pages/route/[id].vue';

// mockNuxtImport(vi.mock)と一緒に巻き上げられる(初期化エラーは発生しない)
const { mockRoute } = vi.hoisted(() => ({
  mockRoute: vi.fn()
}));

// こちらは失敗する(巻き上げられない)
// -> ReferenceError: mockRoute is not defined
// const mockRoute = vi.fn();

// vi.mockに変換されるので巻き上げられる
mockNuxtImport('useRoute', () => mockRoute);

afterEach(() => {
  mockRoute.mockReset();
})

test('id=999', async () => {
  mockRoute.mockReturnValue({
    params: {
      id: '999'
    }
  })
  const wrapper = await mountSuspended(testPage);
  expect(wrapper.get('div').text()).toBe('999');
});

test('id=undefined', async () => {
  mockRoute.mockReturnValue({
    params: {
      id: undefined
    }
  })
  const wrapper = await mountSuspended(testPage);
  expect(wrapper.find('div').exists()).toBe(false);
});

vi.hoistedはvi.mockと一緒に巻き上げ対象となりますので、参照エラーは発生しません(単純にトップレベルで宣言すると巻き上げられず参照エラーになります)。
この特性を利用して、vi.hoistedでモックを初期化(vi.fn)しておき、それをvi.mockのFactory関数で返すようにします。
あとは各テストに応じてモックの振る舞いを記述するだけです(もちろんモックは各テストで共有されますので、afterEach等で後始末しておきます)。

なお、Vitestのvi.mock/vi.hoistedの巻き上げに関する詳細は以下記事がとてもよくまとまっています。
内部の仕組みを知りたい方はご一読ください(本記事でもかなり参考にさせていただきました)。

mockNuxtImportマクロの変換内容

前述の通り、mockNuxtImportはマクロでAPIとしての実態はありません。
実際の動きを調べてみたところ、以下のように書き換えが行われていました。

vi.hoisted(() => {
  if (!globalThis.__NUXT_VITEST_MOCKS) {
    vi.stubGlobal('__NUXT_VITEST_MOCKS', {});
  }
});
vi.mock('#app/composables/router', async (importOriginal) => {
  const mocks = globalThis.__NUXT_VITEST_MOCKS;
  if (!mocks['#app/composables/router']) {
    mocks['#app/composables/router'] = { ...await importOriginal('#app/composables/router') };
  }
  mocks['#app/composables/router']['useRoute'] = await (() => () => ({
    params: {
      id: '999'
    }
  }))();
  return mocks['#app/composables/router'];
});
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime';
import testPage from '~/pages/route/[id].vue';

結構たくさんのコードに変換されていますが、大事なのはVitestのvi.mockに変換されている部分です。
もっとシンプルに書くと以下のような感じでしょうか。

vi.mock('#app/composables/router', async (importOriginal) => {
  return {
    ...await importOriginal<typeof import('#app/composables/router')>(),
    useRoute: () => ({
      params: {
        id: '999'
      }
    })
  };
});
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime';
import testPage from '~/pages/route/[id].vue';

つまり、mockNuxtImportマクロは自動インポートしているComposableのパスを取得して、vi.mockのFactory関数で指定した部分をモックにしているだけです。
原理が分かるとシンプルですね。

サブコンポーネントのスタブ化(mockComponent)

#

テスト対象のコンポーネントのスタブを提供します(mockComponentという名前ですがスタブのイメージが近そうです)。
以下のようなボタンコンポーネントがあったとします。

<script setup lang="ts">
  const { data: foo } = await useFetch('/api/foo');
</script>

<template>
  <button v-if="foo">{{ foo.name }}</button>
</template>

APIからボタン名を取得してボタンを描画するカスタムコンポーネントです。

これを使うページコンポーネントは以下のようなものです。

<script setup lang="ts">
  const counter = ref(0);
</script>

<template>
  <MyButton @click="counter++" />
  <div>{{ counter }}</div>
</template>

このページをテスト対象として単体テストを書いてみます。
ここで、ボタンコンポーネントはuseFetchでAPIに依存してるので、スタブにしたいと考えたとします。

Nuxtのテストユーティリティが提供するmockComponentを使うと以下のようになります。

import testPage from '~/pages/mocks/comp-mock.vue';
import { mockComponent, mountSuspended } from '@nuxt/test-utils/runtime';

mockComponent('MyButton',  {
  template: '<button>stub button</button>'
});

test('using mockComponent', async () => {
  const wrapper = await mountSuspended(testPage);

  await wrapper.get('button').trigger('click');
  expect(wrapper.get('div').text()).toBe('1');
});

mockComponentもファイルのトップレベルに配置します。上記では第1引数にコンポーネント名(相対パスも可)、第2引数にスタブコンポーネント(外部ファイルのimportも可)を定義しています。
mockNuxtImport同様に、mockComponentもvi.mockに変換されますので、Vitestでファイル最上部まで巻き上げられます。このためトップレベルに1つ配置が原則になります。

上記テストを実行するとMyButtonコンポーネントはスタブで置き換えられ、APIコールは行われません。

Vue Test Utilsのスタブ機能を使う

ここではNuxtのテストユーティリティのスタブ機能を紹介しましたが、これを使わなくてもVue Test Utilsにもスタブ機能はあります。

この場合は以下のようなテストコードになります。

import testPage from '~/pages/mocks/comp-mock.vue';
import { mountSuspended } from '@nuxt/test-utils/runtime';

test('using VTU stub', async () => {
  const wrapper = await mountSuspended(testPage, {
    global: {
      stubs: {
        MyButton: defineComponent({
          template: '<button>stub button</button>'
        })
      }
    }
  });

  await wrapper.get('button').trigger('click');
  expect(wrapper.get('div').text()).toBe('1');
});

最後に言うのもアレですが、こちらだとテストごとにスタブの振る舞いを変えるのも簡単です。
デフォルトスタブ実装の提供もありますので、現時点では基本はこちらを使う方がいいんじゃないかと思ったりもしてます。

mockComponentマクロの変換内容

mockComponentもマクロで、ソースコードはテストユーティリティで変換されます。
実際には以下のようなコードに書き換えられていました。

import { vi } from 'vitest';
vi.mock('/path/to/components/MyButton.vue', async () => {
  const factory = ({
    template: '<button>mock button</button>'
  });
  const result = typeof factory === 'function' ? await factory() : await factory;
  return 'default' in result ? result : { default: result };
});
import testPage from '~/pages/mocks/comp-mock.vue';
import { mockComponent, mountSuspended } from '@nuxt/test-utils/runtime';

やはりmockNuxtImport同様に、vi.mockに変換されています。
Factory関数を今回のケースに限定したものに置き換えると以下のような形でしょうか。

import { vi } from 'vitest';
vi.mock('/path/to/MyButton.vue', () => ({
  default: {
    template: '<button>mock button</button>'
  }
}));
import testPage from '~/pages/mocks/comp-mock.vue';
import { mockComponent, mountSuspended } from '@nuxt/test-utils/runtime';

こちらでもテストは成功します。

APIのスタブ・モック化(registerEndpoint)

#

先ほどはComponentごとスタブにしましたが、それよりもAPIコールだけをモックにした方がより実態に近いテストができます。
NuxtのテストユーティリティにはAPIのスタブ化用にregisterEndpoint APIを提供しています(こちらはマクロではなくAPIとしての実態があります)。

これを使うと、テストユーティリティがスタブ用のAPIを提供してくれます。
テストコードは以下のようなものになります。

import testPage from '~/pages/mocks/comp-mock.vue';
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime';

registerEndpoint('/api/foo', () => ({
  name: 'stub button'
}))

test('using registerEndpoint', async () => {
  const wrapper = await mountSuspended(testPage);

  await wrapper.get('button').trigger('click');
  expect(wrapper.get('div').text()).toBe('1');
});

これでコンポーネント自体は実体を使い、APIのみをスタブにした単体テストになります。

MSWでAPIをモックにする

registerEndpointはAPIのスタブ機能を提供するだけで、テストファイル内でレスポンスを切り替えたり検証目的で使えません。
VitestではAPIのモック化にMock Service Worker(MSW)を推奨しています。

MSWを使う場合は、以下のように書き換えられます。

import testPage from '~/pages/mocks/comp-mock.vue';
import { mountSuspended } from '@nuxt/test-utils/runtime';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createFetch } from 'ofetch';

const server = setupServer();

afterAll(() => {
  server.close();
});

afterEach(() => {
  server.resetHandlers();
});

test('using Mock Service Worker', async () => {
  server.use(http.get('http://localhost:3000/api/foo', () => HttpResponse.json({
    name: 'stub button'
  })));
  server.listen({ onUnhandledRequest: 'error' });
  // [重要!!]$fetchのモック化用のパッチ(server.listen後に)
  globalThis.$fetch = createFetch({ fetch: globalThis.fetch, Headers: globalThis.Headers })

  const wrapper = await mountSuspended(testPage);

  await wrapper.get('button').trigger('click');
  expect(wrapper.get('div').text()).toBe('1');
});

このようにすればテストごとにレスポンスをエラーに切り替えたり、リクエスト検証等の細かい制御ができます。

ただし、上記ソースコードを見れば分かるとおり、現時点ではMSWにモック化する場合は一工夫必要でした。
というのも、そのままMSWを使うとuseFetchはモック化されませんでした($fetchも同様)。
調べてみると、Nuxtが内部で使用しているofetchのIssueに以下がありました。

createFetchを使わないとMSWのモック化の対象にならないようです。
プロダクトコードを変えたくない場合は、上記のようにserver.listen後に$fetchを上書きしてあげればモック化できました。

まとめ

#

2部構成でNuxtのテストユーティリティを使った単体テストのやり方をご紹介しました。

Nuxtのテストユーティリティは、Nuxt環境エミュレートやテスト用の各種マクロ・APIを提供してくれます。
機能面に目が行きがちですが、この辺りのテスト機能も品質維持には重要です。しっかり抑えていきたいですね。

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。