Nuxt 3 × Vitest で単体テストの実行環境を作る

2022/05/15に公開約11,100字

Nuxt3 の RC版が公開され、いよいよ本格的な Vue3 時代が訪れようとしている2022年。

それに伴って関連ライブラリもいろいろと新しいものが生まれている中で、Vue のテストツールの新たなスタンダードになりそうなのが Vitest です。

https://zenn.dev/jay_es/articles/2021-12-22-vitest-comparison

https://zenn.dev/keita_hino/articles/f50f6bc4f57af7

Vitest自体を解説している記事は既にたくさん書かれているのでそちらを参考にして頂ければと思いますが、

2022年5月現在、Vitest を Nuxt3 と組み合わせて使った記事はまだ少なく、また設定しようとしたところでいくつかエラーを踏んで苦戦したので、そのメモも兼ねてまとめてみました。

まだ正式リリース前ではありますが、現状で Nuxt3 と Vitest を組み合わせようとする場合の参考になれば幸いです。

https://zenn.dev/ytr0903/articles/d0a91f6180d34e

ちなみに、Nuxt3そのものの機能が気になる方はこちらの記事もぜひよろしくお願いします。

Nuxt3 のセットアップ

まだNuxt3を触ったことがない人もいるかもしれないので念のため。知っている方は飛ばしてください。

https://v3.nuxtjs.org/getting-started/quick-start

こちらのガイドの通りに進めていきます。

npx nuxi init nuxt3-vitest-sample
code nuxt3-vitest-sample

でリポジトリを VSCode で開き、yarn install でインストール。

yarn dev で画面が立ち上がります。

ひとまず最低限のコンポーネントを作成します。

components/HelloMessage.vue
<script lang="ts" setup>
defineProps<{
  name: string;
}>();
</script>

<template>
  <div>Hello, {{ name }}!</div>
</template>

pages も作ります。こちらで props を編集できるようにします。

pages/index.vue
<script setup lang="ts">
const name = ref("World");
</script>

<template>
  <div>
    <HelloMessage :name="name" />
    <p>
      <input type="text" v-model="name" />
    </p>
  </div>
</template>

VSCode × Volar × Nuxt3は、Auto Importでも当たり前のように props の型チェックまでしてくれます。

最後に app.vue の NuxtWelcome を NuxtPage に変更したら完了。画面に反映されるはずです。

app.vue
<template>
  <div>
    <NuxtPage />
  </div>
</template>

Vitest の導入

次に Vitest を導入していきます。

yarn add -D vitest

https://github.com/vitest-dev/vitest/tree/main/examples/vue

Readmeの Vue3 向けのサンプルを基に vitest.config.ts をコピーしてきます。(@vitejs/plugin-vue はNuxt3にも含まれているので追加インストールの必要はありません)

vitest.config.ts
/// <reference types="vitest" />

import { defineConfig } from "vite";
import Vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [Vue()],
  test: {
    globals: true,
    environment: "jsdom",
  },
});

package.json の scripts に "test": "vitest" を追加し、とりあえず yarn test で実行しようとすると、以下のようなメッセージが出るので、指示に従って必要なパッケージをそのままインストールまで進めます。

ちなみにこれは後で気づいたのですが、Nuxt3の元々の機能としてもvitestの実行はサポートされており、scriptsに追加しなくても yarn nuxi test で同じことができます。(別途インストールは必要です)

なので、実は "@vitejs/plugin-vue" を使わないテストであれば vitest.config.ts も不要です(なぜ vitest.config を作成したのかについては後述します)。

単体テストファイルの作成

テストファイルを作成します。

まずは yarn add @vue/test-utils -D で vue-test-utils を追加して、テストを書いていきます。

tests/components/HelloMessage.spec.ts
import { describe, test, expect } from "vitest";
import { mount } from "@vue/test-utils";
import HelloMessage from "../../components/HelloMessage.vue";

describe("HelloMessage", () => {
  test("メッセージが表示される", () => {
    const wrapper = mount(HelloMessage, {
      props: {
        name: "World",
      },
    });
    expect(wrapper.text()).toContain("Hello, World!");
  });
});

yarn test を実行すると、テストが成功することがわかります。

インポート元が違うだけで 書き方は jest とほとんど同じなので、Jestに慣れている方はあまり迷わないと思います。

この時点で、基本的なコンポーネントのレンダリングは問題なく動作します。Jestと比べても明らかに高速で感動します。

Auto Import の ReferenceError を解決する

ところが、pagesのファイルに同じようにテストを追加しようとすると、内容に関わらず fail してしまいます。

