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を使うことで効率的に実装できます。重要なポイントは:
- 適切なテスト環境の構築: VitestとVue Test Utilsの組み合わせ
- コンポーネントの種類に応じたテスト手法: mountとshallowMountの使い分け
- 実践的なテストケースの作成: Props、イベント、非同期処理のテスト
- ベストプラクティスの遵守: 構造化、モック化、テストデータ管理
テストを書くことで、Vue3アプリケーションの品質と保守性を大幅に向上させることができます。まずは簡単なコンポーネントから始めて、徐々に複雑なテストケースに挑戦してみてください。