T-CREATOR

Vitest × Vue 3:SFC を簡単に効率的にテストする

Vitest × Vue 3:SFC を簡単に効率的にテストする

Vue 3 の開発において、コンポーネントのテストは品質向上と保守性確保の重要な要素です。特に SFC(Single File Component)のテストでは、従来の Jest ベースの環境では設定が複雑で実行速度も課題となっていました。

この記事では、Vite エコシステムの一部として開発された Vitest を使用して、Vue 3 の SFC を効率的にテストする方法をご紹介いたします。実際の環境構築から具体的なテストコードまで、段階的に解説していきます。

背景

Vue 3 が正式リリースされて以降、Composition API や TypeScript サポートの強化により、より堅牢なアプリケーション開発が可能になりました。しかし、開発規模が大きくなるにつれて、コンポーネントの品質を保証するテストの重要性が高まっています。

mermaidflowchart LR
    dev[開発者] -->|コード作成| sfc[Vue SFC]
    sfc -->|品質保証| test[テスト]
    test -->|継続的改善| ci[CI/CD]
    ci -->|デプロイ| prod[本番環境]
    test -->|フィードバック| dev

上記の図は、現代的な Vue 開発フローにおけるテストの位置づけを示しています。テストは単なる品質チェックではなく、継続的な改善サイクルの核となる要素です。

従来の Vue 2 時代では、Vue Test Utils と Jest の組み合わせが主流でした。しかし、Vue 3 と Vite の登場により、より高速で効率的なテスト環境が求められるようになっています。

Vue 3 開発でテストが重要な理由

Vue 3 の Composition API は柔軟性を提供する一方で、ロジックの複雑化によりバグが潜在しやすくなる傾向があります。また、TypeScript との組み合わせでは、型安全性を活かすためにもテストによる動作確認が欠かせません。

課題

従来のテスト環境では、いくつかの課題が開発者を悩ませてきました。これらの課題を理解することで、Vitest を選択する理由が明確になります。

Jest ベース環境の課題

mermaidflowchart TD
    jest[Jest環境] --> setup[複雑なセットアップ]
    jest --> speed[実行速度の遅さ]
    jest --> esm[ESMサポート不足]
    setup --> config[設定ファイルの複雑化]
    setup --> transform[トランスパイル設定]
    speed --> startup[起動時間の長さ]
    speed --> watch[ウォッチモードの重さ]
    esm --> module[モジュール解決問題]
    esm --> import[動的インポート課題]

上の図が示すように、Jest 環境では複数の課題が相互に関連し合っています。

1. 複雑なセットアップ

Jest を使用した Vue 3 のテスト環境では、以下のような設定が必要でした:

