VitestとVue Test Utilsの導入と、Nuxt2向けにJestで書いたテストはできるだけ、そのまま使いたいので、使えるように直します。PageやComponentは、Options API+JavaScriptとComposition API+TypeScriptが混在する状態です。
Composable(useで始まるやつ)やPluginも追加しているので、そのテストも書きます。

前回:Nuxt BridgeをNuxt3に移行。Vuetifyのデザイン崩れを直す

VitestとVue Test Utilsを導入

Testing · Get Started with Nuxt
-> @nuxt/test-utilsは現段階で、まだ開発中

Unit testing — Vuetify
Getting Started | Guide | Vitest

Getting Started | Vue Test Utils

Vue Test Utils 1 targets Vue 2.
Vue Test Utils 2 targets Vue 3.

なので、Vue Test Utilsはv2を使う事になる。

% yarn add -D vitest @vue/test-utils resize-observer-polyfill \
  @vitest/coverage-v8 happy-dom @vitejs/plugin-vue flush-promises

カバレッジを表示したいので、@vitest/coverage-v8を追加しています。

また、テスト実行時に下記が出るので、@vitejs/plugin-vueも追加しています。

Error: Failed to parse source for import analysis because the content contains
 invalid JS syntax. Install @vitejs/plugin-vue to handle .vue files.

vitest.config.ts

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

export default defineConfig({
  test: {
    globals: true,
    environment: 'happy-dom',
    server: {
      deps: {
        inline: ['vuetify']
      }
    },
    testTimeout: 10000, // NOTE: 5秒(デフォルト)だとタイムアウトする場合がある為
    // hookTimeout: 10000,
    // teardownTimeout: 10000,
    setupFiles: ['./test/setup.ts']
  },
  plugins: [
    Vue({
      template: {
        compilerOptions: {
          // NOTE: Failed to resolve component
          isCustomElement: tag => ['NuxtLayout', 'NuxtPage', 'Head', 'Title', 'Meta'].includes(tag)
        }
      }
    })
  ],
  resolve: {
    alias: {
      '~': path.resolve(__dirname, './')
    }
  }
})

「globals: true」を設定すると、テストファイル毎にvitestのimportが不要になります(.jsの場合)

import { describe, it, expect, vi } from 'vitest'

environmentは、’happy-dom’の方が’jsdom’よりも高速らしいので、変えてみました。

Vuetifyの公式通りに設定すると下記が出るの、deps:をserver:の中に入れています。

Vitest  "deps.inline" is deprecated. If you rely on vite-node directly,
 use "server.deps.inline" instead.
 Otherwise, consider using "deps.optimizer.web.include"

下記対応の為、aliasも追加しています。相対パスに変えるの大変なので。

Error: Failed to resolve import "~/pages/index.vue" from "test/pages/index.spec.js".
 Does the file exist?

tsconfig.json

{
  // https://nuxt.com/docs/guide/concepts/typescript
  "extends": "./.nuxt/tsconfig.json",
+  "compilerOptions": {
+    "types": ["vitest/globals"]
+  }
}

テストファイル毎にvitestのimportが不要になります(.tsの場合)

import { describe, it, expect, vi } from 'vitest'

test/setup.js(nuxt2/Jest) -> test/setup.ts(nuxt3/Vitest)

※Vuetifyの設定を共通化しました。

- import Vue from 'vue'
- import Vuetify from 'vuetify'
- import { config, RouterLinkStub } from '@vue/test-utils'
- import { TestPluginUtils } from '~/plugins/utils.js'
import { config, RouterLinkStub } from '@vue/test-utils'
import { vuetify } from '~/plugins/vuetify'
import helper from '~/test/helper'
import { TestPluginUtils } from '~/plugins/utils'

// NOTE: 他のテストの影響を受けないようにする
afterEach(() => {
  vi.restoreAllMocks()
})

- Vue.use(Vuetify)
// Vuetify
global.ResizeObserver = require('resize-observer-polyfill')
config.global.plugins = [vuetify]

- Vue.use(TestPluginUtils)

