🧪

Vue3でテストを書く:Vue Test Utilsを使った実践的なテスト手法

に公開

Vue3でテストを書く:Vue Test Utilsを使った実践的なテスト手法

Vue3アプリケーションの品質を保つために、テストは欠かせない要素です。この記事では、Vue Test Utilsを使ったVue3コンポーネントのテスト手法について、実践的な例を交えながら詳しく調べました

なぜVue3でテストを書くのか?

Vue3アプリケーションでテストを書く理由は以下の通りです:

  • バグの早期発見: 開発段階で問題を特定し、修正コストを削減
  • リファクタリングの安全性: 既存機能を壊すことなくコードを改善
  • ドキュメントとしての役割: テストコードがコンポーネントの期待動作を明示
  • チーム開発の効率化: 他の開発者が安心してコードを変更できる

テスト環境のセットアップ

まず、必要なパッケージをインストールしましょう。

npm install --save-dev @vue/test-utils vitest jsdom

vite.config.jsでVitestの設定を行います:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true
  }
})

Vue Test Utilsの基本概念

Vue Test Utilsは、Vueコンポーネントをテストするための公式ライブラリです。Vue3のComposition APIとOptions APIの両方に対応しており、コンポーネントの動作を検証するための豊富なAPIを提供します。

1. mount()とshallowMount()の詳細解説

mount() - 完全マウント

mount()は、コンポーネントとそのすべての子コンポーネントを完全にレンダリングします。
実際のDOM環境でコンポーネントがどのように動作するかを最も正確にテストできます。

使用場面:

  • 統合テスト(Integration Test)
  • 子コンポーネントとの相互作用をテストしたい場合
  • 実際のDOM操作を検証したい場合
import { mount } from '@vue/test-utils'
import ParentComponent from '@/components/ParentComponent.vue'

const wrapper = mount(ParentComponent, {
  // グローバル設定
  global: {
    plugins: [router, store], // Vue RouterやPiniaなどのプラグイン
    stubs: {
      // 特定の子コンポーネントのみスタブ化
      'child-component': false
    }
  }
})

shallowMount() - 浅いマウント

shallowMount()は、コンポーネント自体のみをレンダリングし、子コンポーネントはスタブ化(置き換え)します。これにより、テストの実行速度が向上し、テスト対象のコンポーネントに集中できます。

使用場面:

  • 単体テスト(Unit Test)
  • 子コンポーネントの実装に依存しないテスト
  • テストの実行速度を重視する場合
import { shallowMount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'

const wrapper = shallowMount(MyComponent, {
  global: {
    stubs: {
      // 特定の子コンポーネントをカスタムスタブに置き換え
      'child-component': {
        template: '<div class="stub">Child Component Stub</div>'
      }
    }
  }
})

どちらを選ぶべきか?

状況 推奨方法 理由
コンポーネントの単体テスト shallowMount() 子コンポーネントの実装に依存しない
親子コンポーネントの連携テスト mount() 実際の相互作用を検証
フォームの送信テスト mount() 実際のDOM操作を検証
イベントハンドリングのテスト shallowMount() コンポーネント内のロジックに集中

2. Wrapper APIの詳細解説

Wrapperオブジェクトは、マウントされたコンポーネントを操作・検証するための豊富なメソッドを提供します。

要素の検索メソッド

// 単一要素の検索
const button = wrapper.find('button') // CSSセレクタ
const submitBtn = wrapper.find('[data-testid="submit"]') // data-testid属性
const myComponent = wrapper.findComponent(MyComponent) // コンポーネント

// 複数要素の検索
const buttons = wrapper.findAll('button')
const listItems = wrapper.findAll('li')

// 存在確認
if (wrapper.find('.error-message').exists()) {
  // エラーメッセージが表示されている
}

イベント操作メソッド

// 基本的なイベント発火
await wrapper.find('button').trigger('click')
await wrapper.find('input').trigger('input')
await wrapper.find('form').trigger('submit')

// カスタムイベント
await wrapper.find('input').trigger('keydown.enter')
await wrapper.find('div').trigger('mouseover')

// イベントオブジェクトの詳細設定
await wrapper.find('input').trigger('change', {
  target: { value: '新しい値' }
})

フォーム操作メソッド

// 入力値の設定
await wrapper.find('input[type="text"]').setValue('テスト値')
await wrapper.find('textarea').setValue('複数行のテキスト')
await wrapper.find('select').setValue('option-value')

// チェックボックス・ラジオボタン
await wrapper.find('input[type="checkbox"]').setChecked(true)
await wrapper.find('input[type="radio"]').setChecked()

// ファイルアップロード
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
await wrapper.find('input[type="file"]').setValue(file)

データ取得メソッド

// テキストコンテンツの取得
const text = wrapper.text() // すべてのテキスト
const buttonText = wrapper.find('button').text() // 特定要素のテキスト

// HTMLの取得
const html = wrapper.html() // 完全なHTML
const elementHtml = wrapper.find('div').html() // 特定要素のHTML

// 属性の取得
const id = wrapper.find('div').attributes('id')
const classes = wrapper.find('div').classes() // クラス名の配列
const style = wrapper.find('div').attributes('style')

コンポーネント状態の検証

// Propsの検証
expect(wrapper.props('user')).toEqual(mockUser)
expect(wrapper.props('isVisible')).toBe(true)

// 発火されたイベントの検証
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0]).toEqual([formData])
expect(wrapper.emitted('submit')).toHaveLength(1)