javascript// jest.config.js の例
module.exports = {
  preset:
    '@vue/cli-plugin-unit-jest/presets/typescript-and-babel',
  transform: {
    '^.+\\.vue$': '@vue/vue3-jest',
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
  testEnvironment: 'jsdom',
  moduleFileExtensions: ['js', 'ts', 'json', 'vue'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

この設定では、Vue SFC のトランスパイル、TypeScript 対応、モジュール解決など、多くの設定項目を手動で管理する必要がありました。

2. 実行速度の問題

Jest ベースの環境では、テストの起動時間が長く、特に大規模プロジェクトでは開発効率に大きな影響を与えていました。

環境初回起動時間ウォッチモード再実行全テスト実行
Jest8-15 秒3-8 秒30-60 秒
Vitest1-3 秒0.5-2 秒5-15 秒

3. ESM サポートの不完全性

Vue 3 と Vite が ESM(ECMAScript Modules)をネイティブサポートしているのに対し、Jest の ESM サポートは実験的な機能にとどまっていました。

SFC 特有のテスト課題

Single File Component は、template、script、style を一つのファイルに統合する便利な仕組みですが、テストにおいては以下の課題がありました:

1. コンポーネントの部分的テスト

SFC の各部分(template、script、style)を個別にテストしたい場合の複雑さ

2. Composition API のテスト方法

Vue 3 の Composition API で書かれたロジックを効果的にテストする方法の不明確さ

3. リアクティビティのテスト

Vue 3 の新しいリアクティビティシステムに対応したテスト手法の必要性

解決策

これらの課題に対する解決策として、Vitest と Vue Test Utils の組み合わせが最適です。Vitest は、Vite エコシステムの一部として設計されており、Vue 3 との親和性が非常に高くなっています。

Vitest の優位性

mermaidflowchart LR
    vite[Vite] -->|ネイティブ統合| vitest[Vitest]
    vitest --> speed[高速実行]
    vitest --> esm[ESMネイティブ]
    vitest --> config[設定共有]
    speed --> hmr[HMR対応]
    speed --> parallel[並列実行]
    esm --> import[動的インポート]
    esm --> tree[ツリーシェイキング]
    config --> simple[シンプル設定]
    config --> vite_config[vite.config共用]

Vitest は、Vite の設定をそのまま活用できるため、追加の設定がほとんど不要です。また、Vite の高速なビルドシステムをテストにも適用できるため、開発体験が大幅に向上します。

1. Vite との完全統合

Vitest は Vite の設定ファイル(vite.config.ts)をそのまま使用できます:

typescript// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

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

この設定だけで、Vue 3 の SFC テストが可能になります。

2. 高速なテスト実行

Vitest は以下の技術により高速なテスト実行を実現しています:

  • ESM ネイティブサポート: トランスパイル不要
  • 並列実行: CPU コア数に応じた効率的な実行
  • Smart Watch: 変更されたファイルのみを対象とした再実行

3. Vue Test Utils との組み合わせ

Vue Test Utils は、Vue 公式のテストユーティリティライブラリです。Vue 3 に完全対応しており、Vitest との組み合わせで強力なテスト環境を構築できます。

typescript// テストの基本構造
import { mount } from '@vue/test-utils';
import MyComponent from '@/components/MyComponent.vue';

describe('MyComponent', () => {
  it('正常にマウントされる', () => {
    const wrapper = mount(MyComponent);
    expect(wrapper.exists()).toBe(true);
  });
});

具体例

実際に Vitest と Vue Test Utils を使用した SFC テストの具体例をご紹介します。段階的に環境構築から実装まで解説いたします。

環境セットアップ

まず、新しい Vue 3 プロジェクトで Vitest を導入する手順をご説明します。

1. プロジェクトの初期化

bash# Vue 3プロジェクトの作成
yarn create vue@latest my-vue-app
cd my-vue-app

2. 必要なパッケージのインストール

bash# Vitestと関連パッケージをインストール
yarn add -D vitest @vue/test-utils jsdom

必要なパッケージの役割:

パッケージ役割
vitestテストランナー本体
@vue/test-utilsVue コンポーネント用テストユーティリティ
jsdomブラウザ環境のシミュレーション

3. Vite 設定の更新

typescript// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
  },
});

4. テストセットアップファイルの作成

typescript// src/test/setup.ts
import { config } from '@vue/test-utils';

// グローバルコンポーネントやプラグインの設定
config.global.stubs = {
  // スタブが必要なコンポーネントを指定
};

5. package.json の更新

