Laravel+Inertia.js+Vitestで `TypeError: Cannot read properties of undefined (reading 'createProvider')` が出た時の対応

Inertia.jsという存在を最近知り、面白いと思ったので、フロントエンドは苦手ではありますが、色々試していました。

Inertia.js - The Modern Monolith

そういった状況で表題のエラーが出たので、調べた備忘録です。
一旦やりたかったことは出来ましたが、正攻法かは分かりません。

2022/11/05:別の対応法を確認出来たので追記

環境

  • Laravel: 9.19
  • @inertiajs/inertia: 0.11.1
  • @inertiajs/inertia-vue3: 0.6.0
  • vue: 3.2.4
  • vite: 3.0.0
  • vitest: 0.24.3

エラー内容

下記のようなファイルを resources/js/Pages 以下に作成します。

// Welcome.vue
<script setup>
  import { onMounted } from "vue";
  import { Head } from "@inertiajs/inertia-vue3";
  onMounted(() => {
    console.log("Welcome Page mounted");
  });
</script>

<template>
  <Head>
    <title>Welcome</title>
  </Head>
  <h1>Welcome Inertia.js</h1>
</template>
// Welcome.test.js
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, test } from 'vitest'
import Welcome from './Welcome.vue'

describe('Screen Display', () => {
  test('Display `Welcome` message', () => {
    const wrapper = mount(Welcome)
    expect(wrapper.text()).toContain('Welcome')
  })
})

この状態で、vitestを実行すると以下のエラーになります。

❯❯ npm run test

> test
> vitest run


 RUN  v0.24.3 /laravel_inertia

 ❯ resources/js/Pages/Welcome.test.js (1)
   ❯ Screen Display (1)
     × Display `Welcome` message

 ❯ Proxy.data node_modules/@inertiajs/inertia-vue3/dist/index.js:1:5942

 ❯ applyOptions node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:3384:34
 ❯ finishComponentSetup node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:7257:9
 ❯ setupStatefulComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:7168:9
 ❯ setupComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:7090:11
 ❯ mountComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5448:13
 ❯ processComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5423:17
 ❯ patch node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5027:21
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  resources/js/Pages/Welcome.test.js > Screen Display > Display `Welcome` message
TypeError: Cannot read properties of undefined (reading 'createProvider')
 ❯ mountChildren node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5210:13
 ❯ processFragment node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5382:13
 ❯ patch node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5020:17
 ❯ ReactiveEffect.componentUpdateFn [as fn] node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5562:21
 ❯ ReactiveEffect.run node_modules/@vue/reactivity/dist/reactivity.cjs.js:191:25
 ❯ instance.update node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5669:56
 ❯ setupRenderEffect node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5683:9
 ❯ mountComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5465:9
 ❯ processComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5423:17
 ❯ patch node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5027:21
 ❯ ReactiveEffect.componentUpdateFn [as fn] node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5562:21
 ❯ ReactiveEffect.run node_modules/@vue/reactivity/dist/reactivity.cjs.js:191:25
 ❯ instance.update node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5669:56
 ❯ setupRenderEffect node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5683:9
 ❯ mountComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5465:9
 ❯ processComponent node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5423:17
 ❯ patch node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5027:21
 ❯ render node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6183:13
 ❯ mount node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:4417:25
 ❯ app.mount node_modules/@vue/runtime-dom/dist/runtime-dom.cjs.js:1523:23
 ❯ Proxy.mount node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js:8002:18
 ❯ resources/js/Pages/Welcome.test.js:8:20

Test Files  1 failed (1)
     Tests  1 failed (1)


⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

  Start at  01:05:07
  Duration  1.74s (transform 592ms, setup 0ms, collect 145ms, tests 13ms)

調査

記事確認

TypeError: Cannot read properties of undefined (reading 'createProvider') というエラーになっているので、とりあえずこちらのエラー文をググってみると、laracastsのページが出ます。
ただ、残念ながら解決はしていない。

https://laracasts.com/discuss/channels/inertia/vitest-inertiajs-errors-when-running-tests-on-pagecomponents

また、inertia-laravelのGitHub issueにも同様の質問が投げられていますが、こちらもまだ回答なし。

Vitest + Inertia.js errors when running tests on Page/Components · Issue #459 · inertiajs/inertia-laravel · GitHub

ソースコード確認

