Nuxt 3 × Vitest でユニットテストのエラーを全て解消するための調査レポート

Nuxt 3 × Vitest で既存のユニットテストを全て通すための調査レポート

こんにちは、ANDPADでフロントエンドエンジニアをしている小泉(@ykoizumi0903)です。

昨年末に Nuxt 3 が正式リリースされて以降、アップデートに向けた移行作業を粛々と進めています!

今回はその中でも、ユニットテストを Nuxt 3 に対応させる際に苦労したポイントや対処法についてご紹介したいと思います。

私達のチームでは昨年秋以降、コンポーネントユニットテストの拡充に力を入れてきていて、その一環として元々 Jest から Vitest にテストツールを移行していました。

しかし、Nuxt 3 への移行作業を行ったことで、これらのテストのうちの約半分が失敗するようになりました。

この記事では、このテストのエラーをどのように解消したかの流れをまとめて説明したいと思います。

(Nuxt 2 環境で Vitest を実行する方法や、プロダクトのNuxt 3 への移行についてはここでは触れませんのであらかじめご了承ください。)

記事の流れ

自分のチームの環境では、以下のように順を追って対応作業を進めることで、エラーを全て解消することができました。

  1. Vitest の設定ファイルをアップデートする
  2. Vue Test Utils の V2 (Vue 3 バージョン)に対応させる修正を行う
  3. Nuxt の関数の自動インポートと同等の設定を Vitest に適用させる
  4. Nuxt のエイリアス設定と同じものを Vitest にも適用させる
  5. Nuxt 3 独自の関数を呼び出してもエラーにならないように関数をモック化する
  6. Nuxt のコンポーネントの自動インポートと同等の設定を Vitest に適用させる
  7. 自動インポート・エイリアス解決・モックなどの設定を自動化する

以下、この流れに沿ってエラーを取り除く方法を解説していきますが、必ずしも全ての Nuxt 3 環境でこの作業が必要であるとは限りません。

例えば、自動インポートを使っていなければ③・⑥の手順は不要ですし、shallowMount でのテストしか行っていなければ⑥のコンポーネントのモック設定はなくても動きます。また、Nuxt 独自の機能を使っていなければ⑤の作業を行わなくてもエラーにならないこともあります。

ただ、これらの問題が複雑に絡み合っていることが多く、1つだけを直してもエラーが消えないことも多いので、どういう理屈でこのエラーが発生するのかを何となく把握していた方が、ハマりどころが少なく済むのではないかと考えています。

前置き

あらかじめ断っておくと、この記事は「Nuxt 3 で Vitest を実行するのは難しい」という趣旨の記事ではありません

確かに私自身もこの問題の対応にかなり苦労したのですが、Vitest と Nuxt という異なる環境で同じVueコンポーネントを実行するために追加の設定が必要になるのは避けられず、1つ1つのエラーの理屈がわかると、どれも腑に落ちるというか「当然こうなる」と納得できる内容であり、決して Nuxt と Vitest の相性が悪いわけではないと感じています。

また、この Nuxt 環境での Vitest の実行についてはまさに現在も改善が進んでいる最中であり、近いうちに、プラグインを導入するだけでこの対応が不要になる可能性は高いです。

特に、Nuxt コミッターでもある @danielroe 氏の手がける nuxt-vitest モジュールが数週間前に公開されていますので、基本的にはこちらを使うことを推奨します。

github.com

他にも、自動インポート設定の連携という形で同様の問題に対処した yassilah/vite-plugin-nuxt-test というプラグインも公開されており、今回の記事ではこちらの実装内容も一部参考にさせて頂いています。

現時点で、少なくとも私達のチームの環境では、このモジュールを入れるだけでは全ての事象が解決しなかったのと、そもそもこの対応を開始したのが nuxt-vitest モジュールの公開前であったため、独自の方法で対応させるという手段を選択しましたが、基本的には上記のモジュールで対応する方が良いはずですし、私自身も、Nuxt 3 への移行作業が完了したら、なるべく早くこのモジュールへ移行したいと考えています。

ただ、上記のモジュールを使う場合でも、「Nuxt 3 環境でなぜユニットテストが失敗するのか、どういった設定を行うとエラーメッセージを消えるのか」という仕組みを理解しておいて損はないと思うので、そういった観点でこの記事を読んで頂けたら嬉しいです。