json{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

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

シンプルな SFC から始めて、基本的なテスト手法を学びましょう。

テスト対象コンポーネント

vue<!-- src/components/UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p class="email">{{ user.email }}</p>
    <button @click="sendEmail" :disabled="isLoading">
      {{ isLoading ? '送信中...' : 'メール送信' }}
    </button>
  </div>
</template>

<script setup lang="ts">
interface User {
  id: number;
  name: string;
  email: string;
}

interface Props {
  user: User;
}

const props = defineProps<Props>();
const isLoading = ref(false);

const emit = defineEmits<{
  emailSent: [email: string];
}>();

const sendEmail = async () => {
  isLoading.value = true;
  // メール送信の模擬処理
  await new Promise((resolve) => setTimeout(resolve, 1000));
  emit('emailSent', props.user.email);
  isLoading.value = false;
};
</script>

基本テストの実装

typescript// src/components/__tests__/UserCard.test.ts
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import UserCard from '../UserCard.vue';

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

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

    expect(wrapper.find('h2').text()).toBe('田中太郎');
    expect(wrapper.find('.email').text()).toBe(
      'tanaka@example.com'
    );
  });

  it('初期状態ではボタンが有効', () => {
    const wrapper = mount(UserCard, {
      props: {
        user: mockUser,
      },
    });

    const button = wrapper.find('button');
    expect(button.attributes('disabled')).toBeUndefined();
    expect(button.text()).toBe('メール送信');
  });
});

Props、Emit、Slot のテスト

Vue コンポーネントの主要な機能である Props、Emit、Slot をテストする方法をご紹介します。

Props のテスト

typescript// Propsの検証テスト
describe('UserCard Props', () => {
  it('必須propsが不足している場合のエラーハンドリング', () => {
    // 開発モードでのPropsエラーテスト
    const consoleError = vi
      .spyOn(console, 'error')
      .mockImplementation(() => {});

    mount(UserCard, {
      props: {}, // 必須のuserプロパティが不足
    });

    expect(consoleError).toHaveBeenCalled();
    consoleError.mockRestore();
  });

  it('異なるユーザーデータでの表示確認', () => {
    const users = [
      {
        id: 1,
        name: '佐藤花子',
        email: 'sato@example.com',
      },
      {
        id: 2,
        name: 'Smith John',
        email: 'john@example.com',
      },
    ];

    users.forEach((user) => {
      const wrapper = mount(UserCard, {
        props: { user },
      });

      expect(wrapper.find('h2').text()).toBe(user.name);
      expect(wrapper.find('.email').text()).toBe(
        user.email
      );
    });
  });
});

Emit のテスト

typescript// イベント発火のテスト
describe('UserCard Events', () => {
  it('メール送信ボタンクリックでemitが発火される', async () => {
    const wrapper = mount(UserCard, {
      props: {
        user: mockUser,
      },
    });

    await wrapper.find('button').trigger('click');

    // Vueの非同期更新を待機
    await wrapper.vm.$nextTick();
    await new Promise((resolve) =>
      setTimeout(resolve, 1100)
    );

    const emittedEvents = wrapper.emitted('emailSent');
    expect(emittedEvents).toBeTruthy();
    expect(emittedEvents![0]).toEqual([mockUser.email]);
  });

  it('ローディング中はボタンが無効化される', async () => {
    const wrapper = mount(UserCard, {
      props: {
        user: mockUser,
      },
    });

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

    // ローディング状態の確認
    expect(button.attributes('disabled')).toBeDefined();
    expect(button.text()).toBe('送信中...');
  });
});

Slot のテスト

Slot を使用するコンポーネントのテスト例:

vue<!-- src/components/Modal.vue -->
<template>
  <div class="modal" v-if="isOpen">
    <div class="modal-content">
      <header class="modal-header">
        <slot name="header">
          <h3>デフォルトタイトル</h3>
        </slot>
      </header>
      <main class="modal-body">
        <slot></slot>
      </main>
      <footer class="modal-footer">
        <slot name="footer">
          <button @click="close">閉じる</button>
        </slot>
      </footer>
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  isOpen: boolean;
}

const props = defineProps<Props>();
const emit = defineEmits<{
  close: [];
}>();