- // Mock Config/i18n
- const envConfig = require('~/config/test.js')
- const commonConfig = require('~/config/common.js')
- const locales = require('~/locales/ja.js')
- config.mocks = {
-   $config: Object.assign(envConfig, commonConfig),
-   $t: (key) => {
-     let locale = locales
-     const parts = key.split('.')
-     for (const part of parts) {
-       locale = locale[part]
-     }
-     // eslint-disable-next-line no-throw-literal
-     if (locale == null) { throw `Not found: i18n(${key})` }
-     return locale
-   }
- }
// Mock Config/i18n/utils
config.global.mocks = {
  $config: { public: Object.assign(helper.envConfig, helper.commonConfig, { env: { production: false, test: true } }) },
  $t: (key: string) => {
    let locale: any = helper.locales
    const parts = key.split('.')
    for (const part of parts) {
      locale = locale[part]
      // eslint-disable-next-line no-throw-literal
      if (locale == null) { throw `Not found: i18n(${key})` }
    }
    // eslint-disable-next-line no-throw-literal
    if (typeof locale !== 'string') { throw `Type error: i18n(${key})` }
    return locale
  },
  $tm: (key: string) => {
    let locale: any = helper.locales
    const parts = key.split('.')
    for (const part of parts) {
      locale = locale[part]
      // eslint-disable-next-line no-throw-literal
      if (locale == null) { throw `Not found: i18n(${key})` }
    }
    return locale
  },
  ...TestPluginUtils
}
vi.stubGlobal('useRuntimeConfig', vi.fn(() => config.global.mocks.$config))
vi.stubGlobal('useI18n', vi.fn(() => ({ t: config.global.mocks.$t })))

- // Stub NuxtLink
- config.stubs.NuxtLink = RouterLinkStub
// NOTE: Failed to resolve component: NuxtLink
config.global.stubs.NuxtLink = RouterLinkStub

config.global.pluginsに[vuetify]を入れると、下記が出なくなります。

[Vue warn]: A plugin must either be a function or an object with an "install" function.
[Vue warn]: Failed to resolve component: v-xxx

config.global.mocksで、$configと$tを定義すれば、下記が出なくなります。

TypeError: this.$config is not a function
TypeError: this.$t is not a function

plugins/utils.ts

test/setup.tsから呼び出して、injectが使えるように書き換えます。

TypeError: _ctx.$dateFormat is not a function
export const TestPluginUtils = {
-  install (Vue: any) {
-    Vue.prototype.$sleep = sleep
-    Vue.prototype.$dateFormat = dateFormat
-    Vue.prototype.$textTruncate = textTruncate
-  }
+  $sleep: sleep,
+  $dateFormat: dateFormat,
+  $textTruncate: textTruncate
}