1. Vitest の設定ファイルをアップデートする ── vitest.config.ts

まず初めに、Vitest用の設定ファイルである vitest.config.ts を Vue 3 用に更新する必要があります。

参考までに、Nuxt 2 で Vitest を動かすための Vitestの設定ファイル vitest.config.ts は、以下のようになっていました。

import path from 'path'
import { defineConfig } from 'vitest/config'
import { createVuePlugin } from 'vite-plugin-vue2'
import ScriptSetup from 'unplugin-vue2-script-setup/rollup'

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
    // 省略
    },
  },
  plugins: [createVuePlugin() as any, ScriptSetup()],
  resolve: {
    alias: [
      {
        find: /^@\//,
        replacement: path.join(__dirname, 'app/'),
      },
      { find: /^~\/(.*)$/, replacement: path.join(__dirname, 'app') },
      {
        find: /^vue$/,
        replacement: 'vue/dist/vue.common.js',
      },
      {
        find: '@nuxtjs/composition-api',
        replacement: path.join(
          __dirname,
          'node_modules/@nuxtjs/composition-api/dist/runtime/index.js'
        ),
      },
    ],
  },
})

基本的には Vitest 公式の Vue2 向けサンプルファイルと同じ内容ですが、script setup を先取りして Vue 2 環境で使うためのプラグインを読み込んだり、アプリケーションファイルのディレクトリを app 配下に変更していることに合わせたりといった設定を追加しています。

この設定ファイルを、Vue 3 環境では以下のように書き換えました。

import path from 'path'
import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
    // 省略
    },
  },
  plugins: [Vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './app/'),
      '~': path.resolve(__dirname, './app/'),
    }
  },
})

Pluginを Vue 3 向けに変更し、Script Setup 機能も最初から含まれているのでプラグインは削除します。

以降は、この設定ファイルを使って vitest run コマンドを実行していることを前提に、説明を進めていきます。

2. Vue Test Utils の V2 (Vue 3 バージョン)に対応させる修正を行う

次に、 Vue Test Utils を Vue 3 対応バージョンである v2 に上げたことへの対応を行います。

Vue Test Utils のマイグレーションについては、公式の移行ガイドが提供されているのでこれに従って書き換えれば良いです。

Migrating from Vue Test Utils v1 | Vue Test Utils

特に影響の大きくエラーを発生させやすい変更点には以下のようなものがあります。

  • mount関数で props を渡すためのオプションが propsData から props になった
  • mount関数で stubs mocks を指定する際に、 global オプションを挟む必要ができた

ただし、上記のようなリネームがメインなので、大きくコードを修正しなければならないケースは少ないと感じました。

ちなみに、V1時点で非推奨となっていたものがV2で削除されているケースがあり、これは移行ガイドに載っていない点に注意が必要です。例えば、V1では find メソッドでDOM要素とコンポーネントの両方を検索できていましたが、V2ではコンポーネントは findComponent でしか検索できなくなっています。

コンポーネントの検索に find を使用することは非推奨となり、削除される予定です。代わりに findComponent を使用してください。

Wrapper | Vue Test Utils

find 要素では DOMWrapperfindComponent では VueWrapper 型が常に返るので、以前よりも挙動はわかりやすくなっているのですが、これまでパスしていたものがエラーになるのでお気を付けください。

また、上記のマイグレーションガイドに載っていない点として、型の名前の変更も行われています。

例えば、 mountshallowMount の返り値の型を定義する際に使う Wrapper 型が VueWrapper にリネームされています。

これを使ってテスト内で型を参照する場合は、 InstanceType を使って以下のように書くことができます(この型定義自体は Vue2 でも使えます)。

import ChildComponent from '@/components/ChildComponent.vue'
import { shallowMount, type VueWrapper } from '@vue/test-utils'

const wrapper = shallowMount(ChildComponent) as VueWrapper<InstanceType<typeof ChildComponent>>

これらの修正を行うことで、Vue Test Utils に由来するエラーは解消されますが、refcomputed といった、Nuxt からエクスポートされている各種関数を参照しているテストで、Reference Error というエラーが表示されるようになります。

