0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

参考資料

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?