const close = () => {
  emit('close');
};
</script>
typescript// src/components/__tests__/Modal.test.ts
describe('Modal Slots', () => {
  it('デフォルトスロットの内容が表示される', () => {
    const wrapper = mount(Modal, {
      props: { isOpen: true },
      slots: {
        default: '<p>モーダルの内容</p>',
      },
    });

    expect(wrapper.find('.modal-body p').text()).toBe(
      'モーダルの内容'
    );
  });

  it('名前付きスロットが正しく表示される', () => {
    const wrapper = mount(Modal, {
      props: { isOpen: true },
      slots: {
        header: '<h2>カスタムタイトル</h2>',
        footer:
          '<button class="custom-btn">カスタムボタン</button>',
      },
    });

    expect(wrapper.find('.modal-header h2').text()).toBe(
      'カスタムタイトル'
    );
    expect(
      wrapper.find('.modal-footer .custom-btn').text()
    ).toBe('カスタムボタン');
  });

  it('スロットが提供されない場合はデフォルト内容が表示される', () => {
    const wrapper = mount(Modal, {
      props: { isOpen: true },
    });

    expect(wrapper.find('.modal-header h3').text()).toBe(
      'デフォルトタイトル'
    );
    expect(
      wrapper.find('.modal-footer button').text()
    ).toBe('閉じる');
  });
});

Composition API のテスト

Vue 3 の Composition API を使用したコンポーネントのテスト手法をご紹介します。

複雑な Composition API コンポーネント