次はこの Reference Error を解消します。

3. Nuxt の関数の自動インポートと同等の設定を Vitest に適用させる ── unplugin-auto-import

そもそもこの Reference Error がなぜ起きるかというと、Nuxt 3 の自動インポート設定が Vitest に適用されていないためです。

Nuxt 3 では以下のような関数が自動でインポートされ、明示的にインポート文を書かなくてもエラーにならず、型定義・Tree Shaking 対応で自由に呼び出せる仕組みを持っています。

  • Vue 3 の 組み込み関数(defineComponentrefcomputed など)
  • Nuxt 3 の機能として提供されている組み込み関数(useStateuseAsyncDatauseRouter$fetch など)
  • Nuxt Modules として登録したライブラリを通して提供される関数( PiniausePiniaVueUse の各種関数など)
  • ユーザー定義の関数(composables または utils ディレクトリに配置されているもの)

Vitest (というよりも Vite)でこれと同じように自動インポートを行う場合には、 unplugin-auto-import という別のプラグインを使用する必要があります。作者は Vitest の開発者でもある @antfu 氏です。

github.com

こちらのプラグインを npm i -D unplugin-auto-import でインストールし、 vitest.config.ts の plugin に追加することができます。

以下は README.md に記載されているコード例の一部です。

import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    Vue(),
    AutoImport({
      imports: [
        // presets
        'vue',
        'vue-router',
        // custom
        {
          '@vueuse/core': [
            // named imports
            'useMouse', // import { useMouse } from '@vueuse/core',
          ],
        },
      ],
    }),
  ]
})

unplugin-auto-import の imports には、ライブラリ名を指定するだけで Auto Import が機能するプリセットがいくつか提供されているので、 Vue Vue Router Pinia といったライブラリの関数はこのプリセットを使うことができます。

一方、Nuxt のプリセットは提供されていないので、Nuxt にしかない useState などの関数は自分で書く必要があります。この場合は前述のコード例の @vueuse/core のように、インポート元のライブラリと関数名を列挙することで、import { useMouse } from '@vueuse/core' がトップに自動で挿入された状態で実行されるようになります。

Auto imports · Nuxt Concepts

Nuxt 3 の公式ドキュメントによると、Nuxt で自動インポートされる関数を明示的にインポートする場合には、 import { ref, computed } from '#imports' のように #imports をインポート元として指定すれば良いとされています。

では、 import { useState } from "#imports" となるように補えば良いのかというと、そうではありません。

Nuxt 3 で import { ref } from '#imports' という書き方ができるのは、この #imports からのインポートを VueVue Router といった参照元に振り分ける処理が裏で行われているためで、Vitest で #imports を指定しても、#imports が指しているファイルである .nuxt/imports.d.ts 内で参照しているほかのエイリアスが解決できないことでエラーになってしまいます。

そこで、自動生成されている .nuxt/imports.d.ts の中身を見に行きます。もしこのファイルがまだ存在しない場合は、 nuxi prepare コマンドを実行してください。

export { useHead } from '#head';
export { isVue2, isVue3 } from 'vue-demi';
export { useAsyncData, useLazyAsyncData, refreshNuxtData, clearNuxtData, defineNuxtComponent, useNuxtApp, defineNuxtPlugin, ~~~ } from '#app';
export { onBeforeRouteLeave, onBeforeRouteUpdate, useLink } from 'vue-router';
export { withCtx, withDirectives, withKeys, withMemo, ~~~ } from 'vue';
export { definePageMeta } from '../node_modules/nuxt/dist/pages/runtime/composables';

あまりに多いので省略しましたが、Vue や Vue Router といったライブラリ経由のもの、Nuxt で定義されているものなどが、それぞれ別に定義されています。

ここで、Pinia や VueUse などの Modules を登録していたり、composables utils ディレクトリに関数を登録している場合はもっと多くなります。

