Nuxt3 - 単体テスト前編 - セットアップ・コンポーネントをマウントする

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

Nuxt3がリリースされて結構時間が経ちました。その間にも様々な改良が施されて今では成熟したフレームワークになったと言えるかと思います。

ただ、リリースしてしばらくの間はテストに関するドキュメントはほとんどなく手探りの状態でした。それから時が流れ、ふと公式ドキュメントを見ると、現在はテストユーティリティやドキュメントが充実してきました。
ということで、複数回に分けてNuxt3のテストを整理してみたいと思います。

ここではNuxt3.10.0時点(2024-01-30リリース)の情報をベースに、単体テストのみを対象とします。

初回はテストユーティリティのセットアップとテスト対象のコンポーネントのマウントです。

セットアップ

#

Nuxtから公式で提供されているユーティリティライブラリの@nuxt/test-utilsを使います。
現時点では、単体テスト用のテスティングフレームワークはVitestのみをサポートしています。

非ブラウザ環境で動作する単体テストでは通常DOMエミュレータが必要です。ユーティリティライブラリではhappy-domjsdomをサポートしています。
ここではhappy-domを使ってセットアップします。

npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom

なお、一緒にインストールしている@vue/test-utilsはNuxtではなく純粋なVue向けの単体テストユーティリティです。

NuxtのユーティリティライブラリはNuxtモジュールを提供していますのでnuxt.config.tsに登録します。

nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxt/test-utils/module' // Nuxtモジュールの登録
  ],
  typescript: {
    tsConfig: {
      compilerOptions: {
        types: ["vitest/globals"] // globalsのTypeScriptサポート
      }
    }
  }
})

このNuxtモジュールのソースコード[1]を見てみると、モック用マクロの登録・ルートコンポーネントのスタブ等のViteプラグインが含まれているようです。

次に、プロジェクトルート直下にVitestの設定ファイルvitest.config.tsを作成します。

vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    globals: true // describeやtest/expect等をimportなしで使う
  }
})

ポイントはVitest本体が提供するdefineConfigではなく、Nuxtユーティリティライブラリが提供するdefineVitestConfigを使っている部分です。
ここでNuxtに対応したVitest設定を構築しています。

また、ユーティリティライブラリはVitestのカスタム環境を提供し、ここでNuxt固有のブラウザ環境($fetch等)をセットアップしています[2]
上記ではenvironment: nuxtと指定し、全てのテストに対して有効にしています。
これを指定しない場合、Nuxt環境での単体テストを実行するには個別に以下のいずれかの対応が必要です。

  • テストファイル名のサフィックスを.nuxt.(spec|test).tsとする
  • // @vitest-environment nuxtコメントをテストファイル先頭に付与する
DOMエミューレータにjsdom使う

ここではデフォルトのhappy-domを使っていますが、jsdomを使う場合はvitest.config.tsを以下のようにします。

vitest.config.ts
export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    globals: true,
+    environmentOptions: {
+      nuxt: {
+        domEnvironment: "jsdom"
+      }
+    }
  }
})

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

#

それではテストを書いてみます。まずはシンプルにテストコードからコンポーネントをマウントします。
以下のコンポーネントをテスト対象とします。

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

<template>
  <button @click="counter++">Count Up!!</button>
  <div data-testid="counter">{{ counter }}</div>
  <div data-testid="nuxt-version">{{ nuxtApp.versions.nuxt }}</div>
</template>

ボタンクリックでカウンタを増やしていくだけのシンプルなコンポーネントです。
これに対する単体テストは以下のように書けます。

import { mount, type VueWrapper } from '@vue/test-utils';
import Sample from '~/components/Sample.vue';

describe('Sample Component', () => {
  let wrapper: VueWrapper;
  afterEach(() => {
    wrapper?.unmount();
  });
  test('1回クリックするごとに値が増えていくこと', async () => {
    const wrapper = mount(Sample);
    await wrapper.get('button').trigger('click');
    expect(wrapper.get('[data-testid="counter"]').text()).toBe('1');
    await wrapper.get('button').trigger('click');
    expect(wrapper.get('[data-testid="counter"]').text()).toBe('2');
  });
  test('Nuxtバージョンが正く表示されていること', async () => {
    const wrapper = mount(Sample);
    expect(wrapper.get('[data-testid="nuxt-version"]').text()).toBe('3.10.0');
  });
});