ReferenceError: ref is not defined ということで、Nuxtで自動インポートされる ref などの関数を使っている際に、vitest では自動インポートされていないためマウントに失敗するというエラーのようです。

defineProps などはコンパイラマクロなのでランタイムでは無視されます。

この問題についていろいろ調べてみると、今のところ決定的な解決策はなさそうなのですが、2つの選択肢が見つかりました。

unplugin-auto-import/vite を使ってマッピングを解決する

@nuxt/test-utils-edge を使ってページ単位のテストを行う

unplugin-auto-import/vite と組み合わせる

unplugin-auto-import は、Vitestの作者でもある @antfu7 さんの提供しているプラグインの1つです。

これ自体はVite以外にもRollupやWebpackにも提供され、Vue以外のライブラリの自動インポートもサポートしている汎用的なライブラリなのですが、それは Readme や 【TypeScript】importの記述を不要にするunplugin-auto-import など他の方の記事に任せることにして、ここでは Vitestと組み合わせる使い方に絞って説明します。

まずは yarn add unplugin-auto-import -Dを実行し、#install の項のViteの設定を参考に、vitest.config.ts の plugins に AutoImportを追加します。

(Nuxt3 の Auto Import は、似たような仕組みですがこのプラグインに依存しているわけではないようなので、デフォルトではインストールされていません)

vitest.config.ts
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: ["vue"],
    }),
  ],
  test: {
    globals: true,
    environment: "jsdom",
  },
});

TypeScript で導入するとわかるのですが、この imports には preset としていくつかのライブラリの名前が用意されており、auto import したいものを指定する形式になっています。

ここにNuxtがあれば話は早かったのですが、ないのでとりあえず 'vue' を指定します。

これで、 refcomputed といったVue3の関数のReference Error が解消され、テストも無事に通るようになります!

Nuxt独自の関数はインポートされない

ただし、当然ながらここで auto import しているのは Vue であって Nuxt ではないので、Vue本体が提供していない機能、例えば、useRouter を自動インポートで使っている場合はこれだと引き続き ReferenceError になり、これはimports: ["vue", "vue-router"] のように別でインポート指定する必要があります。

vue-routerはプリセットがあるのでまだ簡単ですが、useState などはNuxt独自の機能なのでサポートされていません。

一応、unplugin-auto-import にはプリセット以外のマッピングをサポートする仕組みも用意されており、

vitest.config.ts
    AutoImport({
      imports: [
        "vue",
        {
          "nuxt/app": [
            "useState"
          ]
        }
      ]
    })

のように指定すれば ReferenceError は解消できるのですが、そもそも大元の Nuxt が動いていないので useState が動作しません。

さらに、Nuxtにはコンポーネントやカスタムフックといったユーザー定義関数の自動インポートもあるので、それらを一つ一つ手で定義していくのもなかなか厳しいものがあります。

なお、エラーを出させないだけであれば vi.stubGlobal でスタブ化してしまうことができ、こちらはファイル単位で設定できます。

vite.congig.ts
import { vi } from "vitest";
vi.stubGlobal("useState", () => {});

Mocking | Vitest

実際のところ、useStateやuseHeadといった関数のユニットテストを書きたいシチュエーションはそこまで多くないと思います。

なので、当面はVue3の基本的な機能に限って、 unplugin-auto-import やスタブを組み合わせてテストしつつ、将来的にNuxt3におけるコンポーネント単位のテストが正式にサポートされることを期待するのが良さそうです。

https://twitter.com/antfu7/status/1516988226665287680

(もっと良い方法をご存知の方はぜひコメントにお願いします!)

@nuxt/test-utils-edge を使う

もう1つのパターンとして、@nuxt/test-utils-edge を使う方法があります。

nuxi test というコマンドがあることについて少し触れましたが、

実は Nuxt3 のドキュメントには既に Nuxt 3 - Testing というページが用意されており、ここでVitestと一緒に @nuxt/test-utils-edge を導入する手順が書かれています。

では @vue/test-utils は不要だったのか、というとそんなことはなく、@nuxt/test-utils-edge は(私が調べた限り)mountのようなことはできません。

上記のページの Tests Setup の書かれているサンプルを見る限り、setupを最初に呼んでNuxtアプリケーションそのものを起動し、ページURLを指定してHTMLをテストするという、結合テスト寄りな内容になっています。