// コンポーネントインスタンスへのアクセス
const vm = wrapper.vm
expect(vm.count).toBe(5)
expect(vm.isLoading).toBe(false)

3. テストの種類と使い分け

Vue3アプリケーションでは、以下の3つのテストレベルを適切に使い分けることが重要です。

単体テスト(Unit Test)

個々のコンポーネントや関数の動作を独立してテストします。

// 単体テストの例
describe('Counter Component', () => {
  it('カウント値が正しく初期化されること', () => {
    const wrapper = shallowMount(Counter)
    expect(wrapper.vm.count).toBe(0)
  })

  it('increment関数が正しく動作すること', () => {
    const wrapper = shallowMount(Counter)
    wrapper.vm.increment()
    expect(wrapper.vm.count).toBe(1)
  })
})

統合テスト(Integration Test)

複数のコンポーネントが連携して動作することをテストします。

// 統合テストの例
describe('TodoList Integration', () => {
  it('新しいタスクを追加できること', async () => {
    const wrapper = mount(TodoList)
    
    // フォームに入力
    await wrapper.find('input').setValue('新しいタスク')
    await wrapper.find('form').trigger('submit')
    
    // タスクがリストに追加されることを確認
    expect(wrapper.findAll('li')).toHaveLength(1)
    expect(wrapper.text()).toContain('新しいタスク')
  })
})

E2Eテスト(End-to-End Test)

実際のブラウザ環境でユーザーの操作フロー全体をテストします。

// E2Eテストの例(Playwright使用)
test('ユーザー登録フロー', async ({ page }) => {
  await page.goto('/register')
  await page.fill('input[name="email"]', 'test@example.com')
  await page.fill('input[name="password"]', 'password123')
  await page.click('button[type="submit"]')
  
  await expect(page).toHaveURL('/dashboard')
  await expect(page.locator('.welcome-message')).toBeVisible()
})

実践的なテスト例

1. 基本的なコンポーネントテスト

<!-- Counter.vue -->
<template>
  <div>
    <p>カウント: {{ count }}</p>
    <button @click="increment">増加</button>
    <button @click="decrement">減少</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}

const decrement = () => {
  count.value--
}
</script>
// Counter.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from '@/components/Counter.vue'

describe('Counter', () => {
  it('初期値が0であること', () => {
    const wrapper = mount(Counter)
    expect(wrapper.text()).toContain('カウント: 0')
  })

  it('増加ボタンをクリックするとカウントが1増えること', async () => {
    const wrapper = mount(Counter)
    const incrementButton = wrapper.find('button')
    
    await incrementButton.trigger('click')
    
    expect(wrapper.text()).toContain('カウント: 1')
  })

  it('減少ボタンをクリックするとカウントが1減ること', async () => {
    const wrapper = mount(Counter)
    const buttons = wrapper.findAll('button')
    const decrementButton = buttons[1] // 2番目のボタン
    
    await decrementButton.trigger('click')
    
    expect(wrapper.text()).toContain('カウント: -1')
  })
})