なので、これを全てコピーして、from 以降を key 、export の中身を value の配列に指定すれば、 Nuxt を起動しているのと同じ自動インポート設定になります。具体的には、以下のようなコードとなります。(#app#head 以外は省略し、Vue と Pinia はコピーではなくプリセットを指定しています)

export default defineConfig({
  plugins: [
    Vue(),
    AutoImport({
      imports: [
        'vue',
        'pinia',
        {
          '#head': ['useHead'],
          '#app': [
            'useAsyncData',
            'useLazyAsyncData',
            'refreshNuxtData',
            'clearNuxtData',
            'defineNuxtComponent',
            'useNuxtApp',
            ~~~
          ],
        },
      ],
    }),
  ]
})

4. Nuxt のエイリアス設定と同じものを Vitest にも適用させる ── resolve.alias

この状態でテストを実行してみると、エラーメッセージが Reference Error からError: [vite-node] Failed to load #appという新たなエラーに変わります。

Reference Error は「インポートされていないものが呼び出されている」エラーでしたが、今度は「インポートできない」というエラーです。

import { useHead } from '#app' が自動で補われるようになっても、#app #head に対応するエイリアスの指定がなく、 参照先が Vitest ではわからないからです。

ちなみに、 #app #head 以外でインポートされている関数(refcomputed)の参照元はエイリアスではなく 'vue''vue-router' などが指定されているので、Vue の関数を使っているコンポーネントしかなければ、これだけでテストは通るようになります。

エイリアスの内容は.nuxt/tsconfig.jsoncompilerOptions.paths を確認します。

この中で必要なものをコピーして resolve.alias に指定します。

export default defineConfig({
  resolve: {
    alias: {
      "~": path.resolve(__dirname, "src"),
      "@": path.resolve(__dirname, "src"),
      "#app": path.resolve(__dirname, "node_modules/nuxt/dist/app"),
      "#head": path.resolve(__dirname, "node_modules/nuxt/dist/head/runtime"),
    },
  },
  plugins: [
    Vue(),
    AutoImport({
      imports: [
        'vue',
        'pinia',
        {
          '#head': ['useHead'],
          '#app': [
            'useAsyncData',
            'useLazyAsyncData',
            'refreshNuxtData',
            'clearNuxtData',
            'defineNuxtComponent',
            'useNuxtApp',
            ~~~
          ],
        },
      ],
    }),
  ]
})

5. Nuxt 3 独自の関数を呼び出してもエラーにならないように関数をモック化する( vi.mockvi.spyOn

これで Nuxt 3 の自動インポートにまつわる問題が解決し、エラーメッセージは以下のように変わります。

この TypeError: Package import specifier "#build/app.config.mjs" is not defined in package というエラーメッセージの意味がわかりづらいのですが、Nuxt が起動していない状態でモジュールを参照すると、このようなエラーが出てしまうようでした。

ちなみに、Nuxt 2 から 3 に移行するだけならこのエラーには遭遇しないのでは? と思われるかもしれませんが、Nuxt Composition API で読み込むことのできた useRouter useRoute#app を通して動作しているので、これらを使っている場合エラーになります。

他にも、Nuxt 2 で process.env を参照していた変数を useRuntimeConfigに置き換えていたり、これまでの head プロパティを useHead()layout プロパティを definePageMeta() で指定していたりと、移行作業の過程で Nuxt 3 独自の関数を使うケースは少なくありません。

この対処法として、 #app #headといった動作していないインポート元を丸ごとモック化する必要があります。

vi.mock("#app", () => ({ useState: vi.fn() }));

このように vi.mock を使って必要な関数を定義することで、 import { useState } from "#app" と書いてあるコンポーネントでも #app の実体ではなくモックを呼び出すようになります。

そのため、このモック設定を、テストに失敗している各テストファイルのトップで呼び出せば、エラーを回避できます。

ここでさらにダミーの値を返したい場合には mockReturnValue を使います。

例として、表示直後にトップページにリダイレクトするページの挙動をテストする場合は、以下のようなコードになるはずです。

import RedirectVue from '@/pages/redirect.vue'
vi.mock("#app", () => ({ useRouter: vi.fn() }));

describe('redirect.vue', async () => {
  const mockRouterPush = vi.fn()
  vi.spyOn(await import('#app'), 'useRouter').mockReturnValue({
    push: mockRouterPush,
  } as any)
  const wrapper = shallowMount(RedirectVue)
  expect(mockRouterPush).toHaveBeenCalledOnceWith("/")
})

このように mockReturnValueuseRouter() の返り値を指定しているので、コンポーネント側で useRouter().push('/') が呼ばれていることを確認できます。

as any を使っているのは、useRouter の中身丸ごとではなく一部のメソッドのみをモックしていて、そのままではタイプエラーになるためです。

関数の返り値をモックで定義するAPIには、 mockReturnValue 以外にも mockResolvedValuemockRejectedValue など様々なバリエーションがあるので、下記のページをご確認ください。

Mock Functions | Vitest

vi.mock と vi.spyOn の違い

ちなみに、このテストで vi.mockvi.spyOn を別々に呼んでいる理由ですが、この2つの関数には以下のような挙動の違いがあります。

  • mock → 特定のファイルパスを指定してモジュール全体をモック化する。巻き上げによって必ずファイル最上部で呼ばれる
  • spyOn → 特定のモジュールを指定し、そのモジュールに存在するプロパティを上書きしてモック化する。モック化したもの以外は元のモジュールがそのまま呼ばれる

今回のケースで重要なのは太字にした部分です。vi.mock だけを使うと、mockReturnValue の返す値をテストケースごとに変えたり、返り値を変数(ここでは mockRouterPush )にして expect で参照することができません。

const mockRouterPush = vi.fn();
vi.mock("#app", () => ({
  useRouter: vi.fn().mockReturnValue({
    push: mockRouterPush,
  }),
}));

このようなコードを書いても、変数定義よりも vi.mock の方が巻き上げられて先に実行されてしまうため、エラーになります。

一方、 vi.spyOn だけを使おうとすると、Nuxt が実行されていない場合はそもそも #app というモジュールが存在していないので上書きもできません。

そのため、一度 vi.mock で仮の関数を定義し(ここは後で上書きするので中身は何でも良い)、その後 vi.spyOn で上書きする、という流れで、両者を併用する必要があるのです。

この挙動の違いがわかると、例えば Nuxt を介さないモジュール(自分で定義した composables)の場合は vi.spyOn だけ呼べば良いし、テストケースごとに返り値を変える必要がないのであれば vi.mock の時点で返り値まで定義してしまって良い、というように、無駄な記述を避けられるようになります。(私自身も今回調べるまではこの違いをよく理解していませんでした)

ここまでの設定を行うと、全てのテストが無事に成功するようになりました!

6. Nuxt のコンポーネントの自動インポートと同等の設定を Vitest に適用させる ── unplugin-vue-components

ところで、Nuxt 3 で自動インポートされるのは関数だけではなく、コンポーネントも同様です。自動インポートで子コンポーネントを読み込んでいる親コンポーネントをマウントしようとすると、[Vue warn]: Failed to resolve component: XXX のようなメッセージが表示されます。

この項を最後にした理由としては、このメッセージはあくまで警告だからです。ここで解決できなかった子コンポーネントは自動的にスタブ化されるため、コンポーネントがインポートされていないこと自体ではエラーにはならず、ここまでの設定でテストは成功する場合が多いです。

エラーになるのは、例えばコンポーネントに名前付きスロットを渡していたり、そのコンポーネントをクリックすることでのイベントをテストの期待値に含めていたりと、そのコンポーネントが正しく描画されることを前提にしたテストが存在する場合に限られます。

ただ、Testing Library では子コンポーネントをモック化せずに全て読み込んだ状態でテストを実行することを推奨していることもあり、グローバルインポートもテスト時に正しく解決できるようにしていきます。

このコンポーネントの自動インポートでも、またもや @antfu 氏の公開しているプラグイン、 unplugin-vue-components を利用して、設定を行っていきます。

6-1. .nuxt/components.d.ts に登録されているコンポーネント

Nuxt 3 で自動インポートされるコンポーネントについては、大きく分けると下記の4パターンがあります。

  1. Nuxtの組み込みコンポーネント(<NuxtLink><NuxtLayout><ClientOnly><Body> など)
  2. Nuxtモジュールを介して登録しているコンポーネント
  3. ユーザー定義したコンポーネント( components/ ディレクトリ配下のもの。詳しくは components/ · Nuxt Directory Structure
  4. UIライブラリなど、Nuxt モジュールではなく plugins などで登録しているコンポーネント

※Vue の組み込みコンポーネント(<Transition> <Teleport> <Suspense> <template> )などについては特に設定しなくても解決されるのでここでは触れません。

この4パターンのうち、1~3で登録されるコンポーネントについては、自動生成される.nuxt/components.d.ts でその中身を確認できます。

// Generated by components discovery
declare module '@vue/runtime-core' {
  export interface GlobalComponents {
/* components配下の全ファイル ~~~ */
    'CommonBreadcrumb': typeof import("../app/components/common/Breadcrumb.vue")['default']
    'CommonCheckbox': typeof import("../app/components/common/Checkbox.vue")['default']
/* Nuxt組み込みコンポーネント ~~~ */
    'NuxtWelcome': typeof import("../node_modules/@nuxt/ui-templates/dist/templates/welcome.vue")['default']
    'NuxtLayout': typeof import("../node_modules/nuxt/dist/app/components/layout")['default']
/* Lazy Prefix 付きインポート用(レンダリング時に初めてインポートされる) ~~~ */
    'LazyCommonBreadcrumb': typeof import("../app/components/common/Breadcrumb.vue")['default']
    'LazyCommonCheckbox': typeof import("../app/components/common/Checkbox.vue")['default']
    'LazyNuxtWelcome': typeof import("../node_modules/@nuxt/ui-templates/dist/templates/welcome.vue")['default']
    'LazyNuxtLayout': typeof import("../node_modules/nuxt/dist/app/components/layout")['default']
  }
}

/* 各種型定義 */
export const CommonBreadcrumb: typeof import("../app/components/common/Breadcrumb.vue")['default']
export const CommonCheckbox: typeof import("../app/components/common/Checkbox.vue")['default']

かなり端折っていますが、このように全コンポーネントのインポート元と、その Lazy バージョンがそれぞれ記述されているはずです。

これをコピーして、以下のように unplugin-vue-components 向けの設定に書き直します。

import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import path from 'path'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
  plugins: [
    Vue(),
    AutoImport({/* 省略 */}),
    Components({
      dts: false,
      resolvers: [
        (componentName: string) => {
          if (componentName === 'CommonBreadcrumb') {
            return {
              name: 'default',
              as: componentName,
              from: '@/components/common/Breadcrumb.vue',
            }
          }
          if (componentName === 'NuxtWelcome') {
            return {
              name: 'default',
              as: componentName,
              from: '@@/node_modules/@nuxt/ui-templates/dist/templates/welcome.vue',
            }
          }
          if (componentName === 'LazyCommonBreadcrumb') {
            return {
              name: 'default',
              as: componentName,
              from: '@@/app/components/common/Breadcrumb.vue',
            }
          }
          if (componentName === 'LazyNuxtWelcome') {
            return {
              name: 'default',
              as: componentName,
              from: '@@/node_modules/@nuxt/ui-templates/dist/templates/welcome.vue',
            }
          }
        },
      ],
    })
  ]
})

さすがにもう少し綺麗に書けるコードはあると思いますが、このようにすれば全てのコンポーネントを登録することができます。

6-2. Nuxtを経由せずにグローバル登録しているコンポーネント

Nuxt を通さずに、 Vue.useVue.component でグローバル登録しているコンポーネントでは、別のアプローチが必要になります。

今回のプロダクトでは、Element UI の Vue 3 バージョンである Element Plus を、プラグイン経由で登録していました。

参考までに、以下は Element Plus のコンポーネントをグローバルに登録する plugins/element-plus.ts の簡単な例です。(Nuxt 3 では plugins/ ディレクトリ内のファイルは全て自動で登録されます。)

import ElementPlus from 'element-plus'
export default defineNuxtPlugin((nuxtApp) => {
  // import components
  nuxtApp.vueApp.use(ElementPlus)
})

こちらも resolver に登録する必要があります。 Element Plus の場合は共通の Prefix が El なので、この El から始まるコンポーネントが全て Element Plus からインポートされるようにしました。

import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import path from 'path'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
  plugins: [
    Components({
      dts: false,
      resolvers: [
        (componentName: string) => {
          if (componentName.startsWith('El')) {
            return {
              name: componentName,
              as: componentName,
              from: 'element-plus',
            }
          }
        },
      ],
    })
  ]
})