inertiajs/inertiaソースコードcreateProvider を検索してみると、vueやreactのライブラリの各パッケージ用と大元の処理の部分に記載があるようでした。

inertia/head.ts at 66fabda56d2f5e2f645073f619c25cb69b760e91 · inertiajs/inertia · GitHub

この辺りの処理を眺めるとなんとなく、 <title> のタグとかを探したりしていそうです。

vitest動作確認

テストの処理をデバッグしてみます。
console.log を追記してコンポーネントのHTML内容を確認。

// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, test } from 'vitest'
import Welcome from './Welcome.vue'

describe('Screen Display', () => {
  test('Display `Welcome` message', () => {
    const wrapper = mount(Welcome)
    console.log(wrapper.html())
    expect(wrapper.text()).toContain('Welcome')
  })
})
stdout | resources/js/Pages/Welcome.test.js > Screen Display > Display `Welcome` message
Welcome Page mounted
<h1>Welcome Inertia.js</h1>

bodyの中身だけ表示されますね。
Welcomeコンポーネントだけ確認しているので、当たり前ではありますが、headの内容が入っていないのはなんか怪しいような気が...?

検証

以下のようにVitestでテストを行いたいファイルと、 Head を読み込むファイルを分割しました。

// Welcome.vue
<script setup>
  import { onMounted } from "vue";
  onMounted(() => {
    console.log("Welcome Page mounted");
  });
</script>

<script>
  import Layout from "./Layout.vue";
  export default {
    layout: Layout,
  }
</script>

<template>
  <h1>Welcome Inertia.js</h1>
</template>
// Lauout.vue
<script>
  import { Head } from "@inertiajs/inertia-vue3";
  export default {
    components: {
      Head,
    }
  }
</script>

<template>
  <Head>
    <title>Welcome</title>
  </Head>
  <main>
    <slot />
  </main>
</template>

結果、vitestが通りました。

❯❯ npm run test

> test
> vitest run


 RUN  v0.24.3 /laravel_inertia

stdout | resources/js/Pages/Welcome.test.js > Screen Display > Display `Welcome` message
Welcome Page mounted

 ✓ resources/js/Pages/Welcome.test.js (1)

Test Files  1 passed (1)
     Tests  1 passed (1)
  Start at  01:17:37
  Duration  1.66s (transform 618ms, setup 0ms, collect 197ms, tests 13ms)

推測

vitestでテストしたいファイルと、 Head でhead要素を書き換える処理を行なっている部分を別ファイルに分割することで、対応は出来ました。
ということで、 Head が処理する内容がvitestで描画して確認する範囲に存在しないため、出ているエラーだったんじゃないかと推測しました。 ただ、これがあっているのかはちょっと分かりません...。
GitHubのissueにはとりあえずこの記事の対応でコメントしておいたので、もし間違っていた場合は詳しい方が指摘していただけることを願います。
やっぱりフロントエンドは苦手ですが、今回は調べていてちょっと楽しかったです。

2022/11/05追記

分離してテストから外すという方法がなんとなく気持ち悪かったので、諦めずに他の方法を探していました。
すると、Next.jsの方で同じような記事を発見。こちらはjestでの記述ですが、ほぼ同じ記述で再現することが出来ました。

next/headを使ったmetadataを React Testing Library でテストする

How to test metadata using jest and react library test · Discussion #11060 · vercel/next.js · GitHub

Reactで書いていますが、要するにHead部分をmock化すれば良いので、Vueでも似たような方法で書けると思います。

// Title.tsx
import React from 'react';
import { Head } from '@inertiajs/inertia-react';

type TitlePrpps = {
  title: string;
};

function Title(props: TitlePrpps) {
  const { title } = props;
  return (
    <Head>
      <title>{title ? `${title} - MyPage` : 'MyPage'}</title>
    </Head>
  );
}

export default Title;
// Title.test.tsx
// @vitest-environment happy-dom
import React from 'react';
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import Title from '@/Components/Layout/Title';

vi.mock('@inertiajs/inertia-react', () => ({
  Head: ({ children }: { children: Array<React.ReactElement> }) => (
    <>{children}</>
  )
}));

describe('Screen Display', () => {
  test('Display `title - MyPage` title', async () => {
    render(<Title title="test" />, {
      container: document.head
    });
    expect(document.head.querySelector('title')?.innerHTML).toEqual(
      'test - MyPage'
    );
  });
});