2. Propsのテスト

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <span v-if="user.isActive" class="status active">アクティブ</span>
    <span v-else class="status inactive">非アクティブ</span>
  </div>
</template>

<script setup>
defineProps({
  user: {
    type: Object,
    required: true
  }
})
</script>
// UserCard.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import UserCard from '@/components/UserCard.vue'

describe('UserCard', () => {
  const mockUser = {
    name: '田中太郎',
    email: 'tanaka@example.com',
    isActive: true
  }

  it('ユーザー情報が正しく表示されること', () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    })

    expect(wrapper.find('h3').text()).toBe('田中太郎')
    expect(wrapper.find('p').text()).toBe('tanaka@example.com')
    expect(wrapper.find('.status').text()).toBe('アクティブ')
  })

  it('非アクティブユーザーの場合、ステータスが正しく表示されること', () => {
    const inactiveUser = { ...mockUser, isActive: false }
    const wrapper = mount(UserCard, {
      props: { user: inactiveUser }
    })

    expect(wrapper.find('.status').text()).toBe('非アクティブ')
    expect(wrapper.find('.status').classes()).toContain('inactive')
  })
})

3. イベントのテスト

<!-- TodoItem.vue -->
<template>
  <li class="todo-item">
    <input 
      type="checkbox" 
      :checked="todo.completed"
      @change="toggleComplete"
    />
    <span :class="{ completed: todo.completed }">{{ todo.text }}</span>
    <button @click="deleteTodo">削除</button>
  </li>
</template>

<script setup>
const props = defineProps({
  todo: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['toggle', 'delete'])

const toggleComplete = () => {
  emit('toggle', props.todo.id)
}

const deleteTodo = () => {
  emit('delete', props.todo.id)
}
</script>
// TodoItem.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import TodoItem from '@/components/TodoItem.vue'

describe('TodoItem', () => {
  const mockTodo = {
    id: 1,
    text: 'テストタスク',
    completed: false
  }

  it('チェックボックスをクリックするとtoggleイベントが発火すること', async () => {
    const wrapper = mount(TodoItem, {
      props: { todo: mockTodo }
    })

    const checkbox = wrapper.find('input[type="checkbox"]')
    await checkbox.trigger('change')

    expect(wrapper.emitted('toggle')).toBeTruthy()
    expect(wrapper.emitted('toggle')[0]).toEqual([1])
  })

  it('削除ボタンをクリックするとdeleteイベントが発火すること', async () => {
    const wrapper = mount(TodoItem, {
      props: { todo: mockTodo }
    })

    const deleteButton = wrapper.find('button')
    await deleteButton.trigger('click')

    expect(wrapper.emitted('delete')).toBeTruthy()
    expect(wrapper.emitted('delete')[0]).toEqual([1])
  })

  it('完了済みタスクの場合、completedクラスが適用されること', () => {
    const completedTodo = { ...mockTodo, completed: true }
    const wrapper = mount(TodoItem, {
      props: { todo: completedTodo }
    })

    expect(wrapper.find('span').classes()).toContain('completed')
    expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true)
  })
})

4. 非同期処理のテスト

<!-- UserList.vue -->
<template>
  <div>
    <div v-if="loading">読み込み中...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const users = ref([])
const loading = ref(false)
const error = ref('')

const fetchUsers = async () => {
  loading.value = true
  error.value = ''
  
  try {
    const response = await fetch('/api/users')
    if (!response.ok) throw new Error('ユーザー取得に失敗しました')
    users.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(fetchUsers)
</script>
// UserList.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import UserList from '@/components/UserList.vue'

// fetchをモック化
global.fetch = vi.fn()

describe('UserList', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('ユーザーリストが正しく表示されること', async () => {
    const mockUsers = [
      { id: 1, name: '田中太郎' },
      { id: 2, name: '佐藤花子' }
    ]

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUsers
    })

    const wrapper = mount(UserList)
    
    // 非同期処理の完了を待つ
    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(wrapper.find('ul').exists()).toBe(true)
    expect(wrapper.findAll('li')).toHaveLength(2)
    expect(wrapper.text()).toContain('田中太郎')
    expect(wrapper.text()).toContain('佐藤花子')
  })

  it('エラーが発生した場合、エラーメッセージが表示されること', async () => {
    fetch.mockRejectedValueOnce(new Error('ネットワークエラー'))

    const wrapper = mount(UserList)
    
    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(wrapper.text()).toContain('ネットワークエラー')
    expect(wrapper.find('ul').exists()).toBe(false)
  })
})