package.json

  "scripts": {

+    "test": "vitest run",
+    "test:coverage": "vitest run --coverage",
+    "test:watch": "vitest watch --coverage"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.3.4",
    "@vitest/coverage-v8": "^0.34.3",
    "@vue/test-utils": "^2.4.1",
    "happy-dom": "^11.0.2",
    "resize-observer-polyfill": "^1.5.1",
    "vitest": "^0.34.4",

Jestの時と同じコマンド「yarn test ファイル名(省略で全て)」で実行できるようにします。
但し、カバレッジは表示できないので「yarn test:coverage」も用意しました。
「yarn test:watch」はテストだけでなく、テスト対象や設定ファイルの変更も監視して、再テストしてくれるので、めちゃ便利!

Nuxt2向けにJestで書いたテストを書き換える

今更ながら、ディレクトリ名が気になったので、リネームしました。
(testsにしている記事もありましたが、何となく違和感が。好みの問題?)
test/page -> test/pages

今までのファイル名は「.spec.js」でしたが、Vitestの公式に習って「.test.ts」にします。
※Vue Test Utilsの公式では「.spec.js」が使われています。

一括変更のコマンド
% find test -type f -name "*.spec.js" | sed 'p;s/.spec.js/.test.ts/' | xargs -n2 mv

ファイル一覧
% find test -type f | sort

test/pages/index.spec.js -> .test.ts

- import Vuetify from 'vuetify'
- import { createLocalVue, mount } from '@vue/test-utils'
+ import { mount } from '@vue/test-utils'
import IndexSignUp from '~/components/index/SignUp.vue'
import IndexInfomations from '~/components/index/Infomations.vue'
import Page from '~/pages/index.vue'

describe('index.vue', () => {
-  const mountFunction = (loggedIn) => {
+  const mountFunction = (loggedIn: boolean) => {
-    const localVue = createLocalVue()
-    const vuetify = new Vuetify()
    const wrapper = mount(Page, {
-      localVue,
-      vuetify,
+      global: {
        stubs: {

        },
        mocks: {

        }
+      }

Vue Test Utilsのバージョンアップで、
stubs:やmocks:はglobal:の中に入れるように変更になっています。
ちなみに「data ()」の位置は変わらず、直下のままです。
「propsData」は「props」に変更されていますが、こちらも直下のままです。

mocks and stubs are now in global -> Migrating from Vue Test Utils v1 | Vue Test Utils
propsData is now props -> Migrating from Vue Test Utils v1 | Vue Test Utils

vi.stubGlobalが効かないコード

Options APIだけの問題だと思いますが、いきなり壁にぶち当たる。

test/pages/index.test.ts

+ import { ref } from 'vue'

describe('index.vue', () => {
  const mountFunction = (loggedIn) => {
+    vi.stubGlobal('useAuthState', vi.fn(() => ({ status: ref(loggedIn ? 'authenticated' : 'unauthenticated') })))

    const wrapper = mount(Page, {
% yarn test test/pages/index.test.ts

 FAIL  test/pages/index.test.ts [ test/pages/index.test.ts ]
ReferenceError: useAuthState is not defined
 ❯ pages/index.vue:27:32
     25| import IndexInfomations from '~/components/index/Infomations.vue'
     26| 
     27| const { status: authStatus } = useAuthState()
       |                                ^

※段々、refが分かってきた気がしました。
Vue3で出てくるrefの使い方(reactiveも) – Amayz-技術ブログ

効くコード

pages/index.vue

- const { status: authStatus } = useAuthState()

export default {
  computed: {
    loggedIn () {
+      const { status: authStatus } = useAuthState()
      return authStatus.value === 'authenticated'
    }
 ✓ test/pages/index.test.ts (2)
   ✓ index.vue (2)
     ✓ [未ログイン]表示される
     ✓ [ログイン中]表示される

 Test Files  1 passed (1)
      Tests  2 passed (2)

但し、このコードにはブラウザ表示時にwarnが出ます。
computedをmethodsに変えても変わらず。v-if=”!loggedIn()”と書くのも少し抵抗がある。
onUnmounted is called when there is no active component instance to be associated with.

テストが書きやすいコードに変える

$authをinjectしていると、mocksで値を定義できるので、状態作るのが簡単です。
Nuxt BridgeをNuxt3に移行。nuxt-authを導入 の時は、直にuseAuthState()を参照した方が遠回りしなくて良いかなと思いましたが、テストの事を考えるとinjectしていた方が良かった。
という事で、pluginsを作成して、一部戻したり、変えたりします。

plugins/auth.ts

pluginで$authを作る を参照してください。

呼び出し元を修正

- const { status: authStatus, data: authData } = useAuthState()
-> 使用するメソッドの中に移動

-    loggedIn () {
-      return authStatus.value === 'authenticated'
-    },
-    authData () {
-      return authData.value
-    },

-        authData.value = data
+        this.$auth.setData(data)

ReferenceError: defineNuxtComponent is not defined

Options APIだけの問題ですが、Headを認識させる為には、defineNuxtComponentが必要なので削除できない。
ヘッダのタイトルが表示されない

 FAIL  test/pages/index.test.ts [ test/pages/index.test.ts ]
ReferenceError: defineNuxtComponent is not defined
 ❯ components/app/Loading.vue:10:19
      6| 
      7| <script>
      8| export default defineNuxtComponent({
       |                   ^

Vitest(Jestも同じ)では、Nuxtが動いている訳ではないので、自動インポートが効かない。

なので、明示的にimportする必要がありますが、

import { defineNuxtComponent } from '#app'

数が多くて面倒なので、↓pluginを導入します。

unplugin-auto-importを導入

GitHub – unplugin/unplugin-auto-import: Auto import APIs on-demand for Vite, Webpack and Rollup

% yarn add -D unplugin-auto-import

vitest.config.ts

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

  plugins: [
    Vue(),
+    AutoImport({
+      imports: [
+        // presets
+        'vue',
+        'vue-router',
+        // custom
+        {
+          '#app': [
+            'defineNuxtComponent',
+            'defineNuxtPlugin'
+          ]
+        }
+      ],
+      dts: false // NOTE: ./auto-imports.d.tsを出力しない
+    })
  ],

defineNuxtPluginは、後で使うので追加しておきます。

これで、PageやComponentに下記を追加する必要がなくなりましたが、

import { defineNuxtComponent } from '#app'

Error: Failed to resolve import “#app”

参照先の#appはNuxtの話なので、エラーになります。

 FAIL  test/pages/index.test.ts [ test/pages/index.test.ts ]
Error: Failed to resolve import "#app" from "components/index/Infomations.vue".
 Does the file exist?

失敗

vitest.config.ts

  resolve: {
    alias: {
      '~': path.resolve(__dirname, './'),
+      '#app': path.resolve(__dirname, 'node_modules/nuxt/dist/app')
 FAIL  test/pages/index.test.ts [ test/pages/index.test.ts ]
TypeError: Package import specifier "#build/app.config.mjs" is not defined
 in package /Users/xxxx/workspace/nuxt-app-origin/node_modules/nuxt/package.json
 imported from /Users/xxxx/workspace/nuxt-app-origin/node_modules/nuxt/dist/app/config.js

これだけでは、Nuxtは動かない。

成功

情報がなくて、手こずりましたが、対応するように実装して解決!
最初、mockを試しましたが、そもそもdefineNuxtComponentの中身は適当な値ではなく、定義されているものを返さないといけない。

TypeError: Cannot read properties of undefined (reading '__vccOpts')
 ❯ Module.__vite_ssr_exports__.default plugin-vue:export-helper:3:22
 ❯ components/app/Loading.vue:34:74
     14|   }
     15| })
     16| </script>
       |          ^

vitest.config.ts

  resolve: {
    alias: {
      '~': path.resolve(__dirname, './'),
+      '#app': path.resolve(__dirname, 'test/nuxt.app.ts')

test/nuxt.app.ts

defineNuxtComponentの中身を返却するように実装します。

export const defineNuxtComponent = (component: object) => { return component }
export const defineNuxtPlugin = (component: object) => { return component }
 ✓ test/pages/index.test.ts (2)
   ✓ index.vue (2)
     ✓ [未ログイン]表示される
     ✓ [ログイン中]表示される

 Test Files  1 passed (1)
      Tests  2 passed (2)

SyntaxError: Unexpected token ‘export’

requireをimportに変更します。

 FAIL  test/pages/users/sign_in.test.ts [ test/pages/users/sign_in.test.ts ]
SyntaxError: Unexpected token 'export'
 ❯ Object.compileFunction node:vm:360:18
 ❯ Helper. test/helper.js:5:15
      3| 
      4| export class Helper {
      5|   envConfig = require('../config/test.ts')
       |               ^

test/pages/users/sign_in.spec.js -> .test.ts

- import { Helper } from '~/test/helper.js'
- const helper = new Helper()
+ import helper from '~/test/helper'

test/helper.js -> .ts

import { RouterLinkStub } from '@vue/test-utils'
+ import { envConfig } from '../config/test'
+ import { commonConfig } from '../config/common'
+ import { ja } from '~/locales/ja'

- export class Helper {
-  envConfig = require('~/config/test.js')
-  commonConfig = require('~/config/common.js')
-  locales = require('~/locales/ja.js')
+ export default {
+   envConfig,
+   commonConfig,
+   locales: ja,

ReferenceError: useNuxtApp is not defined

 FAIL  test/pages/development/color.test.ts > color.vue > 表示される
ReferenceError: useNuxtApp is not defined
 ❯ setup pages/development/color.vue:41:22
     39|   loading.value = false
     40| 
     41|   const { $toast } = useNuxtApp()
       |                      ^

test/pages/development/color.test.ts

Nuxt BridgeをNuxt3に移行。Vuetifyのデザイン崩れを直す のテストを書きました。

vi.stubGlobalで、useNuxtApp()を受け取れるようにします。
mockの使い方はJestの時と変わらない。
「jest.fn()」は「vi.fn()」に、「xit」は「it.skip」に変わっています。

import { mount } from '@vue/test-utils'
import helper from '~/test/helper'
import Page from '~/pages/development/color.vue'

describe('color.vue', () => {
  let mock: any
  beforeEach(() => {
    mock = {
      toast: helper.mockToast
    }
  })

  const mountFunction = () => {
    vi.stubGlobal('useNuxtApp', vi.fn(() => ({ $toast: mock.toast })))

    const wrapper = mount(Page)
    expect(wrapper.vm).toBeTruthy()
    return wrapper
  }

  // テスト内容
  const viewTest = (wrapper: any) => {
    helper.presentTest(wrapper)
    helper.toastMessageTest(mock.toast, { error: 'error', info: 'info', warning: 'warning', success: 'success' })
  }

  // テストケース
  it('表示される', () => {
    const wrapper = mountFunction()
    viewTest(wrapper)
  })
})

test/helper.js -> .ts

- const presentTest = (wrapper, AppLoading = null) => {
const presentTest = (wrapper: any, AppLoading: any = null) => {
  if (AppLoading != null) {
    expect(wrapper.findComponent(AppLoading).exists()).toBe(false)
  }
  expect(wrapper.html()).not.toBe('')
}

- const mockCalledTest = (mock, count, ...args) => {
const mockCalledTest = (mock: any, count: number, ...args: any[]) => {
  expect(mock).toBeCalledTimes(count)
  if (count > 0) {
    expect(mock).nthCalledWith(count, ...args)
  }
}

const mockToast = {
  error: vi.fn(),
  info: vi.fn(),
  warning: vi.fn(),
  success: vi.fn()
}

const toastMessageTest = (toastMock: any, message: any) => {
  mockCalledTest(toastMock.error, message.error == null ? 0 : 1, message.error)
  mockCalledTest(toastMock.info, message.info == null ? 0 : 1, message.info)
  mockCalledTest(toastMock.warning, message.warning == null ? 0 : 1, message.warning)
  mockCalledTest(toastMock.success, message.success == null ? 0 : 1, message.success)
}

export default {
  presentTest,
  mockCalledTest,
  mockToast,
  toastMessageTest
}

型が必要な所はlintが教えてくれますが、
「= null」は推論でnullになるので教えてくれない場合があるので、注意しないと。

‘#app’は自動インポートに任せた方が楽

上記のテストで、’#app’はNuxtで自動インポートできるので、記載しなくても動きますが、あえて記載するとVitestでエラーになります。

pages/development/color.vue

import { useNuxtApp } from '#app'

vitest.config.tsのAutoImportと、test/nuxt.app.tsを実装すれば解決できそうですが、手間が増えるだけですね。
unplugin-auto-importを導入

序でに、’vue’や’vue-router’もAutoImportが解決してくれるようになったので、下記のようなimportを記載しなくて良くなりました。

import { ref } from 'vue'

一方、Component(下記のようなの)も省略可能ですが、Vitest(Jestも同じ)ではエラーになるので、引き続き必要になります。
AutoImportで解決できそうな気もしますが、膨大に記載する事になるのと、メンバーが把握している必要があったり、自動インポートの命名規則だと長すぎたりするので、各ページで定義した方が幸せになれそう。

pages/index.vue

<script>
import IndexSignUp from '~/components/index/SignUp.vue'
import IndexInfomations from '~/components/index/Infomations.vue'

TypeError: Cannot read properties of undefined (reading ‘disabled’)

 FAIL  test/pages/users/sign_up.test.ts > sign_up.vue > [未ログイン]表示される
TypeError: Cannot read properties of undefined (reading 'disabled')
 ❯ Helper.waitChangeDisabled test/helper.ts:85:21
     83|     for (let index = 0; index < 100; index++) {
     84|       await this.sleep(1)
     85|       if (button.vm.disabled === disabled) { break }
       |                     ^

vue.js - Using Vue Test Utils, how can I check if a button is disabled? - Stack Overflow

Vue3(Vue Test Utils v2)では、vmをelementに変えるか、attributes()でundefinedかで判定するように変わっています。

前者(element)の例。lintエラーが出るので、anyを追加します。

-    const button = wrapper.find('#sign_up_btn')
+    const button: any = wrapper.find('#sign_up_btn')
-    expect(button.vm.disabled).toBe(true) // 無効
+    expect(button.element.disabled).toBe(true) // 無効

「.vm.disabled」を「.element.disabled」に置換して、
「= wrapper.find(」で検索して、定義に「: any」を追加していく。

ReferenceError: navigateTo is not defined
AssertionError: expected "spy" to be called 1 times, but got 0 times

 FAIL  test/pages/users/sign_up.test.ts > sign_up.vue > [ログイン中]トップページにリダイレクトされる
ReferenceError: navigateTo is not defined
 ❯ Proxy.appRedirectTop utils/application.js:136:7
    134|     appRedirectTop (data, require = false) {
    135|       this.appSetToastedMessage(data, require, false)
    136|       navigateTo('/')
       |       ^
 FAIL  test/pages/users/sign_up.test.ts > sign_up.vue > [ログイン中]トップページにリダイレクトされる
AssertionError: expected "spy" to be called 1 times, but got 0 times
 ❯ Helper.mockCalledTest test/helper.ts:44:18
     37| 
     38| const mockCalledTest = (mock: any, count: number, ...args: (string | object | null)[]) => {
     39|   expect(mock).toBeCalledTimes(count)
       |                ^

test/pages/users/sign_up.test.ts

describe('sign_in.vue', () => {
-  let routerPushMock
+  let mock: any
-
  beforeEach(() => {
-    routerPushMock = vi.fn()
+    mock = {
+      navigateTo: vi.fn()
+    }
  })

  const mountFunction = (loggedIn: boolean, values: object | null = null) => {
+    vi.stubGlobal('navigateTo', mock.navigateTo)
+
    const wrapper = mount(Page, {
      global: {
        mocks: {
-          $router: {
-            push: routerPushMock
-          }

-    helper.mockCalledTest(routerPushMock, 1, '/')
+    helper.mockCalledTest(mock.navigateTo, 1, '/')

Globals -> Mocking | Guide | Vitest

ちなみに、これはどっちでもOK

      navigateTo: vi.fn()
      navigateTo: vi.fn(() => {})

TypeError: (intermediate value) is not iterable

TypeError: (intermediate value) is not iterable
 ❯ Proxy.postSingUp pages/users/sign_up.vue:141:32
    139|       this.processing = true
    140| 
    141|       const [response, data] = await useApiRequest(this.$config.public.apiBaseURL + this.$config.public.singUpUrl, 'POST', {
       |                                ^

axiosから作成したuseApiRequestに変更した為、レスポンスが変わっています。
実装に合わせて、配列でオブジェクトを2つ返却するように変更します。

test/pages/users/sign_up.test.ts

-      axiosPostMock = jest.fn(() => Promise.resolve({ data }))
+      mock.useApiRequest = vi.fn(() => [{ ok: true, status: 200 }, data])

-      axiosPostMock = jest.fn(() => Promise.resolve({ data: null }))
+      mock.useApiRequest = vi.fn(() => [{ ok: true, status: 200 }, null])

-      axiosPostMock = jest.fn(() => Promise.reject({ response: null }))
+      mock.useApiRequest = vi.fn(() => [{ ok: false, status: null }, null])

-      axiosPostMock = jest.fn(() => Promise.reject({ response: { status: 500 } }))
+      mock.useApiRequest = vi.fn(() => [{ ok: false, status: 500 }, null])

-      axiosPostMock = jest.fn(() => Promise.reject({ response: { status: 422, data: Object.assign({ errors: { email: ['errorメッセージ'] } }, data) } }))
+      mock.useApiRequest = vi.fn(() => [{ ok: false, status: 422 }, Object.assign({ errors: { email: ['errorメッセージ'] } }, data)])

-      axiosPostMock = jest.fn(() => Promise.reject({ response: { status: 400, data: {} } }))
+      mock.useApiRequest = vi.fn(() => [{ ok: false, status: 400 }, {}])

AssertionError: expected true to be false(バグ)

テストはパスするものの、下記が出ましたが、バグでした。(テスト大事ですね!)

AssertionError: expected true to be false // Object.is equality

- Expected
+ Received

- false
+ true

 ❯ Helper.disabledTest test/helper.ts:76:59
     69| 
     70| const disabledTest = async (wrapper: any, AppProcessing: any, button: any, disabled: boolean) => {
     71|   expect(wrapper.findComponent(AppProcessing).exists()).toBe(false)
       |                                                         ^

returnで抜けてしまい、processingがfalseに戻らなかった為でした。

pages/users/sign_up.vue

      if (response?.ok) {
-        if (!this.appCheckResponse(data, { toasted: true })) { return }
-
+        if (this.appCheckResponse(data, { toasted: true })) {
          <成功時の処理>
+        }
-      } else {
-        if (!this.appCheckErrorResponse(response?.status, data, { toasted: true })) { return }
+      } else if (this.appCheckErrorResponse(response?.status, data, { toasted: true })) {

        <失敗時の処理>
      }

      this.processing = false

AssertionError: expected '<!--v-if-->\n<!--v-if-->' to be '' // Object.is equality

v-ifがfalseの場合、「<!--v-if-->」が返るように変更されたようです。

 FAIL  test/components/app/Message.test.ts > Message.vue > [alertなし/noticeなし]表示されない
AssertionError: expected ''<!--v-if-->\n<!--v-if-->' to be '' // Object.is equality

- Expected
+ Received

+ <!--v-if-->
+ <!--v-if-->

 ❯ Helper.blankTest test/helper.ts:33:28
     26|     expect(wrapper.findComponent(AppLoading).exists()).toBe(false)
     27|   }
     28|   expect(wrapper.html()).toBe('')
       |                          ^

削除して比較するように変更で対応します。

test/helper.js -> .ts

-  expect(wrapper.html()).toBe('')
+  expect(wrapper.html({ raw: true }).replaceAll('', '')).toBe('')

-  expect(wrapper.html()).not.toBe('')
+  expect(wrapper.html({ raw: true }).replaceAll('', '')).not.toBe('')

TypeError: Cannot read properties of undefined (reading '$emit')

vm.$emitがなくなった?ようで、vmもundefinedになります。他の所も見つからず。

 FAIL  test/pages/infomations/index.test.ts > index.vue > お知らせ一覧取得 > [ページネーション]表示される
TypeError: Cannot read properties of undefined (reading '$emit')
 ❯ test/pages/infomations/index.test.ts:156:36
    154| 
    155|       // ページネーション(2頁目)
    156|       wrapper.find('#pagination1').vm.$emit('input', 2)
       |                                    ^

input(v-text-fieldやv-file-input)、select(v-select)なら、setValueで設定できますが、
setValue -> API Reference | Vue Test Utils

test/pages/users/confirmation/resend.test.ts

-      wrapper.vm.$data.query = { email: 'user1@example.com' }
+      wrapper.find('#input_email').setValue('user1@example.com')

v-paginationは無理でした。
値をセットして、クリックはできるので、そちらで対応する事にします。

test/pages/infomations/index.test.ts

-      wrapper.find('#pagination1').vm.$emit('input', 2)
+      wrapper.vm.$data.page = 2
+      wrapper.find('#pagination1').trigger('click')

v-dialogを表示後の要素が見つからない(Teleport)

ボタンをクリックして、v-dialogの表示をテストしている箇所です。

 FAIL  test/pages/users/delete.test.ts > delete.vue > [ログイン中]表示される
AssertionError: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ test/pages/users/delete.test.ts:89:29
     87|     // 確認ダイアログ
     88|     const dialog: any = wrapper.find('#user_delete_dialog')
     89|     expect(dialog.exists()).toBe(true)
       |                             ^
 FAIL  test/pages/users/delete.test.ts > delete.vue > [ログイン中]表示される
Error: Cannot call isVisible on an empty DOMWrapper.
 ❯ Object.get node_modules/@vue/test-utils/dist/vue-test-utils.esm-bundler.mjs:1522:27
 ❯ test/pages/users/delete.test.ts:90:19
     88|     const dialog: any = wrapper.find('#user_delete_dialog')
     89|     // expect(dialog.exists()).toBe(true)
     90|     expect(dialog.isVisible()).toBe(true) // 表示
       |                   ^

この段階のhtmlを確認してみます。

+    console.log(wrapper.html())
    expect(dialog.exists()).toBe(true)
    <!--teleport start-->
    <!--teleport end-->

ここに記載されているのと同じ出力になりました。
Testing Teleport | Vue Test Utils

Vue3の新機能のようです。
Teleport | Vue.js

<Teleport> は、コンポーネントにあるテンプレートの一部を、そのコンポーネントの
 DOM 階層の外側に存在する DOM ノードに「テレポート」できる組み込みコンポーネントです。

attachを付ければ解決します。
ただ、ページ全体がバックグラウンドになって選択できなかったのが、ヘッダーやサイドバーを除く部分だけに変わってしまいました。なので、testの時だけtrueになるようにします。

pages/users/delete.vue

-      <v-dialog transition="dialog-top-transition" max-width="600px">
+      <v-dialog transition="dialog-top-transition" max-width="600px" :attach="$config.public.env.test">

attach -> VDialog API — Vuetify

表示後のisVisibleがfalseにならない

 FAIL  test/pages/users/delete.test.ts > delete.vue > [ログイン中]表示される
AssertionError: expected true to be false // Object.is equality

- Expected
+ Received

- false
+ true

 ❯ test/pages/users/delete.test.ts:105:32
    103| 
    104|     // 確認ダイアログ
    105|     expect(dialog.isVisible()).toBe(false) // 非表示
       |                                ^

表示後は、disabledのCSSで制御するように挙動が変わっています。

-    expect(dialog.isVisible()).toBe(false) // 非表示
+    expect(dialog.isDisabled()).toBe(false) // 非表示

v-btn等にnuxtを付けてもNuxtLinkにならない

components/index/SignUp.vue

      <v-btn color="primary" to="/users/sign_up" nuxt>
        <v-icon>mdi-account-plus</v-icon>
        <span class="ml-1">無料で始める</span>
      </v-btn>

Vuetify2では、propsにnuxtがありましたが、Vuetify3ではなくなっています。v-btn以外も同様。
【Vuetify2】Props -> v-btn API — Vuetify
【Vuetify3】Props -> VBtn API — Vuetify

単に「nuxt=""」が追加されるだけで、意味はないので、全体的に削除。

<a class="・・・" href="/users/sign_up" nuxt="">

ただ、これだとNuxtLinkが存在しているかだけをテストしている場合に困ります。

 FAIL  test/components/index/SignUp.test.ts > SignUp.vue > 表示される
AssertionError: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ viewTest test/components/index/SignUp.test.ts:15:46
     13|   const viewTest = (wrapper: any) => {
     14|     const links = helper.getLinks(wrapper)
     15|     expect(links.includes('/users/sign_up')).toBe(true) // アカウント登録
       |                                              ^

aタグのelementやattributes()の中も見ましたが見つからず。(もっと良い方法があるのかな?)

    const aTags = wrapper.findAll('a')
    for (let index = 0; index < aTags.length; index++) {
      console.log(aTags[index].element)
      console.log(aTags[index].attributes())
    }
<ref *2> HTMLAnchorElement {
  _listeners: {
<省略>
  }
}
{
  'data-v-433a9abd': '',
  class: 'v-btn v-theme--light v-btn--density-default v-btn--rounded v-btn--size-default v-btn--variant-text',
  nuxt: ''
}

解決策1

NuxtLinkで囲ってあげれば存在するようになります。
単純なボタンならこれでOKなのですが、
v-app-bar内だったり、v-list-itemの場合はデザインが崩れてしまいます。

components/index/SignUp.vue

+      <NuxtLink to="/users/sign_up">
-        <v-btn color="primary" to="/users/sign_up" nuxt>
+        <v-btn color="primary">
          <v-icon>mdi-account-plus</v-icon>
          <span class="ml-1">無料で始める</span>
        </v-btn>
+      </NuxtLink>

解決策2

ベストな方法ではないけど、デザインが崩れる所だけ、
v-btnやv-list-itemをtestの時だけ、NuxtLinkに変更する事にしました。

layouts/default.vue

-        <v-btn to="/users/sign_in" text rounded nuxt>
+        <component :is="$config.public.env.test ? 'NuxtLink' : 'v-btn'" to="/users/sign_in" rounded>
          <v-icon>mdi-login</v-icon>
          <div class="hidden-sm-and-down">ログイン</div>
-        </v-btn>
+        </component>

-            <v-list-item to="/users/update" nuxt rounded="xl">
+            <component :is="$config.public.env.test ? 'NuxtLink' : 'v-list-item'" to="/users/update" rounded="xl">
              <v-list-item-title>
                <v-icon>mdi-account-edit</v-icon>
                ユーザー情報
              </v-list-item-title>
-            </v-list-item>
+            </component>

行単位でカバレッジの対象から外す

カバレッジ出力と確認。ブラウザで表示すれば通っていない行を確認できます。
Jest(ìstanbulかな?)の時は、vueファイルは100%になってしまっていたけど、今回のは細かく出るので良い!

% yarn test:coverage
  (= vitest run --coverage)
% open coverage/index.html

v8(c8)とìstanbulがあり、デフォルトだとv8(@vitest/coverage-v8)を使っている。
カバレッジの対象から外すコメントがistanbulの時と変わっているので、変更します。
Ignoring code -> Coverage | Guide | Vitest
Ignoring Uncovered Lines, Functions, and Blocks -> c8/README.md at main · bcoe/c8 · GitHub

1行の場合

pages/development/color.vue

if (process.env.NODE_ENV === 'production') {
+  /* c8 ignore next */
  showError({ statusCode: 404 })
} else {

複数行の場合

nextの後に無視する行数を指定する方法もありますが、行の増減を意識する必要があるので、start/stopを使うのが良さそう。

layouts/default.vue

-  /* istanbul ignore next */
+  /* c8 ignore start */
  head () {
    const { t: $t } = useI18n()
    const $config = useRuntimeConfig()
    return {
      titleTemplate: `%s - ${$t('app_name')}${$config.public.envName}`
    }
  },
+  /* c8 ignore stop */

最後に、カバレジやファイルを確認してテストを追加

カバレジを確認して、Statements(=Lines)が100%でないテストを追加していきます。
Functionsは全ては対応できないので、@clickとか無理のない範囲で対応するぐらいで良さそう。

% yarn test:coverage
 or
% yarn test:watch

% open coverage/index.html

ここには追加したファイル(今回はComposableやPlugin等)は出てこないので、ファイル名を比較して追加していきます。
追加したテストはコミットを参照してください。

追加 -> test/app.test.ts
test/layouts/error.spec.js -> test/error.test.ts

参考までに、mock/stubあれこれ。

test/pages/spaces/index.test.ts
        const beforeLocation = window.location
        const mockReload = vi.fn()
        Object.defineProperty(window, 'location', { value: { reload: mockReload } })

        Object.defineProperty(window, 'location', { value: beforeLocation })

test/pages/downloads.test.ts
      const beforeCreateObjectURL = URL.createObjectURL
      URL.createObjectURL = vi.fn(() => helper.commonConfig.downloads.fileUrl)

      URL.createObjectURL = beforeCreateObjectURL

test/components/invitations/Lists.test.ts
        const mockWriteText = vi.fn(() => Promise.resolve())
        Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: mockWriteText } })

test/components/app/BackToTop.test.ts
    Object.defineProperty(window, 'scrollY', { value: 200 })

    const mockScrollTo = vi.fn()
    Object.defineProperty(window, 'scrollTo', { value: mockScrollTo })

test/composables/apiRequest.test.ts
    const reqToken = Object.freeze({ 'token-type': 'Bearer', uid: 'uid1', client: 'client1', 'access-token': 'token1' })
    mock.setItem: vi.fn(),
    vi.stubGlobal('localStorage', { getItem: vi.fn((key: string) => reqToken[key]), setItem: mock.setItem })

    const result = Object.freeze({ ok: true, status: 200 })
    const resToken = Object.freeze({ ...reqToken, 'access-token': 'token2', expiry: '123' })
    const resData = JSON.stringify({ key1: 'value1' })
    mock.fetch = vi.fn(() => ({ ...result, headers: { get: vi.fn((key: string) => resToken[key]) }, json: vi.fn(() => resData) }))
    vi.stubGlobal('fetch', mock.fetch)

test/composables/authRedirect.test.ts
    vi.stubGlobal('useState', vi.fn(() => ref(null)))

test/composables/authSignOut.test.ts
    const authData = ref({ name: 'user1の氏名' })
    vi.stubGlobal('useAuthState', vi.fn(() => ({ data: authData })))

test/pages/infomations/[id].test.ts
    mock.showError: vi.fn()
    vi.stubGlobal('showError', mock.showError)

今回のコミット内容

origin#507 VitestとVue Test Utilsを導入、既存のテストを修正
origin#507 テスト追加、リファクタ
origin#507 nuxt-lodashをlodashに変更、Vuetifyの設定をVitestと共通化、リファクタ

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です