これで、<ElInput><ElDatePicker> といったコンポーネントが全て element-plus から自動的にインポートされるようになりました。

他のコンポーネントライブラリでも同様だと思います。共通の Prefix がなかったり、他のライブラリとコンフリクトしていたら、 4-1 と同様に必要なコンポーネント名を1つずつ指定すれば良さそうです。

注意点として、4-1 では name を常に 'default' としていましたが、Element Plus からは名前付きでインポートするので name にもコンポーネント名を指定してください。

ただし、Element Plus の場合はもっと簡単な方法もあります。 unplugin-vue-components がプリセットとして用意している組み込みリゾルバーを使う方法です。

https://github.com/antfu/unplugin-vue-components#importing-from-ui-libraries

import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
  plugins: [
    Vue(),
    AutoImport({/* 省略 */}),
    Components({
      dts: false,
      resolvers: [
        ElementPlusResolver({ importStyle: false }),
      ],
    })
  ]
})

このように組み込みのリゾルバーを指定して渡すだけで、Element Plus のコンポーネントは全て自動で読み込まれます。ユーザー定義のカスタムリゾルバーは、配列で渡すことができます。

他にも VuetifyBootstrapVueVueUse Components といった代表的なライブラリについては一通り組み込みリゾルバーが提供されているので、簡単に設定できます。

