📚

@nuxt/test-utilsとその機能

2024/07/30に公開

イントロダクション

Nuxt3でテスト環境を作る公式Docsでは、以下のようにライブラリをインストールしています。

npmの場合
npm i --save-dev @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core

それぞれ、

  • vitest:Vitestというテストフレームワーク
  • @vue/test-utils: Vue Test Utilsという、vue.jsでテストをするときに使えるライブラリ 例としてmountでコンポーネントを簡単にマウントして、ユニットテストができるようになる。
  • happy-dom: GUIなしのjs実装でjsdomの数倍処理が速い
  • playwright-core(任意):playwrightをテストランナーとして利用せずに、Playwrightの基本的な機能(page.clickなど)を使う場合に入れる

となっています。

・・・@nuxt/test-utilsは?

Nuxtでテストをする上で便利な機能を提供してくれるものですが、VitestやVue Test Utilsのように体系化されたドキュメントがなく、最初に紹介したDocsの中盤以降に埋もれてしまっていたので、本記事ではそれらの機能について紹介していこうと思います。

Nuxt環境でテストを走らせる

/testsディレクトリに普通はtest.tsという形でファイルを作りますが、nuxt.test.tsとすることによってNuxt環境でテストを走らせることができます。

example.nuxt.test.ts
import { test,expect,it,describe } from 'vitest'

describe('my test', () => {
  it('should work', () => {
    expect(1 + 1).toBe(2)
  });
});

example.test.tsと実行の結果を比較してみると、確かにnuxt.test.tsの方が、Nuxt環境を立ち上げてからテストを走らせるため実行時間がかかっているのがわかります。

 ✓ tests/example.test.ts (1)
   ✓ my test (1)
     ✓ should work

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:17:24
   Duration  11ms
<Suspense> is an experimental feature and its API will likely change.

 ✓ tests/example.nuxt.test.ts (1)
   ✓ my test (1)
     ✓ should work

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:18:10
   Duration  99ms

Nuxt環境でないと、以下のような簡単なテストでもReference Errorが発生してしまうため、vitest.config.tsを編集することでファイル名に関わらず全てのテストをnuxt環境で走らせることができます。

example.test.ts
import { test,expect,it,describe } from 'vitest'
import { mount } from '@vue/test-utils'
import TestComponent from '@/components/Test.vue'

describe('component test', () => {
  it('should work', () => {
    const wrapper = mount(TestComponent)
    expect(wrapper.text()).toBe('This is a test component.')
  });
});
vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    environmentOptions: {
      nuxt: {
        domEnvironment: 'happy-dom',
      },
    },
  }
})

ビルドインのモック

現在Nuxt3では交差オブザーバー API(デフォルトでtrue)IndexedDB API(デフォルトでfalse)のモックがサポートされています。

ヘルパー

@nuxt/test-utilsには5つのNuxt3向けのヘルパーが用意されています。

mountSuspended

Vue Test Utilsではmountを使ってコンポーネントをレンダリングしています。mountSuspended+非同期関数に置き換えることで、Vue3のライフサイクルでいうinitial render以降の処理を待ってからmountするようになります。

Test.vue
<script setup>
const text = ref()
const beforeText = ref()

onBeforeMount(() => {
  beforeText.value = 'Before'
})

onMounted(() => {
  text.value = 'Hello, World!'
})
</script>

<template>
  <div>
    <p>This is a test component.</p>
    <p>{{ beforeText }}</p>
    <p>{{ text }}</p>
  </div>
</template>
example.test.ts
import { test,expect,it,describe } from 'vitest'
import { mount } from '@vue/test-utils'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import TestComponent from '@/components/Test.vue'

describe('component test', () => {
  it('should work', () => {
    const wrapper = mount(TestComponent)
    console.log(wrapper.html())
    expect(wrapper.text()).include('Hello, World!') //エラー
  });
});

describe('component suspended test', () => {
  it('should work', async () => {
    const wrapper = await mountSuspended(TestComponent)
    console.log(wrapper.html())
    expect(wrapper.text()).include('Hello, World!') //成功する
  });
});
console.logの結果
stdout | tests/example.test.ts > component test > should work
<div>
  <p>This is a test component.</p>
  <p>Before</p>
  <p></p> //onMountedの処理が実行されていない