vue<!-- src/components/TodoList.vue -->
<template>
  <div class="todo-list">
    <form @submit.prevent="addTodo">
      <input
        v-model="newTodoText"
        placeholder="新しいタスクを入力"
        data-testid="todo-input"
      />
      <button type="submit" :disabled="!newTodoText.trim()">
        追加
      </button>
    </form>

    <ul>
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
        data-testid="todo-item"
      >
        <input
          type="checkbox"
          v-model="todo.completed"
          @change="updateTodo(todo)"
        />
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">削除</button>
      </li>
    </ul>

    <div class="filters">
      <button
        v-for="filter in ['all', 'active', 'completed']"
        :key="filter"
        @click="currentFilter = filter"
        :class="{ active: currentFilter === filter }"
      >
        {{ filterLabels[filter] }}
      </button>
    </div>

    <p>{{ todoStats }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

type Filter = 'all' | 'active' | 'completed';

const todos = ref<Todo[]>([]);
const newTodoText = ref('');
const currentFilter = ref<Filter>('all');

const filterLabels = {
  all: 'すべて',
  active: '未完了',
  completed: '完了済み',
};

const filteredTodos = computed(() => {
  switch (currentFilter.value) {
    case 'active':
      return todos.value.filter((todo) => !todo.completed);
    case 'completed':
      return todos.value.filter((todo) => todo.completed);
    default:
      return todos.value;
  }
});

const todoStats = computed(() => {
  const total = todos.value.length;
  const completed = todos.value.filter(
    (todo) => todo.completed
  ).length;
  const active = total - completed;

  return `全${total}件 (完了: ${completed}件, 未完了: ${active}件)`;
});

const addTodo = () => {
  if (newTodoText.value.trim()) {
    todos.value.push({
      id: Date.now(),
      text: newTodoText.value.trim(),
      completed: false,
    });
    newTodoText.value = '';
  }
};

const removeTodo = (id: number) => {
  const index = todos.value.findIndex(
    (todo) => todo.id === id
  );
  if (index !== -1) {
    todos.value.splice(index, 1);
  }
};

const updateTodo = (updatedTodo: Todo) => {
  const index = todos.value.findIndex(
    (todo) => todo.id === updatedTodo.id
  );
  if (index !== -1) {
    todos.value[index] = updatedTodo;
  }
};

// ローカルストレージとの同期
watchEffect(() => {
  localStorage.setItem(
    'todos',
    JSON.stringify(todos.value)
  );
});

// コンポーネント初期化時にローカルストレージから復元
const savedTodos = localStorage.getItem('todos');
if (savedTodos) {
  todos.value = JSON.parse(savedTodos);
}
</script>

Composition API のテスト実装

typescript// src/components/__tests__/TodoList.test.ts
import { mount } from '@vue/test-utils';
import {
  describe,
  it,
  expect,
  beforeEach,
  vi,
} from 'vitest';
import TodoList from '../TodoList.vue';

// ローカルストレージのモック
const localStorageMock = {
  getItem: vi.fn(),
  setItem: vi.fn(),
  removeItem: vi.fn(),
  clear: vi.fn(),
};

Object.defineProperty(window, 'localStorage', {
  value: localStorageMock,
});

describe('TodoList Composition API', () => {
  beforeEach(() => {
    localStorageMock.getItem.mockReturnValue(null);
    localStorageMock.setItem.mockClear();
  });

  it('新しいタスクが追加される', async () => {
    const wrapper = mount(TodoList);

    const input = wrapper.find(
      '[data-testid="todo-input"]'
    );
    const form = wrapper.find('form');

    await input.setValue('新しいタスク');
    await form.trigger('submit');

    expect(
      wrapper.findAll('[data-testid="todo-item"]')
    ).toHaveLength(1);
    expect(wrapper.text()).toContain('新しいタスク');
    expect(input.element.value).toBe('');
  });

  it('空のタスクは追加されない', async () => {
    const wrapper = mount(TodoList);

    const input = wrapper.find(
      '[data-testid="todo-input"]'
    );
    const form = wrapper.find('form');

    await input.setValue('   ');
    await form.trigger('submit');

    expect(
      wrapper.findAll('[data-testid="todo-item"]')
    ).toHaveLength(0);
  });

  it('タスクの完了状態が切り替わる', async () => {
    const wrapper = mount(TodoList);

    // タスクを追加
    await wrapper
      .find('[data-testid="todo-input"]')
      .setValue('テストタスク');
    await wrapper.find('form').trigger('submit');

    const todoItem = wrapper.find(
      '[data-testid="todo-item"]'
    );
    const checkbox = todoItem.find(
      'input[type="checkbox"]'
    );

    // 完了状態に変更
    await checkbox.setValue(true);

    expect(todoItem.classes()).toContain('completed');
    expect(wrapper.text()).toContain('完了: 1件');
  });

  it('フィルタリングが正しく動作する', async () => {
    const wrapper = mount(TodoList);

    // 複数のタスクを追加
    const todos = ['タスク1', 'タスク2', 'タスク3'];
    for (const todo of todos) {
      await wrapper
        .find('[data-testid="todo-input"]')
        .setValue(todo);
      await wrapper.find('form').trigger('submit');
    }

    // 1つのタスクを完了状態にする
    const firstCheckbox = wrapper.findAll(
      'input[type="checkbox"]'
    )[0];
    await firstCheckbox.setValue(true);

    // 全て表示(デフォルト)
    expect(
      wrapper.findAll('[data-testid="todo-item"]')
    ).toHaveLength(3);

    // 未完了のみ表示
    const activeButton = wrapper.findAll(
      '.filters button'
    )[1];
    await activeButton.trigger('click');
    expect(
      wrapper.findAll('[data-testid="todo-item"]')
    ).toHaveLength(2);

    // 完了済みのみ表示
    const completedButton = wrapper.findAll(
      '.filters button'
    )[2];
    await completedButton.trigger('click');
    expect(
      wrapper.findAll('[data-testid="todo-item"]')
    ).toHaveLength(1);
  });

  it('ローカルストレージとの同期が動作する', async () => {
    const wrapper = mount(TodoList);

    await wrapper
      .find('[data-testid="todo-input"]')
      .setValue('保存テスト');
    await wrapper.find('form').trigger('submit');

    // ローカルストレージに保存されることを確認
    expect(localStorageMock.setItem).toHaveBeenCalledWith(
      'todos',
      expect.stringContaining('保存テスト')
    );
  });

  it('初期化時にローカルストレージからデータを復元する', () => {
    const savedTodos = [
      { id: 1, text: '保存されたタスク', completed: false },
    ];

    localStorageMock.getItem.mockReturnValue(
      JSON.stringify(savedTodos)
    );

    const wrapper = mount(TodoList);

    expect(
      wrapper.findAll('[data-testid="todo-item"]')
    ).toHaveLength(1);
    expect(wrapper.text()).toContain('保存されたタスク');
  });
});

リアクティビティのテスト

Vue 3 のリアクティビティシステムに特化したテスト:

typescript// リアクティブな計算プロパティのテスト
describe('TodoList Reactivity', () => {
  it('computed プロパティが正しく更新される', async () => {
    const wrapper = mount(TodoList);

    // 初期状態の確認
    expect(wrapper.text()).toContain('全0件');

    // タスクを追加
    await wrapper
      .find('[data-testid="todo-input"]')
      .setValue('タスク1');
    await wrapper.find('form').trigger('submit');

    await wrapper
      .find('[data-testid="todo-input"]')
      .setValue('タスク2');
    await wrapper.find('form').trigger('submit');

    // 統計が更新されることを確認
    expect(wrapper.text()).toContain(
      '全2件 (完了: 0件, 未完了: 2件)'
    );

    // 1つ完了状態にする
    const checkbox = wrapper.find('input[type="checkbox"]');
    await checkbox.setValue(true);

    // 統計が再計算されることを確認
    expect(wrapper.text()).toContain(
      '全2件 (完了: 1件, 未完了: 1件)'
    );
  });

  it('watchEffect が適切に動作する', async () => {
    const wrapper = mount(TodoList);

    // タスクを追加
    await wrapper
      .find('[data-testid="todo-input"]')
      .setValue('ウォッチテスト');
    await wrapper.find('form').trigger('submit');

    // watchEffect により localStorage.setItem が呼ばれることを確認
    expect(localStorageMock.setItem).toHaveBeenCalled();

    // タスクの状態を変更
    const checkbox = wrapper.find('input[type="checkbox"]');
    await checkbox.setValue(true);

    // 再度 localStorage.setItem が呼ばれることを確認
    expect(localStorageMock.setItem).toHaveBeenCalledTimes(
      2
    );
  });
});

まとめ

この記事では、Vitest と Vue Test Utils を組み合わせた Vue 3 SFC テストの実装方法をご紹介しました。従来の Jest 環境の課題を解決し、より効率的で高速なテスト環境を構築することができました。

主要なポイント

mermaidflowchart TD
    setup[環境セットアップ] --> basic[基本テスト]
    basic --> props[Props/Emit/Slot]
    props --> composition[Composition API]
    composition --> benefits[得られる効果]

    benefits --> speed[開発速度向上]
    benefits --> quality[コード品質向上]
    benefits --> maintenance[保守性向上]

環境構築の簡単さ: Vitest は最小限の設定で Vue 3 のテスト環境を構築できます。Vite の設定をそのまま活用できるため、追加の複雑な設定は不要です。

テスト実行の高速化: 従来の Jest 環境と比較して、大幅な高速化を実現できます。開発中のウォッチモードでも快適にテストを実行できるため、TDD(テスト駆動開発)の導入もしやすくなります。

Vue 3 機能の完全サポート: Composition API、リアクティビティシステム、TypeScript との統合など、Vue 3 の最新機能を活用したコンポーネントも効果的にテストできます。

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

  1. 段階的な導入: 既存プロジェクトでは、新しいコンポーネントから順次 Vitest を導入することをお勧めします
  2. 適切なテスト範囲: すべてをテストするのではなく、ビジネスロジックや重要な機能に焦点を当てたテストを作成しましょう
  3. 継続的な改善: テストコードも品質を保つため、定期的なリファクタリングを行いましょう

Vitest を活用することで、Vue 3 開発における品質向上と開発効率の両立が可能になります。ぜひ、あなたのプロジェクトでも Vitest を導入して、より堅牢な Vue アプリケーションの開発にお役立てください。

関連リンク