import { describe, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { setup, $fetch } from '@nuxt/test-utils-edge'

describe('ssr', async () => {
  await setup({
    rootDir: fileURLToPath(new URL('./fixture', import.meta.url)),
  })

  it('renders the index page', async () => {
    // Get response to a server-rendered page with `$fetch`.
    const html = await $fetch('/')

    expect(html).toContain('<a>A Link</a>')
  })
})

nuxi test を実行する時点では vitest.config.ts@vue/test-utils が不要と書きましたが、それではVueのコンポーネントをマウントさせることができないので、

Componentの単体テストを書きたい場合は Vitest の plugin に @vue/test-utils を自分で設定する必要がありそうです。

先程の pages/index.vue の例で言えば、

tests/pages/index.spec.ts
import { describe, test, expect } from "vitest";
import { setup, $fetch } from "@nuxt/test-utils-edge";

describe("Index.vue", async () => {
  await setup({
    // rootDir: fileURLToPath(new URL("../../app.vue", import.meta.url)),
    server: true,
    // test context options
  });
  test("Display a Component", async () => {
    const html = await $fetch("/");
    expect(html).toContain("Hello, World!");
  });
});

このように書くことで、確かにReference Errorに遭遇することなくテストは実行できるのですが、propsも変えたりといった操作はできません。(もちろん、DOM要素をJSで操作していろいろやればできるとは思いますが)

そして、当然ながらアプリ全体を起動するので時間もかかります。

逆説的に、そんな力業のテストであっても9秒という許容範囲の実行時間で収まっている Vite・Vitest・Nuxt3 の爆速さ の証明とも言えますが、サンプルサイト以外でこれを全てのテストの標準にするのはやはり無理があると思われるので、これまでJestなどでやってきたこととは別のシチュエーションでの利用が主になりそうです。

ちなみに、この setup を呼び出す際に、 ERROR Error: Vitest was initialized with native Node instead of Vite Node.というエラーが出ることがあります。(参照:Vitest was initialized with native Node instead of Vite Node · Issue #3252 · nuxt/framework

その際は、vitest.config.ts

vitest.config.ts
  test: {
    deps: {
      inline: [/@nuxt\/test-utils-edge/],
    },
  },

と、depsを指定してあげると動くようになりました。この理由を詳しく知りたい方は Issue with 3rd party library loading vitest. · Issue #843 · vitest-dev/vitest のissueを参照すると良さそうです。

<script setup> でコンポーネント内の変数をテストする

さて、遠回りになりましたが、改めて pages/index.vue のrefのテストを書いてみます。

tests/components/HelloMessage.spec.ts
import { describe, test, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import IndexVue from "../../pages/index.vue";

describe("Index.vue", () => {
  test("Component", () => {
    const wrapper = mount(IndexVue, {
      stub: { HelloMessage: true },
    });
    wrapper.find("input").setValue("Test");
    expect(wrapper.vm.name).toBe("Test");
  });
});

ところが、実際にテストファイルを書いてみると、この wrapper.vm.name がタイプエラーになります。

これはVitest/Nuxtに限らない問題で、script setup 内で宣言した変数はそのファイル内でしか参照できないようになっています。

<script setup> を使用したコンポーネントは、デフォルトで閉じられています。つまり、テンプレート参照や $parent チェーンを介して取得されるコンポーネントのパブリックインスタンスは、<script setup> 内で宣言されたバインディングを公開しません
SFC | Vue.js

ただし、実は @vue/test-utils を通したマウントでは全ての変数が exposeされるような改善が既に行われているため、上記のコードでもテストは普通にpassします

参照:feat: expose everything on wrapper.vm to help testing script setup #931

コードでは setValue を使っていますが、 wrapper.vm.name = 'Test' のように直接上書きもできるので、テストそのもので困ることはありません。

TypeScriptのサポートだけが後追いでまだ対応されていない状態で、こちらも issue は既に作成されています。

Better types for wrapper.vm when the component is closed · Issue #972 · vuejs/test-utils

openのまま数ヶ月動きはなさそうですが、直近でも重複するissueがcloseされていることから、忘れられているというわけではなさそうです。

ということで、こちらの進捗を待ちつつ、エラー表示が気になる場合はひとまず expect((wrapper.vm as any).name).toBe("Test")のようにするしかなさそうです。

最後に

いろいろと書きましたが、Vitest × Nuxt3 は悪い体験ではないということは最後に改めて強調しておきます。

この記事に書いたような設定を一通り済ませた上で使うと、テスト実行のあまりの速さに驚かされます。すごく速いというだけなのでさらっと流してしまいましたが、Jestと比べてもかなり良いものだと思います。

vitestもnuxt3もまだ正式リリース前なのでやや不安定ですし、プロダクションへの採用は推奨されていませんが、どちらもVue3のエコシステムの中核に近いところに存在し、これからどんどん普及・改善されていくことは確実と言って良いと思います。

今のうちに触ってその良さを体感しつつ、正式リリースに備えてみてはいかがでしょうか。

Discussion

ログインするとコメントできます