これらの方法を組み合わせて、コンポーネントのインポートエラーも解消できました。

7. 自動インポート・エイリアス解決・モックなどの設定を自動化する

ここまででテスト実行時のエラーについては全て解消できました。

ただ、この設定を全て行うと vitest.config.ts の行数が相当長くなってしまっているはずですし、Nuxt の内容が変更されるたびに .nuxt/tsconfig.json から設定をコピーする必要が出てきます。

なので、自分のチームでは、3~6の手順については、 .nuxt/tsconfig.json.nuxt/imports.d.ts.nuxt/components.d.ts といったファイルを fs.readFileSync などで読み込んで加工することで、その一部を自動化しています。

こちらについては自動化が完全にできているわけではないのと、やっている内容も愚直に String.prototype.replace() で加工しているだけだったりするので、これで動く! という保証はしづらいのですが、例えば 3 のインポート設定の自動化は以下のようなコードで行っています。

import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
const nuxtImportsConfigObject: { [key: string]: string[] } = JSON.parse(`{
  ${fs
    .readFileSync(path.resolve(__dirname, '.nuxt/imports.d.ts'), 'utf-8')
    .replace(/export {(.*?)} from '(\S*)';/g, '"$2": [$1],')
    .replace(/ (\S*?)(,| \])/g, ' "$1"$2')
    .replace(/\],$/g, ']')}
}`)