5. エラーハンドリングの詳細テスト

Vue3アプリケーションでは、適切なエラーハンドリングが重要です。以下のようなエラーケースをテストします。

バリデーションエラーのテスト

<!-- LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <input 
        v-model="email" 
        type="email" 
        placeholder="メールアドレス"
        :class="{ error: errors.email }"
      />
      <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
    </div>
    
    <div class="form-group">
      <input 
        v-model="password" 
        type="password" 
        placeholder="パスワード"
        :class="{ error: errors.password }"
      />
      <span v-if="errors.password" class="error-message">{{ errors.password }}</span>
    </div>
    
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '送信中...' : 'ログイン' }}
    </button>
  </form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const email = ref('')
const password = ref('')
const isSubmitting = ref(false)
const errors = reactive({})

const validateForm = () => {
  errors.email = ''
  errors.password = ''
  
  if (!email.value) {
    errors.email = 'メールアドレスは必須です'
  } else if (!/\S+@\S+\.\S+/.test(email.value)) {
    errors.email = '有効なメールアドレスを入力してください'
  }
  
  if (!password.value) {
    errors.password = 'パスワードは必須です'
  } else if (password.value.length < 6) {
    errors.password = 'パスワードは6文字以上で入力してください'
  }
  
  return !errors.email && !errors.password
}

const handleSubmit = async () => {
  if (!validateForm()) return
  
  isSubmitting.value = true
  try {
    // ログイン処理
    await login(email.value, password.value)
  } catch (error) {
    errors.general = error.message
  } finally {
    isSubmitting.value = false
  }
}
</script>
// LoginForm.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import LoginForm from '@/components/LoginForm.vue'

describe('LoginForm エラーハンドリング', () => {
  it('空のメールアドレスでバリデーションエラーが表示されること', async () => {
    const wrapper = mount(LoginForm)
    
    const submitButton = wrapper.find('button[type="submit"]')
    await submitButton.trigger('click')
    
    expect(wrapper.find('.error-message').text()).toBe('メールアドレスは必須です')
    expect(wrapper.find('input[type="email"]').classes()).toContain('error')
  })

  it('無効なメールアドレス形式でバリデーションエラーが表示されること', async () => {
    const wrapper = mount(LoginForm)
    
    const emailInput = wrapper.find('input[type="email"]')
    await emailInput.setValue('invalid-email')
    
    const submitButton = wrapper.find('button[type="submit"]')
    await submitButton.trigger('click')
    
    expect(wrapper.find('.error-message').text()).toBe('有効なメールアドレスを入力してください')
  })

  it('短いパスワードでバリデーションエラーが表示されること', async () => {
    const wrapper = mount(LoginForm)
    
    const emailInput = wrapper.find('input[type="email"]')
    const passwordInput = wrapper.find('input[type="password"]')
    
    await emailInput.setValue('test@example.com')
    await passwordInput.setValue('123')
    
    const submitButton = wrapper.find('button[type="submit"]')
    await submitButton.trigger('click')
    
    expect(wrapper.find('.error-message').text()).toBe('パスワードは6文字以上で入力してください')
  })

  it('複数のバリデーションエラーが同時に表示されること', async () => {
    const wrapper = mount(LoginForm)
    
    const submitButton = wrapper.find('button[type="submit"]')
    await submitButton.trigger('click')
    
    const errorMessages = wrapper.findAll('.error-message')
    expect(errorMessages).toHaveLength(2)
    expect(errorMessages[0].text()).toBe('メールアドレスは必須です')
    expect(errorMessages[1].text()).toBe('パスワードは必須です')
  })
})