</div>

stdout | tests/example.test.ts > component suspended test > should work
<div>
  <p>This is a test component.</p>
  <p>Before</p>
  <p>Hello, World!</p>
</div>

onMounted以降のライフサイクルで処理を待つ必要があるコンポーネントのテストをする際はmountSuspendedが必要です。

renderSuspended

Testing Libraryのオプションを使いたい場合はこちらを使います。Tesing Libraryをインストールしていない状態だとError: Could not resolve "@testing-library/vue" imported by "@nuxt/test-utils". Is it installed?エラーが出てきます。
ソースコード上でのimportに失敗しているからです。

render.ts
import { Suspense, effectScope, h, nextTick, isReadonly, unref } from 'vue'
import type { DefineComponent, SetupContext } from 'vue'
// ↓これ
import type { RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue'
import { defu } from 'defu'
import type { RouteLocationRaw } from 'vue-router'

import { RouterLink } from './components/RouterLink'

import NuxtRoot from '#build/root-component.mjs'
import { tryUseNuxtApp, useRouter } from '#imports'

// この部分がTestingLibraryのものになっている。mountはComponentMountingOptionsというvue-test-utilsのものを使っている
export type RenderOptions<C = unknown> = TestingLibraryRenderOptions<C> & {
  route?: RouteLocationRaw
}

mockNuxtImport

useFetchuseRouteといったNuxt3のcomposablesをモックするのに使います。

  • useFetchをモックした例
Test.vue
<script setup>
import ImportComponent from './ImportComponent.vue';

const response = await useFetch('https://api.warframestat.us/pc/archonHunt')

const text = ref()
const beforeText = ref()

onBeforeMount(() => {
  beforeText.value = 'Before'
})

onMounted(() => {
  text.value = 'Hello, World!'
  console.log(response.data.value)
})
</script>


ゲームのデータが返ってきている

example.test.ts
import { test,expect,it,describe } from 'vitest'
import { mount } from '@vue/test-utils'
import { mountSuspended,mockNuxtImport } from '@nuxt/test-utils/runtime'
import TestComponent from '@/components/Test.vue'

mockNuxtImport('useFetch', () => {
  return () => {
    return {
      data: {
        value: 'useFetch has been mocked'
      }
    }
  }
})

describe('component suspended test', () => {
  it('should work', async () => {
    const wrapper = await mountSuspended(TestComponent)
    expect(wrapper.text()).include('Hello, World!')
  });
});
テスト結果
stdout | tests/example.test.ts > component suspended test > should work
useFetch has been mocked

 ✓ tests/example.test.ts (2)
   ✓ component suspended test (1)
     ✓ should work
   ✓ my test (1)
     ✓ should work

console.log(response.data.value)の値がモックされたデータに変わっていることが確認できます。

  • useRouteをモックした例:[slug].vueといったDynamic Routesを用いたテストをすることができます。
[team].vue
<script setup>
const route = useRoute()

// 適当なダミーのURL
const rice = useFetch(`/api/rice/${route.params.team}`)

</script>

<template>
  <div>
    <p>{{ rice.data.value || 'お米はありません!' }}</p>
  </div>
</template>
team.test.ts
import Team from "~/pages/[team].vue";
import { test, expect, it, describe } from "vitest";
import { mountSuspended,mockNuxtImport } from '@nuxt/test-utils/runtime'

const mockData = {
  Giants : 'ササニシキ',
  Tigers : 'あきたこまち'
}

const team = ref()

mockNuxtImport('useFetch', () => {
  return (url) => {
    const id = url.split('/').pop();
    const data = mockData[id] || null;
    return {
      data: ref(data),
    };
  };
});
mockNuxtImport('useRoute', () => {
  return () => {
    return {
      params: {
        team: team.value,
      },
    };
  };
});

describe('useRoute mock test', () => {
  test('お米がある場合', async () => {
    team.value = 'Giants';
    const wrapper = await mountSuspended(Team);
    expect(wrapper.text()).toContain('ササニシキ');
  });
  test('お米がない場合', async () => {
    team.value = 'Dragons';
    const wrapper = await mountSuspended(Team);
    expect(wrapper.text()).toContain('お米はありません!');
  });
});

mockComponent

複雑なコンポーネントや、コンポーネント開発前にテストをしたい時などにモックするのに使います。

user.vue
<script setup>
const props = defineProps({
  id: Number
})
</script>
<template>
  <div>
    <p>This is a user page.</p>
    <Profile :id="props.id" />
    <Schedule :id="props.id" />
  </div>
</template>

Profile,Schedule共にコンポーネントだがまだ未完成でこのようになっています。

Profile.vue
<template>
</template>
user.test.ts
import { test, expect, it, describe } from "vitest";
import User from "~/pages/user.vue";
import { mountSuspended, mockComponent } from "@nuxt/test-utils/runtime";

const mockData = {
  user: [
    {
      id: 1,
      name: "John Doe",
    },
  ],
  schedule: [
    {
      id: 1,
      date: "2021-01-01",
      description: "初詣",
    },
    {
      id: 1,
      date: "2021-12-24",
      description: "クリスマス",
    },
  ],
};

mockComponent("Profile", {
  props: {
    id: Number,
  },
  setup(props) {
    return {
      user: mockData.user.find((u) => u.id === props.id),
    };
  },
  template: `<div>{{ user.name }}</div>`,
});

mockComponent("Schedule", {
  props: {
    id: Number,
  },
  setup(props) {
    return {
      schedule: mockData.schedule.filter((s) => s.id === props.id),
    };
  },
  template: `
    <ul>
      <li v-for="s in schedule" :key="s.id">
        <span>{{ s.date }}</span>
        <span>{{ s.description }}</span>
      </li>
    </ul>
  `,
});

describe("User component test", async () => {
  test("コンポーネントモック", async () => {
    const wrapper = await mountSuspended(User,{
      props: {
        id: 1,
      },
    });
    console.log(wrapper.html());
  });
});

console.log(wrapper.html())の結果
<div>
  <p>This is a user page.</p>
  <div>John Doe</div>
  <ul>
    <li><span>2021-01-01</span><span>初詣</span></li>
    <li><span>2021-12-24</span><span>クリスマス</span></li>
  </ul>
</div>

モックされたデータがレンダリングされていることがわかります。

registerEndpoint

エンドポイントをモックします。fetchをモックするのとは異なり、method毎による挙動の違いや複数のfetchを同テストファイルで扱うときにまとめやすいかと思います。

Endpoint.vue
<script setup>
const { data: batai} = await useFetch('/api/batai')
const { data: komon} = await useFetch('/api/komon')
</script>

<template>
  <div>
    <p>{{ batai.data ?? 'わしを殺せるものがあるか' }}</p>
    <p>{{ komon.data ?? '告文を持っているんだろうな' }}</p>
  </div>
</template>
endpoint.test.ts
import Endpoint from "~/components/Endpoint.vue";
import { test, expect, it, describe } from "vitest";
import { mountSuspended, registerEndpoint } from "@nuxt/test-utils/runtime";

registerEndpoint("/api/batai", () => ({
  data: "ここにいるぞ!",
}));

registerEndpoint("/api/komon", () => ({
  data: "そんなものはない",
}));

describe("Endpoint component test", () => {
  test("エンドポイント", async () => {
    const wrapper = await mountSuspended(Endpoint);
    console.log(wrapper.html());
    expect(wrapper.text()).toContain("ここにいるぞ!");
  });
});
console.logの中身
<div>
  <p>ここにいるぞ!</p>
  <p>そんなものはない</p>
</div>

最後に

NuxtのTestに関するページは、他のテストフレームワークのようにAPIやexampleといった形でDocsが構成されていないため分かりにくいですが、調べてみたところNuxtでテストを書くにあたって簡単に書けるような機能をいくつも搭載していることがわかりました。
この記事によって、自分、そして皆さんのNuxtライフが良くなったら嬉しいです。

Discussion