今までと変わらないシンプルなものですね。このテストは成功します。
ここでコンポーネントのマウントにはVue Test Utilsが提供するmountを使用しています。

2つ目のテストは、Nuxtアプリのバージョン表示を検証しています。
このテストが成功するのは、Nuxtテストユーティティがテスト実行前にNuxtアプリ(NuxtApp)を初期化[3]しているからです。
全て試した訳ではありませんが、Nuxtが提供するComposable等のAPIの大半はモック/スタブ化しなくても単体テストで使えるようです(テストの内容次第ではモック化したいケースも多いとは思いますが)。

非同期のコンポーネントをマウントする

#

テスト対象のコンポーネントでscript setupが非同期の場合はどうでしょうか?
Nuxt3が提供する非同期APIuseFetchを使ってコンテンツを取得するケースを考えてみます。

<script setup lang="ts">
  const counter = ref(0);
  const nuxtApp = useNuxtApp();
  // 非同期処理
  const { data } = await useFetch('/api/foo');
</script>

// 省略(先ほどと同じ)

このコンポーネントでは、先ほどのテストは失敗してしまいます。

コンポーネントがレンダリングされる前にテストが実行されているようです。Vue Test Utilsが提供するflushPromiseを実行しても解決しません。
Vue Test Utilsのドキュメントを見ると、このような非同期setupの場合はVue組み込みのSuspenseコンポーネントでラップする必要があると記載されています。

ドキュメントの通りに修正すると以下のようになります(修正方法は同じなので1つ目のケースのみ掲載します)。

test('1回クリックするごとに値が増えていくこと', async () => {
  const TestComponent = defineComponent({
    components: { Sample },
    template: '<Suspense><Sample /></Suspense>'
  });

  const wrapper = mount(TestComponent);
  await flushPromises();
  await wrapper.get('button').trigger('click');
  expect(wrapper.get('[data-testid="counter"]').text()).toBe('1');
  await wrapper.get('button').trigger('click');
  expect(wrapper.get('[data-testid="counter"]').text()).toBe('2');
});

これでテストは成功しますが、なんだか分かりにくいですね。
Nuxtのテストユーティリティでは、このようなケースで使えるmountSuspended(Testing Libraryを使う場合はrenderSuspended)を提供しています[4]

これを利用すると、テストは以下のようになります。

test('1回クリックするごとに値が増えていくこと', async () => {
  const wrapper = await mountSuspended(Sample);
  
  await wrapper.get('button').trigger('click');
  expect(wrapper.get('[data-testid="counter"]').text()).toBe("1")
  await wrapper.get('button').trigger('click');
  expect(wrapper.get('[data-testid="counter"]').text()).toBe("2")
})

前述のコードと比較してシンプルになりましたね。mountSuspended内部ではSuspenseコンポーネントでテスト対象コンポーネントをラップしています。
また、同期関数のmountと違い、mountSuspendedは非同期関数ですのできっちりとawaitする必要があります。
ほとんどのケースではVue Test Utilsのmountでも問題はないですが、Nuxtの単体テストでは一律このmountSuspendedを使った方が混乱なくテストが書けそうですね[5]

終わりに

#

今回は、Nuxt3が提供するテストユーティリティのセットアップ方法とテストコンポーネントのマウント方法についてご紹介しました。
テストユーティリティを使うと、面倒なセットアップが不要でNuxt環境での単体テストが簡単に記述できることが分かります。

次回は単体テストでのモックについて掘り下げていきたいと思います。


  1. ソースコード(Nuxtモジュール): https://github.com/nuxt/test-utils/blob/main/src/module.ts ↩︎

  2. ソースコード(Vitestカスタム環境): https://github.com/nuxt/test-utils/blob/main/src/environments/vitest/index.ts ↩︎

  3. ソースコード(Vitestのセットアップスクリプト): https://github.com/nuxt/test-utils/blob/main/src/runtime/entry.ts ↩︎

  4. ソースコード(mountSuspended): https://github.com/nuxt/test-utils/blob/main/src/runtime-utils/mount.ts ↩︎

  5. 内部でVue Routerを使っていたりしますので、useRouterをモックにする場合は注意が必要です。 ↩︎

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

recruit

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