【Vue】コンポーネントのユニットテスト実践

JavaScript

こんにちは、かつコーチです。

前回は、Vue Test Utilsの基本的な使い方として、propsやイベントのテストを解説しました。

テスト編の最後となる今回は、フォームやAPI通信を含む、もう少し実務に近いコンポーネントを題材にユニットテストを実践していきます。

題材:ログインフォームコンポーネント

対象コンポーネントの中身

まずはテスト対象となるログインフォームを見てみましょう。

<!-- LoginForm.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'

const email = ref('')
const password = ref('')
const errorMessage = ref('')

const isValid = computed(() => {
  return email.value.includes('@') && password.value.length >= 8
})

const emit = defineEmits<{
  login: [email: string, password: string]
}>()

const handleSubmit = () => {
  if (!isValid.value) {
    errorMessage.value = 'メールアドレスまたはパスワードの形式が正しくありません'
    return
  }
  errorMessage.value = ''
  emit('login', email.value, password.value)
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="email" data-testid="email" type="email" />
    <input v-model="password" data-testid="password" type="password" />
    <p v-if="errorMessage" data-testid="error">{{ errorMessage }}</p>
    <button type="submit">ログイン</button>
  </form>
</template>

バリデーション、エラーメッセージの表示、emitによるイベント通知という、実務でよく登場する要素が一通り含まれています。

テストしたい振る舞いを整理する

コードをいきなり書き始める前に、「何をテストするか」を日本語で整理しておくと、テストの抜け漏れが減ります。

  • 不正な入力で送信すると、エラーメッセージが表示される
  • 不正な入力ではloginイベントが発火しない
  • 正しい入力で送信すると、loginイベントが発火する
  • 正しい入力で送信すると、エラーメッセージは表示されない

この整理がそのままitの説明文になるので、後から見返したときにも「何を保証しているテストか」が分かりやすくなります。

テストコードを書く

異常系:バリデーションエラーのテスト

// LoginForm.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'

describe('LoginForm', () => {
  it('不正な入力で送信するとエラーメッセージが表示される', async () => {
    const wrapper = mount(LoginForm)

    await wrapper.get('[data-testid="email"]').setValue('invalid-email')
    await wrapper.get('[data-testid="password"]').setValue('short')
    await wrapper.get('form').trigger('submit')

    expect(wrapper.get('[data-testid="error"]').text()).toContain('正しくありません')
  })

  it('不正な入力ではloginイベントが発火しない', async () => {
    const wrapper = mount(LoginForm)

    await wrapper.get('[data-testid="email"]').setValue('invalid-email')
    await wrapper.get('[data-testid="password"]').setValue('short')
    await wrapper.get('form').trigger('submit')

    expect(wrapper.emitted('login')).toBeFalsy()
  })
})

setValue()は、入力欄に値をセットしてv-modelを通じて内部の状態も更新してくれるヘルパーメソッドです。

toContain()を使うと、エラーメッセージ全文と完全一致させなくても、一部の文字列が含まれているかどうかで検証できます。

正常系:送信成功のテスト

describe('LoginForm', () => {
  it('正しい入力で送信するとloginイベントが発火する', async () => {
    const wrapper = mount(LoginForm)

    await wrapper.get('[data-testid="email"]').setValue('katsu@example.com')
    await wrapper.get('[data-testid="password"]').setValue('password123')
    await wrapper.get('form').trigger('submit')

    expect(wrapper.emitted('login')).toBeTruthy()
    expect(wrapper.emitted('login')?.[0]).toEqual(['katsu@example.com', 'password123'])
  })

  it('正しい入力で送信するとエラーメッセージは表示されない', async () => {
    const wrapper = mount(LoginForm)

    await wrapper.get('[data-testid="email"]').setValue('katsu@example.com')
    await wrapper.get('[data-testid="password"]').setValue('password123')
    await wrapper.get('form').trigger('submit')

    expect(wrapper.find('[data-testid="error"]').exists()).toBe(false)
  })
})

emitted('login')?.[0]で、最初に発火したloginイベントに渡された引数の配列を取得し、期待する値と一致しているかをtoEqualで検証しています。

find()get()と似ていますが、要素が見つからなくてもエラーにならず、exists()で存在確認ができる点が異なります。

つまずきやすいポイント:テストが実装の細部に依存しすぎる

私が実際にやってしまった「壊れやすいテスト」

以前、フォームのテストを書いていたとき、内部の変数名やCSSクラス名に依存したテストを書いてしまい、デザイン修正のたびにテストが壊れる状態に陥ったことがあります。

❌ Before:内部実装やCSSクラスに依存したテスト

it('送信ボタンが無効化される', () => {
  const wrapper = mount(LoginForm)
  // 内部のcomputedの値を直接参照してしまっている
  expect((wrapper.vm as any).isValid).toBe(false)
})

このテストは、コンポーネント内部のisValidという変数名やロジックの実装詳細に強く依存しています。

内部のリファクタリングで変数名が変わっただけでテストが壊れてしまい、「テストのために実装の自由度が下がる」という本末転倒な状態になっていました。

✅ After:ユーザーの操作と画面上の見え方だけをテストする

it('不正な入力で送信するとエラーメッセージが画面に表示される', async () => {
  const wrapper = mount(LoginForm)
  await wrapper.get('[data-testid="email"]').setValue('invalid-email')
  await wrapper.get('form').trigger('submit')

  // 内部の変数ではなく、実際に画面に表示される内容だけを検証する
  expect(wrapper.get('[data-testid="error"]').text()).toContain('正しくありません')
})

「ユーザーが実際にどう操作し、画面に何が表示されるか」という外部から観測できる振る舞いだけをテストすることで、内部実装が変わってもテストが壊れにくくなります。

この考え方を意識するようになってから、リファクタリングのたびにテストを書き直す手間がぐっと減りました。

応用:APIをモックしてテストする

vi.fn()でAPI呼び出しを差し替える

実際のAPIを叩くコンポーネントをテストするときは、通信部分を偽物(モック)に差し替えます。

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'
import * as api from './api'

describe('LoginForm(API連携あり)', () => {
  it('API呼び出しが成功したときの表示を確認する', async () => {
    vi.spyOn(api, 'login').mockResolvedValue({ token: 'dummy-token' })

    const wrapper = mount(LoginForm)
    await wrapper.get('[data-testid="email"]').setValue('katsu@example.com')
    await wrapper.get('[data-testid="password"]').setValue('password123')
    await wrapper.get('form').trigger('submit')

    expect(api.login).toHaveBeenCalledWith('katsu@example.com', 'password123')
  })
})

vi.spyOn().mockResolvedValue()で、実際のAPI関数を「常に成功したことにする偽物」に差し替えています。

テストのたびに本物のAPIサーバーに通信すると、実行速度が遅くなるだけでなく、外部サービスの状態に結果が左右されてしまうため、モックで切り離すのが定石です。

まとめ

この記事のポイント

  • テストを書く前に、日本語で「何を保証したいか」を整理しておくとテストの抜け漏れが減る
  • setValue()で入力値をセットし、emitted()で発火したイベントの内容まで検証できる
  • 内部の変数名やロジックに依存したテストは壊れやすく、ユーザーから見える振る舞いを検証するのが基本
  • find().exists()で要素の存在有無、get()で確実に存在する要素の取得、と使い分ける
  • API通信を含むコンポーネントは、vi.spyOn()などでモックに差し替えてテストを安定させる

次に読むべき記事

次回からはデプロイ・インフラ連携編に入り、「VueアプリをNetlifyにデプロイする方法」を解説します。

→ 次の記事:VueアプリをNetlifyにデプロイする方法

タイトルとURLをコピーしました