非同期エラーのテスト

<!-- DataFetcher.vue -->
<template>
  <div>
    <div v-if="loading" class="loading">データを読み込み中...</div>
    <div v-else-if="error" class="error">
      <h3>エラーが発生しました</h3>
      <p>{{ error }}</p>
      <button @click="retry">再試行</button>
    </div>
    <div v-else class="data">
      <h3>データ取得成功</h3>
      <ul>
        <li v-for="item in data" :key="item.id">{{ item.name }}</li>
      </ul>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const data = ref([])
const loading = ref(false)
const error = ref('')

const fetchData = async () => {
  loading.value = true
  error.value = ''
  
  try {
    const response = await fetch('/api/data')
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`)
    }
    data.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

const retry = () => {
  fetchData()
}

onMounted(fetchData)
</script>
// DataFetcher.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import DataFetcher from '@/components/DataFetcher.vue'

global.fetch = vi.fn()

describe('DataFetcher エラーハンドリング', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('HTTPエラー(404)が適切に処理されること', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 404
    })

    const wrapper = mount(DataFetcher)
    
    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(wrapper.find('.error').exists()).toBe(true)
    expect(wrapper.text()).toContain('HTTP Error: 404')
    expect(wrapper.find('.data').exists()).toBe(false)
  })

  it('ネットワークエラーが適切に処理されること', async () => {
    fetch.mockRejectedValueOnce(new Error('ネットワークに接続できません'))

    const wrapper = mount(DataFetcher)
    
    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(wrapper.find('.error').exists()).toBe(true)
    expect(wrapper.text()).toContain('ネットワークに接続できません')
  })

  it('再試行ボタンが正しく動作すること', async () => {
    // 最初のリクエストは失敗
    fetch.mockRejectedValueOnce(new Error('最初のエラー'))
    
    const wrapper = mount(DataFetcher)
    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(wrapper.find('.error').exists()).toBe(true)

    // 再試行ボタンをクリック
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => [{ id: 1, name: 'テストデータ' }]
    })

    const retryButton = wrapper.find('button')
    await retryButton.trigger('click')

    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(wrapper.find('.data').exists()).toBe(true)
    expect(wrapper.text()).toContain('テストデータ')
  })

  it('ローディング状態が正しく表示されること', async () => {
    // 非同期処理を遅延させる
    fetch.mockImplementationOnce(() => 
      new Promise(resolve => setTimeout(() => resolve({
        ok: true,
        json: async () => []
      }), 100))
    )

    const wrapper = mount(DataFetcher)
    
    // ローディング中
    expect(wrapper.find('.loading').exists()).toBe(true)
    expect(wrapper.text()).toContain('データを読み込み中...')

    // 非同期処理完了を待つ
    await new Promise(resolve => setTimeout(resolve, 150))
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.loading').exists()).toBe(false)
  })
})

グローバルエラーハンドリングのテスト

<!-- ErrorBoundary.vue -->
<template>
  <div>
    <slot v-if="!hasError" />
    <div v-else class="error-boundary">
      <h2>予期しないエラーが発生しました</h2>
      <p>{{ errorMessage }}</p>
      <button @click="resetError">リセット</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onErrorCaptured } from 'vue'

const hasError = ref(false)
const errorMessage = ref('')

onErrorCaptured((error, instance, info) => {
  hasError.value = true
  errorMessage.value = error.message
  console.error('Error caught by boundary:', error, info)
  return false // エラーを伝播させない
})

const resetError = () => {
  hasError.value = false
  errorMessage.value = ''
}
</script>
// ErrorBoundary.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import ErrorBoundary from '@/components/ErrorBoundary.vue'

// エラーを投げる子コンポーネント
const ErrorComponent = {
  template: '<div>{{ throwError() }}</div>',
  methods: {
    throwError() {
      throw new Error('テストエラー')
    }
  }
}

describe('ErrorBoundary', () => {
  it('子コンポーネントのエラーをキャッチして表示すること', () => {
    const wrapper = mount(ErrorBoundary, {
      slots: {
        default: ErrorComponent
      }
    })

    expect(wrapper.find('.error-boundary').exists()).toBe(true)
    expect(wrapper.text()).toContain('予期しないエラーが発生しました')
    expect(wrapper.text()).toContain('テストエラー')
  })

  it('リセットボタンでエラー状態をクリアできること', async () => {
    const wrapper = mount(ErrorBoundary, {
      slots: {
        default: ErrorComponent
      }
    })

    expect(wrapper.find('.error-boundary').exists()).toBe(true)

    const resetButton = wrapper.find('button')
    await resetButton.trigger('click')

    expect(wrapper.find('.error-boundary').exists()).toBe(false)
  })
})

パフォーマンステスト

Vue3アプリケーションのパフォーマンスをテストすることは、ユーザーエクスペリエンスの向上に重要です。

1. レンダリングパフォーマンスのテスト

<!-- HeavyList.vue -->
<template>
  <div>
    <button @click="addItems">アイテム追加</button>
    <ul>
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.name }} - {{ item.description }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([])

const addItems = () => {
  const newItems = Array.from({ length: 1000 }, (_, index) => ({
    id: items.value.length + index,
    name: `アイテム ${items.value.length + index}`,
    description: `これは${items.value.length + index}番目のアイテムです`
  }))
  items.value.push(...newItems)
}
</script>
// HeavyList.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import HeavyList from '@/components/HeavyList.vue'

describe('HeavyList パフォーマンステスト', () => {
  it('大量のアイテム追加時のレンダリング時間を測定', async () => {
    const wrapper = mount(HeavyList)
    
    const startTime = performance.now()
    
    const addButton = wrapper.find('button')
    await addButton.trigger('click')
    
    // DOM更新の完了を待つ
    await wrapper.vm.$nextTick()
    
    const endTime = performance.now()
    const renderTime = endTime - startTime
    
    // レンダリング時間が100ms以内であることを確認
    expect(renderTime).toBeLessThan(100)
    expect(wrapper.findAll('.list-item')).toHaveLength(1000)
  })

  it('メモリリークがないことを確認', async () => {
    const wrapper = mount(HeavyList)
    
    // 複数回アイテムを追加
    const addButton = wrapper.find('button')
    for (let i = 0; i < 5; i++) {
      await addButton.trigger('click')
      await wrapper.vm.$nextTick()
    }
    
    // コンポーネントをアンマウント
    wrapper.unmount()
    
    // メモリリークの検出は実際のアプリケーションでは
    // より高度なツール(Chrome DevTools等)を使用
    expect(true).toBe(true) // プレースホルダー
  })
})

2. 非同期処理のパフォーマンステスト

<!-- SearchComponent.vue -->
<template>
  <div>
    <input 
      v-model="searchQuery" 
      @input="debouncedSearch"
      placeholder="検索..."
    />
    <div v-if="isSearching">検索中...</div>
    <ul v-else>
      <li v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])
const isSearching = ref(false)

let searchTimeout = null

const debouncedSearch = () => {
  if (searchTimeout) {
    clearTimeout(searchTimeout)
  }
  
  searchTimeout = setTimeout(async () => {
    if (searchQuery.value.trim()) {
      isSearching.value = true
      const startTime = performance.now()
      
      try {
        const response = await fetch(`/api/search?q=${searchQuery.value}`)
        const results = await response.json()
        searchResults.value = results
      } catch (error) {
        console.error('検索エラー:', error)
      } finally {
        isSearching.value = false
        const endTime = performance.now()
        console.log(`検索時間: ${endTime - startTime}ms`)
      }
    }
  }, 300)
}
</script>
// SearchComponent.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import SearchComponent from '@/components/SearchComponent.vue'

global.fetch = vi.fn()

describe('SearchComponent パフォーマンステスト', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('デバウンス機能が正しく動作すること', async () => {
    const wrapper = mount(SearchComponent)
    const input = wrapper.find('input')
    
    // 複数回の入力
    await input.setValue('a')
    await input.setValue('ab')
    await input.setValue('abc')
    
    // タイマーを進める
    vi.advanceTimersByTime(300)
    
    // fetchが1回だけ呼ばれることを確認
    expect(fetch).toHaveBeenCalledTimes(1)
    expect(fetch).toHaveBeenCalledWith('/api/search?q=abc')
  })

  it('検索レスポンス時間が許容範囲内であること', async () => {
    const mockResults = [
      { id: 1, title: 'テスト結果1' },
      { id: 2, title: 'テスト結果2' }
    ]

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockResults
    })

    const wrapper = mount(SearchComponent)
    const input = wrapper.find('input')
    
    await input.setValue('テスト')
    vi.advanceTimersByTime(300)
    
    await wrapper.vm.$nextTick()
    
    expect(wrapper.find('ul').exists()).toBe(true)
    expect(wrapper.findAll('li')).toHaveLength(2)
  })
})

3. メモリ使用量の監視

// memory-test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import MemoryIntensiveComponent from '@/components/MemoryIntensiveComponent.vue'

describe('メモリ使用量テスト', () => {
  it('大量のデータ処理時のメモリ使用量を監視', async () => {
    const initialMemory = process.memoryUsage()
    
    const wrapper = mount(MemoryIntensiveComponent)
    
    // 大量のデータを処理
    await wrapper.vm.processLargeDataset()
    
    const finalMemory = process.memoryUsage()
    const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed
    
    // メモリ増加量が許容範囲内であることを確認
    expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024) // 50MB以下
    
    wrapper.unmount()
  })
})

デバッグ手法

テストが失敗した際の効率的なデバッグ方法を説明します。

1. テストのデバッグ出力

// デバッグ用のテスト例
describe('デバッグ手法の例', () => {
  it('コンポーネントの状態をデバッグ出力', () => {
    const wrapper = mount(MyComponent)
    
    // コンポーネントの状態を出力
    console.log('Component HTML:', wrapper.html())
    console.log('Component Text:', wrapper.text())
    console.log('Component Props:', wrapper.props())
    console.log('Component Data:', wrapper.vm.$data)
    
    // 特定の要素の状態を確認
    const button = wrapper.find('button')
    console.log('Button exists:', button.exists())
    console.log('Button classes:', button.classes())
    console.log('Button attributes:', button.attributes())
    
    expect(button.exists()).toBe(true)
  })

  it('イベント発火のデバッグ', async () => {
    const wrapper = mount(MyComponent)
    
    const button = wrapper.find('button')
    await button.trigger('click')
    
    // 発火されたイベントを確認
    console.log('Emitted events:', wrapper.emitted())
    console.log('Click events:', wrapper.emitted('click'))
    
    expect(wrapper.emitted('click')).toBeTruthy()
  })
})

2. カスタムマッチャーの作成

// test-utils/custom-matchers.js
import { expect } from 'vitest'

// カスタムマッチャーを定義
expect.extend({
  toHaveValidEmail(received) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    const pass = emailRegex.test(received)
    
    return {
      message: () => 
        `expected ${received} ${pass ? 'not ' : ''}to be a valid email`,
      pass
    }
  },

  toBeVisibleInDOM(received) {
    const pass = received.exists() && received.isVisible()
    
    return {
      message: () => 
        `expected element ${pass ? 'not ' : ''}to be visible in DOM`,
      pass
    }
  }
})

// 使用例
describe('カスタムマッチャーの使用', () => {
  it('メールアドレスの形式を検証', () => {
    const email = 'test@example.com'
    expect(email).toHaveValidEmail()
  })

  it('要素がDOMに表示されていることを確認', () => {
    const wrapper = mount(MyComponent)
    const element = wrapper.find('.visible-element')
    expect(element).toBeVisibleInDOM()
  })
})

3. テストヘルパー関数の作成

// test-utils/helpers.js
import { mount } from '@vue/test-utils'

// よく使用するテストヘルパー関数
export const createWrapper = (component, options = {}) => {
  const defaultOptions = {
    global: {
      stubs: {
        'router-link': true,
        'router-view': true
      }
    }
  }
  
  return mount(component, { ...defaultOptions, ...options })
}

export const waitForAsync = async (wrapper) => {
  await wrapper.vm.$nextTick()
  await new Promise(resolve => setTimeout(resolve, 0))
}

export const mockFetch = (data, options = {}) => {
  const defaultOptions = {
    ok: true,
    status: 200,
    json: async () => data
  }
  
  return vi.fn().mockResolvedValue({ ...defaultOptions, ...options })
}

export const createMockUser = (overrides = {}) => ({
  id: 1,
  name: 'テストユーザー',
  email: 'test@example.com',
  isActive: true,
  ...overrides
})

// 使用例
describe('ヘルパー関数の使用', () => {
  it('ヘルパー関数を使ってテストを簡潔に記述', async () => {
    const mockUser = createMockUser({ name: 'カスタムユーザー' })
    const wrapper = createWrapper(UserComponent, {
      props: { user: mockUser }
    })
    
    await waitForAsync(wrapper)
    
    expect(wrapper.text()).toContain('カスタムユーザー')
  })
})

4. テストの可視化とレポート

// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // テスト結果の詳細なレポート
    reporter: ['verbose', 'html'],
    
    // カバレッジレポート
    coverage: {
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{js,vue}'],
      exclude: ['src/**/*.test.{js,vue}']
    },
    
    // テストの実行時間を表示
    slowTestThreshold: 1000,
    
    // 並列実行の設定
    maxConcurrency: 5,
    maxThreads: 5
  }
})

テストのベストプラクティス

1. テストの構造化

テストは以下の構造で書くことを推奨します:

describe('コンポーネント名', () => {
  describe('機能グループ1', () => {
    it('具体的なテストケース1', () => {
      // テストコード
    })
  })
})

2. AAA パターンの活用

  • Arrange: テストの準備(データの設定、コンポーネントのマウント)
  • Act: テスト対象の動作を実行
  • Assert: 結果の検証
it('ユーザーがログインできること', async () => {
  // Arrange
  const wrapper = mount(LoginForm)
  const emailInput = wrapper.find('input[type="email"]')
  const passwordInput = wrapper.find('input[type="password"]')
  const submitButton = wrapper.find('button[type="submit"]')

  // Act
  await emailInput.setValue('test@example.com')
  await passwordInput.setValue('password123')
  await submitButton.trigger('click')

  // Assert
  expect(wrapper.emitted('login')).toBeTruthy()
})

3. モックの適切な使用

外部依存をモック化して、テストの安定性を保ちます:

// 外部APIのモック
vi.mock('@/api/userService', () => ({
  fetchUser: vi.fn()
}))

// 子コンポーネントのモック
const wrapper = mount(ParentComponent, {
  global: {
    stubs: {
      ChildComponent: true
    }
  }
})

4. テストデータの管理

再利用可能なテストデータを作成します:

// test-utils.js
export const createMockUser = (overrides = {}) => ({
  id: 1,
  name: 'テストユーザー',
  email: 'test@example.com',
  isActive: true,
  ...overrides
})

// テストファイル内で使用
const user = createMockUser({ name: 'カスタムユーザー' })

5. テストの命名規則

// 良い例:何をテストしているかが明確
describe('UserProfile', () => {
  describe('ユーザー情報の表示', () => {
    it('ユーザー名が正しく表示されること', () => {
      // テストコード
    })
    
    it('プロフィール画像が表示されること', () => {
      // テストコード
    })
  })
  
  describe('ユーザー情報の編集', () => {
    it('編集ボタンをクリックすると編集モードになること', () => {
      // テストコード
    })
  })
})

// 悪い例:何をテストしているかが不明確
describe('UserProfile', () => {
  it('test1', () => {
    // テストコード
  })
  
  it('should work', () => {
    // テストコード
  })
})

まとめ

Vue3でのテストは、Vue Test Utilsを使うことで効率的に実装できます。重要なポイントは:

  1. 適切なテスト環境の構築: VitestとVue Test Utilsの組み合わせ
  2. コンポーネントの種類に応じたテスト手法: mountとshallowMountの使い分け
  3. 実践的なテストケースの作成: Props、イベント、非同期処理のテスト
  4. ベストプラクティスの遵守: 構造化、モック化、テストデータ管理

テストを書くことで、Vue3アプリケーションの品質と保守性を大幅に向上させることができます。まずは簡単なコンポーネントから始めて、徐々に複雑なテストケースに挑戦してみてください。

参考資料

GitHubで編集を提案

Discussion