export default defineConfig({
  plugins: [
    Vue(),
    AutoImport({
      imports: [nuxtImportsConfigObject],
    }),
  ]
})

また、最初に紹介した yassilah/vite-plugin-nuxt-test プラグインはまさにこの部分の自動化を行うライブラリなので、興味がある方はぜひそちらを参照してみてください。

おわりに

今回は Nuxt 3 環境で Vitest でのユニットテストを実行するための必要なポイントを紹介しました。

Nuxt 3 はリリース直後ということもあり、他にもいろいろと工夫が必要な部分がありますが、個人的にはこのユニットテストが一番の難所でした。

ただ、この作業のおかげで Vitest がどういう仕組みで動いているのか、mockspyOn の違いなど、今まで何となく雰囲気で書いていたテストコードの意味を理解することができたので、個人的にはかなり良い経験になったと考えています。

自分のチームとしては Nuxt 3 アップデートはあと一息というところまで来ており、なるべく早いタイミングでアップデートできるように取り組んでいます。大変な作業ではありますが、Nuxt 3 に上げることで開発環境・パフォーマンスの両面で大きな恩恵が得られそうで、移行後の開発が楽しみにもなっています。

アンドパッドではこのような技術的チャレンジに一緒に取り組んでいただける方を大募集しております! 興味のある方はカジュアル面談など、ぜひお気軽に参加してくだされば嬉しいです。

hrmos.co

engineer.andpad.co.jp