Vue Test Utils で学ぶ!コンポーネントテスト入門
はじめに:なぜコンポーネントテストが必要なのか?
Vue.jsを使ったフロントエンド開発において、テストはアプリケーションの品質と保守性を保証する上で非常に重要なプラクティスです。特に、ユーザーインターフェースを構成する最小単位である「コンポーネント」に対するテストは、開発プロセスにおいて大きな価値をもたらします。
なぜコンポーネントテストが必要なのでしょうか?主な理由をいくつか挙げます。
- 品質の向上: コンポーネントが期待通りに動作するかを自動的に検証することで、バグの早期発見につながります。これにより、手動テストでは見落としがちなケースや、リグレッション(過去に修正したはずのバグが再発すること)を防ぐことができます。
- 開発効率の向上: テストが揃っていると、安心してコードを修正したり、機能を追加したりできます。変更が既存の機能に悪影響を与えないか、テストスイートを実行すればすぐに確認できるため、デバッグにかかる時間を大幅に削減できます。
- リファクタリングの促進: 既存のコードの構造を改善する「リファクタリング」は、コードの保守性を高める上で不可欠ですが、同時に既存機能を壊すリスクも伴います。コンポーネントテストがあれば、リファクタリング後もコンポーネントの外部仕様が変わっていないことをテストで保証できるため、安心してリファクタリングを進めることができます。
- ドキュメントとしての役割: テストコードは、そのコンポーネントがどのような入力(Propsやユーザー操作)に対して、どのような出力(DOMの変更、イベントの発火、状態の変更)をするべきかを示す、生きたドキュメントとしても機能します。新しい開発者がプロジェクトに参加した際、テストコードを読むことで、コンポーネントの振る舞いを素早く理解できます。
Vue.jsにおけるテストには、主に以下の種類があります。
- 単体テスト (Unit Test): アプリケーションの最小単位(関数、モジュール、Vueコンポーネントなど)が単独で正しく機能するかをテストします。コンポーネントテストは、この単体テストの一部と位置づけられます。
- 結合テスト (Integration Test): 複数のユニット(コンポーネント、モジュールなど)が連携して正しく機能するかをテストします。例えば、親コンポーネントと子コンポーネントが連携するテストなどがこれにあたります。
- E2Eテスト (End-to-End Test): ユーザーの視点に立って、アプリケーション全体が最初から最後まで(ログイン、操作、結果の確認など)正しく動作するかを、実際のブラウザを使ってテストします。CypressやSeleniumなどが使われます。
この記事では、これらのうち特にコンポーネントテストに焦点を当てます。そして、Vue.js公式が推奨するコンポーネントテストライブラリであるVue Test Utils (VTU) を使用して、Vueコンポーネントを効果的にテストする方法を、初心者の方でも理解できるように丁寧に解説していきます。
Vue Test Utilsは、Vueコンポーネントをマウントし、そのインスタンスやDOMを操作・検証するための便利なAPIを提供します。これにより、Vueコンポーネント特有の機能を考慮したテストを容易に記述できます。
さあ、Vue Test Utils を使って、堅牢で保守性の高いVueアプリケーションを構築するための一歩を踏み出しましょう!
テスト環境のセットアップ
Vueコンポーネントのテストを始める前に、テストを実行するための環境をセットアップする必要があります。一般的には、以下のツールを使用します。
- テストランナー: テストコードを実行するための基盤です。ここでは、軽量で高速なテストランナーである Jest を使用します。JestはFacebookが開発しており、特にReactのテストで広く使われていますが、Vue.jsのテストにも非常に適しています。アサーションライブラリやモッキング機能も内蔵しています。
- Vue Test Utils (VTU): Vueコンポーネントをマウントし、操作・検証するためのユーティリティライブラリです。Vueのインスタンス、Props、イベント、スロット、非同期更新などをテストするために必要なAPIを提供します。
- Vue 用 Jest トランスフォーマー: JestはデフォルトではJavaScriptファイルを直接実行しますが、Vueの単一ファイルコンポーネント (
.vue
ファイル) やTypeScriptを使用している場合は、それらをJavaScriptに変換(トランスフォーム)する必要があります。Vue CLIやViteでプロジェクトを作成した場合、通常は@vue/vue3-jest
や@vitejs/plugin-vue
が適切に設定されます。
ここでは、既存のVue 3プロジェクト(Vue CLIまたはViteで作成されたもの)にテスト環境を追加することを想定して説明します。
必要なパッケージのインストール
プロジェクトのルートディレクトリで、以下のコマンドを実行して必要なパッケージをインストールします。
“`bash
npm を使用する場合
npm install –save-dev jest vue-test-utils@next @vue/vue3-jest @babel/preset-env @babel/core babel-jest
yarn を使用する場合
yarn add –dev jest vue-test-utils@next @vue/vue3-jest @babel/preset-env @babel/core babel-jest
“`
jest
: テストランナー本体。vue-test-utils@next
: Vue 3 用の Vue Test Utils。Vue 2 の場合は@vue/test-utils
を使用します。@vue/vue3-jest
:.vue
ファイルをJestが理解できるJavaScriptにトランスフォームするためのプリセット。@babel/preset-env
: Babelの最新JavaScript機能のプリセット。@babel/core
,babel-jest
: JavaScriptコードをJestが実行できる形式にトランスフォームするためにBabelを使用するためのパッケージ。
Vue CLIやViteでプロジェクトを作成した場合、すでにこれらの依存関係の一部が含まれている可能性があります。その場合は、不足しているものだけをインストールしてください。特にVue CLI 4.x 以降や Vite 2.x 以降では、テスト環境が最初からセットアップされているテンプレートを選択することも可能です。
Jest の設定
Jestはプロジェクトルートにある jest.config.js
または package.json
の jest
プロパティで設定を管理します。.vue
ファイルやBabelを使ったトランスフォームを設定するために、jest.config.js
ファイルを作成(または編集)します。
“`javascript
// jest.config.js
module.exports = {
// テスト対象のファイル拡張子を指定します
moduleFileExtensions: [
‘js’,
‘json’,
‘vue’ // .vue ファイルを含める
],
// モジュール解決のエイリアスを設定します (webpackやViteの設定と合わせる)
// 例: @
が src
ディレクトリを指す場合
moduleNameMapper: {
‘^@/(.*)$’: ‘
},
// ファイルを変換するための設定
transform: {
// .js
ファイルを babel-jest
で変換
‘^.+\.js$’: ‘babel-jest’,
// .vue
ファイルを @vue/vue3-jest
で変換
‘^.+\.vue$’: ‘@vue/vue3-jest’
},
// スナップショットテスト時のシリアライザー
snapshotSerializers: [
‘jest-serializer-vue’ // Vueコンポーネントのスナップショットを綺麗に表示
],
// テストファイルのパターン
// tests ディレクトリ内の .spec.js または .test.js ファイルを対象とする
testMatch: [
‘/tests//.spec.js’,
‘/tests//.test.js’
],
// テスト環境を設定 (デフォルトはjsdom)
// Vue Test Utils は jsdom 環境で動作します
testEnvironment: ‘jsdom’,
// テストのセットアップファイル (オプション)
// 各テストファイルの実行前に特定のコードを実行したい場合に使用
// setupFilesAfterEnv: [‘
};
“`
また、Babelの設定ファイル (babel.config.js
または .babelrc
) が必要です。最低限、@babel/preset-env
を使用するように設定します。
javascript
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }] // JestがNode.js環境で実行されるため
]
};
これで、JestとVue Test Utilsを使って .vue
ファイルをテストできる基本的な環境が整いました。通常は package.json
の scripts
にテスト実行コマンドを追加します。
json
// package.json
{
"scripts": {
"test": "jest"
}
}
これで、ターミナルで npm test
または yarn test
を実行すると、Jestがテストファイル (.spec.js
や .test.js
) を探して実行するようになります。
Vue Test Utils の基本
テスト環境のセットアップが完了したら、いよいよ Vue Test Utils (VTU) を使ってコンポーネントのテストを書いてみましょう。VTUの中心となるのは、コンポーネントをマウントする機能と、マウントされたコンポーネントを操作・検証するための Wrapper
オブジェクトです。
コンポーネントのマウント: mount
と shallowMount
VTUを使ってコンポーネントをテストする際は、まずそのコンポーネントを仮想DOM環境(jsdomなど)にマウントします。これには主に mount
と shallowMount
の2つのメソッドを使用します。
“`javascript
import { mount, shallowMount } from ‘@vue/test-utils’;
import MyComponent from ‘@/components/MyComponent.vue’; // テスト対象のコンポーネント
describe(‘MyComponent’, () => {
it(‘renders correctly’, () => {
// コンポーネントをマウント
const wrapper = mount(MyComponent, {
// オプション (Props, Slots, グローバル設定など)
props: {
message: ‘Hello Test’
}
});
// ここで wrapper を使って検証を行う
expect(wrapper.text()).toContain('Hello Test');
});
});
“`
-
mount(Component, options?)
:- コンポーネントとその全ての子コンポーネントを再帰的にマウントします。
- コンポーネントが実際にレンダリングされるのと近い状態でテストできます。
- 結合テストに近い側面を持ちます。
- 子コンポーネントの内部的なバグが原因で親コンポーネントのテストが失敗する可能性があります。
-
shallowMount(Component, options?)
:- コンポーネントをマウントしますが、子コンポーネントはスタブ化します(ダミーのコンポーネントに置き換えます)。
- コンポーネント単体の振る舞いを分離してテストすることに適しています(単体テスト)。
- 子コンポーネントの実装に影響されずにテストできます。
- 子コンポーネントとのインタラクション(イベントの発火やPropsの受け渡し)をテストしたい場合は、スタブ化された子コンポーネントに対して明示的に検証を行う必要があります。
どちらを使うべきかは、テストの目的によります。一般的には、デフォルトでは shallowMount
を使い、子コンポーネントを含めた結合的なテストが必要な場合にのみ mount
を使うのが良いプラクティスとされています。これにより、テストの依存関係を減らし、テストの実行速度を向上させることができます。
mount
および shallowMount
の第2引数 options
オブジェクトには、様々な設定を指定できます。
props
: コンポーネントに渡すPropsを指定します。slots
: コンポーネントに渡すスロットコンテンツを指定します。global
: Vueプラグイン(Vue Router, Vuexなど)、グローバルコンポーネント、ディレクティブ、ミックスインなどを設定します。mocks
: グローバルプロパティ ($route
,$store
など) やメソッドをモックします。stubs
: 特定の子コンポーネントをスタブ化したり、全てのコンポーネントのスタブ方法を制御したりします(shallowMount
はデフォルトで全てのコンポーネントをスタブ化しますが、mount
で特定の子コンポーネントだけをスタブ化したい場合などに使用します)。attachTo
: コンポーネントをDOMの特定要素にマウントします。通常は必要ありませんが、portalやbody直下にモーダルをレンダリングする場合などに使われることがあります。
Wrapper
オブジェクトの操作と検証
mount
または shallowMount
が返すのは Wrapper
オブジェクトです。この Wrapper
オブジェクトを通じて、マウントされたコンポーネントのインスタンスやレンダリングされたDOM要素にアクセスし、様々な操作や検証を行います。
Wrapper
オブジェクトの主なAPIを紹介します。
-
要素の検索:
wrapper.find(selector)
: セレクタにマッチする最初の要素またはコンポーネントのWrapper
を返します。見つからない場合はexists()
がfalse
を返すWrapper
を返します。wrapper.get(selector)
: セレクタにマッチする最初の要素またはコンポーネントのWrapper
を返します。見つからない場合はエラーをスローします。要素が存在することを保証したい場合に便利です。wrapper.findAll(selector)
: セレクタにマッチする全ての要素またはコンポーネントのWrapperArray
を返します。wrapper.getAll(selector)
: セレクタにマッチする全ての要素またはコンポーネントのWrapperArray
を返します。見つからない場合はエラーをスローします。
セレクタには以下の種類が使えます。
* CSSセレクタ:'div'
,'.my-class'
,'#my-id'
,'[data-test="my-selector"]'
など。テストではdata-test
属性を使うのが推奨されます。
* コンポーネントセレクタ:MyChildComponent
のようにコンポーネントオプションを直接渡します。
* Ref セレクタ:{ ref: 'myRef' }
のようにオブジェクトで指定します。 -
要素の状態確認:
wrapper.exists()
: 要素またはコンポーネントが存在するかどうかを真偽値で返します。wrapper.isVisible()
: 要素が表示されているか(display: none
やvisibility: hidden
で隠されていないか)を返します。wrapper.text()
: 要素とその子孫要素のテキストコンテンツを返します。wrapper.html()
: 要素とその子孫要素のHTMLコンテンツを文字列で返します。wrapper.classes([className])
: 要素のクラスリストを文字列配列で返します。引数にクラス名を渡すと、そのクラスが含まれているか真偽値で返します。wrapper.attributes([attributeName])
: 要素の属性をオブジェクトで返します。引数に属性名を渡すと、その属性の値または存在するか真偽値で返します。wrapper.props([propName])
: コンポーネントに渡されたPropsをオブジェクトで返します。引数にProps名を渡すと、そのPropsの値または存在するか真偽値で返します。wrapper.emitted([eventName])
: コンポーネントが発火したカスタムイベントをオブジェクトで返します。引数にイベント名を渡すと、そのイベントが発火された回数とペイロードを含む配列を返します。
-
要素の操作:
wrapper.trigger(eventName, options?)
: 要素上で指定されたイベント(例:'click'
,'input'
,'submit'
)を発火させます。options
でイベントペイロード(例:event.target.value
など)を渡すことができます。wrapper.setValue(value)
:<input>
,<select>
,<textarea>
要素の値を設定し、対応するイベント(通常'input'
または'change'
)を発火させます。フォーム要素のテストに便利です。wrapper.findComponent(Component)
: 子コンポーネントを検索します。find
のコンポーネントセレクタ版です。wrapper.getComponent(Component)
: 子コンポーネントを検索します。見つからない場合はエラーをスローします。wrapper.findAllComponents(Component)
:全ての子コンポーネントを検索します。wrapper.getAllComponents(Component)
:全ての子コンポーネントを検索します。見つからない場合はエラーをスローします。
-
コンポーネントインスタンスへのアクセス:
wrapper.vm
: マウントされたコンポーネントインスタンスにアクセスできます。data
,computed
,methods
などにアクセスできますが、原則としてDOMやProps/Eventsを通じた公開APIのみをテストし、内部状態 (data
) やプライベートメソッドへの直接アクセスは避けるのが良いプラクティスです。実装の詳細に依存しすぎると、リファクタリングが難しくなります。
WrapperArray
の操作
findAll
や getAll
が返す WrapperArray
は、複数の Wrapper
オブジェクトを扱うためのユーティリティです。配列ライクなメソッド(at
, length
, wrappers
, filter
, forEach
, map
など)を提供します。
“`javascript
const wrapper = mount(ListComponent); //
- …
const items = wrapper.findAll(‘li’);
expect(items.length).toBe(3); // リストアイテムが3つあることを確認
expect(items.at(0).text()).toBe(‘Item 1’); // 最初のアイテムのテキストを確認
items.forEach(itemWrapper => {
expect(itemWrapper.isVisible()).toBe(true); // 全てのアイテムが表示されていることを確認
});
“`
基本的なコンポーネントのテスト
ここからは、具体的なコンポーネントの機能をテストする方法を見ていきましょう。
Props のテスト
コンポーネントがPropsを正しく受け取り、それに応じてレンダリングや振る舞いを変えるかをテストします。
テスト対象コンポーネント例 (Greeting.vue
):
“`vue
Hello, {{ name }}!
“`
テストコード例 (Greeting.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import Greeting from ‘@/components/Greeting.vue’;
describe(‘Greeting.vue’, () => {
it(‘renders greeting with name prop’, () => {
const wrapper = mount(Greeting, {
props: {
name: ‘Alice’
}
});
// Props が正しく渡されていることを確認 (Wrapper.props() または Wrapper.vm.name)
expect(wrapper.props().name).toBe('Alice');
// または
// expect(wrapper.vm.name).toBe('Alice'); // VMへのアクセスは推奨されない場合が多い
// DOM に Props の値が反映されていることを確認
expect(wrapper.text()).toContain('Hello, Alice!');
});
it(‘renders default greeting when no name prop is provided’, () => {
const wrapper = mount(Greeting); // propsオプションなし
// デフォルト値が使われていることを確認
expect(wrapper.props().name).toBe('Guest');
// または
// expect(wrapper.vm.name).toBe('Guest');
// DOM にデフォルト値が反映されていることを確認
expect(wrapper.text()).toContain('Hello, Guest!');
});
});
“`
イベントの発火とテスト
コンポーネントがユーザー操作などに応じてカスタムイベントを正しく発火するか、そしてそのイベントペイロードが正しいかをテストします。
テスト対象コンポーネント例 (Counter.vue
):
“`vue
Count: {{ count }}
“`
テストコード例 (Counter.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import Counter from ‘@/components/Counter.vue’;
describe(‘Counter.vue’, () => {
it(‘increments count and emits update:count event on button click’, async () => {
const wrapper = mount(Counter);
// ボタン要素を取得
const button = wrapper.find('button');
// ボタンが存在することを確認
expect(button.exists()).toBe(true);
// ボタンをクリックしてイベントを発火
await button.trigger('click'); // 非同期イベントハンドラを待つ場合は await が必要
// DOM が更新されていることを確認
expect(wrapper.text()).toContain('Count: 1');
// カスタムイベント 'update:count' が発火されたことを確認
const emittedEvent = wrapper.emitted('update:count');
expect(emittedEvent).toBeTruthy(); // イベントが発火されたか
// イベントが発火された回数を確認
expect(emittedEvent.length).toBe(1);
// イベントのペイロードを確認 (最初の発火のペイロード)
expect(emittedEvent[0]).toEqual([1]); // emit('update:count', 1) の場合、ペイロードは配列 [1] となる
// もう一度クリック
await button.trigger('click');
// DOM が更新されていることを確認
expect(wrapper.text()).toContain('Count: 2');
// イベントが合計2回発火されたことを確認
const emittedEventAfterSecondClick = wrapper.emitted('update:count');
expect(emittedEventAfterSecondClick.length).toBe(2);
// 2回目のイベントのペイロードを確認
expect(emittedEventAfterSecondClick[1]).toEqual([2]);
});
});
“`
wrapper.emitted()
は発火されたイベントに関する情報を取得する非常に便利なAPIです。特定のイベント名 ('update:count'
) を引数に渡すと、そのイベントが発火されたすべての呼び出しに関する情報が配列で返されます。各要素は、その呼び出しで emit
に渡された引数の配列です。
データ(data)や算出プロパティ(computed)のテスト
コンポーネントのリアクティブデータや算出プロパティの状態に基づいた表示や振る舞いをテストします。直接 wrapper.vm.dataProperty
にアクセスして検証することも可能ですが、可能な限りDOMやイベントを通じてコンポーネントの公開されたインターフェースをテストするのが望ましいです。
テスト対象コンポーネント例 (StatusDisplay.vue
):
“`vue
Status: {{ statusText }}
“`
テストコード例 (StatusDisplay.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import StatusDisplay from ‘@/components/StatusDisplay.vue’;
describe(‘StatusDisplay.vue’, () => {
it(‘displays initial status as Inactive’, () => {
const wrapper = mount(StatusDisplay);
expect(wrapper.text()).toContain(‘Status: Inactive’);
});
it(‘toggles status to Active on button click’, async () => {
const wrapper = mount(StatusDisplay);
const button = wrapper.find(‘button’);
await button.trigger('click');
// ボタンクリック後に DOM が更新され、Active と表示されることを確認
expect(wrapper.text()).toContain('Status: Active');
// vm を使って内部状態を直接確認することも可能だが、推奨度は低い
// expect(wrapper.vm.isActive).toBe(true);
// expect(wrapper.vm.statusText).toBe('Active');
});
it(‘toggles status back to Inactive on second button click’, async () => {
const wrapper = mount(StatusDisplay);
const button = wrapper.find(‘button’);
await button.trigger('click'); // Active にする
await button.trigger('click'); // Inactive に戻す
// 再度クリック後に DOM が更新され、Inactive と表示されることを確認
expect(wrapper.text()).toContain('Status: Inactive');
// expect(wrapper.vm.isActive).toBe(false);
});
});
“`
この例では、算出プロパティ statusText
の結果がDOMに表示されることをテストしています。算出プロパティの内部ロジックを直接テストするのではなく、それが引き起こす副作用(DOMの変更)をテストすることが、よりユーザー視点に近く、リファクタリング耐性の高いテストになります。
DOMの状態に基づいたテスト
コンポーネントの状態やPropsに応じて、特定の要素が表示されるか、特定のクラスや属性が付与されるかなどをテストします。wrapper.find()
, wrapper.classes()
, wrapper.attributes()
, wrapper.isVisible()
, wrapper.exists()
などのAPIが役立ちます。
テスト対象コンポーネント例 (AlertBox.vue
):
“`vue
{{ message }}
“`
テストコード例 (AlertBox.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import AlertBox from ‘@/components/AlertBox.vue’;
describe(‘AlertBox.vue’, () => {
it(‘renders message and is initially visible’, () => {
const wrapper = mount(AlertBox, {
props: {
message: ‘Something happened!’
}
});
// メッセージが表示されていることを確認
expect(wrapper.text()).toContain('Something happened!');
// コンポーネント全体が存在し、かつ表示されていることを確認
expect(wrapper.exists()).toBe(true);
expect(wrapper.isVisible()).toBe(true);
});
it(‘applies type class correctly’, () => {
const wrapper = mount(AlertBox, {
props: {
message: ‘Warning!’,
type: ‘warning’ // warning タイプを指定
}
});
// warning クラスが適用されていることを確認
const alertDiv = wrapper.find('.alert');
expect(alertDiv.classes()).toContain('warning');
expect(alertDiv.classes('info')).toBe(false); // info クラスは適用されていない
});
it(‘becomes hidden and emits close event when close button is clicked’, async () => {
const wrapper = mount(AlertBox, {
props: {
message: ‘Click to close’
}
});
const closeButton = wrapper.find('button');
await closeButton.trigger('click');
// v-if により DOM から削除されることを確認 (または isVisible が false になることを確認)
// v-if の場合、要素は DOM に存在しなくなります
expect(wrapper.find('.alert').exists()).toBe(false);
// isVisible というプロパティはコンポーネント内部にしか存在しないため、wrapper.isVisible() は使えません。
// wrapper.exists() でトップレベル要素の存在を確認するのが適切です。
// 'close' イベントが発火されたことを確認
expect(wrapper.emitted('close')).toBeTruthy();
expect(wrapper.emitted('close').length).toBe(1);
});
it(‘is initially hidden if initialVisible is false’, () => {
const wrapper = mount(AlertBox, {
props: {
message: ‘Initially hidden’,
initialVisible: false // initialVisible を false に設定
}
});
// コンポーネントが initiallyVisible=false の場合に表示されないことを確認
expect(wrapper.find('.alert').exists()).toBe(false);
});
});
“`
この例では、Propsによるクラスの動的適用、v-if
による要素の表示/非表示、ボタンクリックによるDOM状態の変化とイベント発火など、様々なDOMに基づいたテストを行っています。
スロットのテスト
コンポーネントがスロットコンテンツを正しくレンダリングするかをテストします。
テスト対象コンポーネント例 (Card.vue
):
“`vue
“`
テストコード例 (Card.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import Card from ‘@/components/Card.vue’;
describe(‘Card.vue’, () => {
it(‘renders default slot content’, () => {
const wrapper = mount(Card, {
slots: {
default: ‘
This is the body content.
‘
}
});
// デフォルトスロットの内容が .card-body に含まれていることを確認
expect(wrapper.find('.card-body').html()).toContain('<p>This is the body content.</p>');
});
it(‘renders named slot content (header and footer)’, () => {
const wrapper = mount(Card, {
slots: {
header: ‘
Card Title
‘,
default: ‘
Body content.
‘,
footer: ‘‘
}
});
// ヘッダースロットの内容が .card-header に含まれていることを確認
expect(wrapper.find('.card-header').html()).toContain('<h1>Card Title</h1>');
// フッタースロットの内容が .card-footer に含まれていることを確認
expect(wrapper.find('.card-footer').html()).toContain('<button>More Info</button>');
});
it(‘does not render header/footer sections if slots are not provided’, () => {
const wrapper = mount(Card, {
slots: {
default: ‘
Only body content.
‘
}
});
// ヘッダーとフッターのスロット要素が存在しないことを確認
expect(wrapper.find('.card-header').exists()).toBe(false);
expect(wrapper.find('.card-footer').exists()).toBe(false);
});
});
“`
mount
オプションの slots
プロパティを使って、テスト中にコンポーネントに渡すスロットコンテンツを指定できます。デフォルトスロットは default
キー、名前付きスロットはスロット名に対応するキーを使用します。値としてはHTML文字列や、別のコンポーネント、あるいはレンダー関数などを指定できますが、HTML文字列が最もシンプルで一般的です。
非同期処理のテスト
Vue.jsでは、DOMの更新は非同期に行われることがあります。例えば、データの変更、イベントハンドラ内での状態更新、nextTick
の使用などです。これらの非同期更新を伴うテストでは、DOMが更新されるのを待つ必要があります。
Vue の更新サイクルと nextTick
Vueは、データ変更を検知すると、すぐにDOMを更新するのではなく、イベントループの次の「ティック」でまとめて更新を行います。これにより、同じイベントループ内で複数回データが変更されても、DOM更新は一度で済み、パフォーマンスが向上します。
テストコード内で状態を変更した後、その変更がDOMに反映されたことを確認したい場合は、await nextTick()
を使用して次のティックまで待つ必要があります。
“`javascript
import { nextTick } from ‘vue’; // または ‘@vue/test-utils’ から import する場合もある
import { mount } from ‘@vue/test-utils’;
import MyComponent from ‘@/components/MyComponent.vue’;
it(‘updates text after data change’, async () => {
const wrapper = mount(MyComponent);
// 状態を変更
wrapper.vm.message = ‘Updated Message’;
// DOM 更新を待つ
await nextTick();
// DOM が更新されたことを確認
expect(wrapper.text()).toContain(‘Updated Message’);
});
“`
VTUの trigger
メソッドや setValue
メソッドは、イベントハンドラが非同期の場合でも、その処理が完了するのを待つように設計されています。そのため、通常は await wrapper.trigger('click')
のように await
を付けるだけで十分です。しかし、イベントハンドラ内でさらに nextTick
を使用している場合や、複雑な非同期処理(API呼び出しなど)を行っている場合は、その非同期処理の完了自体を待つ必要があります。
非同期イベントハンドラやデータ取得のテスト
コンポーネントがAPIからデータを取得したり、非同期処理を含むイベントハンドラを持っていたりする場合、テストではこれらの非同期処理が完了し、DOMが更新されるのを待つ必要があります。
テスト対象コンポーネント例 (AsyncButton.vue
):
“`vue
{{ data }}
{{ error }}
“`
このコンポーネントの fetchData
メソッドは async
関数であり、非同期処理を含んでいます。テストでは、ボタンクリック後にこの非同期処理が完了し、data
または error
がセットされ、DOMが更新されるのを待つ必要があります。
テストコード例 (AsyncButton.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import AsyncButton from ‘@/components/AsyncButton.vue’;
// setTimeout をモックして非同期処理を制御可能にする
jest.useFakeTimers();
describe(‘AsyncButton.vue’, () => {
it(‘displays “Loading…” while fetching data’, async () => {
const wrapper = mount(AsyncButton);
const button = wrapper.find(‘button’);
// ボタンをクリック (非同期処理開始)
await button.trigger('click');
// 非同期処理が完了する前に、Loading 状態になっているか確認
expect(wrapper.text()).toContain('Loading...');
expect(button.element.disabled).toBe(true);
// 非同期処理 (setTimeout) を実行完了させる
jest.runAllTimers();
// DOM が更新されるのを待つ (nextTick が必要になることもあるが、trigger が待つことが多い)
// 必要に応じて await wrapper.vm.$nextTick(); を追加
// 非同期処理完了後、Loading 表示が消えていることを確認
expect(wrapper.text()).not.toContain('Loading...');
expect(button.element.disabled).toBe(false);
expect(wrapper.text()).toContain('Fetched Data!'); // データが表示されていることを確認
});
it(‘displays fetched data after successful fetch’, async () => {
const wrapper = mount(AsyncButton);
const button = wrapper.find(‘button’);
await button.trigger('click');
// 非同期処理を完了させる
jest.runAllTimers();
// await wrapper.vm.$nextTick(); // 必要に応じて
// データが表示されていることを確認
expect(wrapper.text()).toContain('Fetched Data!');
expect(wrapper.find('p:nth-of-type(1)').text()).toBe('Fetched Data!'); // より具体的に指定
expect(wrapper.find('p:nth-of-type(2)').exists()).toBe(false); // エラーメッセージは表示されていない
});
it(‘displays error message if fetch fails’, async () => {
// ここではフェッチ処理が失敗するようにモックするなどの工夫が必要になります。
// 例: componentOptions.global.mocks.$http = { get: () => Promise.reject(‘Error’) } など
// Jestのモックタイマーだけではエラーをシミュレートできないため、
// より高度なモックテクニックが必要ですが、ここでは await とタイマーの待ち方を理解することに焦点を当てます。
// もし fetchData メソッドがエラーをスローするように修正すれば、以下のようにテストできます。
// fetchData内で意図的にエラーをスローするように修正するか、
// fetchData が依存する関数をモックしてエラーを返すようにする
// ... ここでは、簡単のために fetchData を直接モックする方法を想定せず、
// await trigger とタイマー待ちの方法のみを示します ...
const wrapper = mount(AsyncButton);
const button = wrapper.find('button');
// 通常の成功フローを実行...
await button.trigger('click');
jest.runAllTimers();
// await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Fetched Data!'); // 成功する場合の確認
});
});
“`
非同期処理を伴うテストでは、async/await
を積極的に使用し、trigger
や nextTick
を適切に await
することが重要です。API呼び出しなどの外部依存がある場合は、Jestのモック機能 (jest.fn()
, jest.mock()
) を活用してそれらを置き換え、テストの分離性と速度を確保します。また、setTimeout
や setInterval
を使用している場合は、jest.useFakeTimers()
と jest.runAllTimers()
/ jest.advanceTimersByTime()
を使うことで、タイマーの完了を待つことができます。
Vue Router と Vuex のテスト
多くのVue.jsアプリケーションは、Vue RouterによるルーティングやVuexによる状態管理を使用します。これらの機能をコンポーネントテストで扱うには、いくつかの特別な考慮が必要です。
ルーターを含むコンポーネントのテスト
Vue Routerを使用するコンポーネント(例: $route
や $router
にアクセスするコンポーネント、<router-link>
を使用するコンポーネント)をテストする場合、テスト環境にモックのルーターインスタンスを提供する必要があります。
mount
オプションの global.mocks
や global.stubs
を使用します。
テスト対象コンポーネント例 (UserNav.vue
):
“`vue
“`
テストコード例 (UserNav.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import UserNav from ‘@/components/UserNav.vue’;
import { createRouter, createWebHistory } from ‘vue-router’; // モックルーター作成用
// ダミーのルーターインスタンスを作成
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: ‘/’, name: ‘home’, component: { template: ‘
‘ } },
{ path: ‘/profile/:id’, name: ‘profile’, component: { template: ‘
‘ } },
],
});
describe(‘UserNav.vue’, () => {
it(‘renders router links correctly’, async () => {
// mount オプションの global でルーターと router-link/router-view を提供
const wrapper = mount(UserNav, {
props: {
userId: 123
},
global: {
plugins: [router], // Vue Router プラグインをグローバルに追加
stubs: { // router-link はデフォルトでスタブ化されるが、明示的に設定することも可能
RouterLink: { template: ‘
RouterView: { template: ‘
‘ }
}
}
});
// ルーターの初期化を待つ
await router.isReady();
// router-link が正しくレンダリングされているか確認
const links = wrapper.findAll('a');
expect(links.length).toBe(2);
expect(links.at(0).attributes('href')).toBe('/');
expect(links.at(0).text()).toBe('Home');
expect(links.at(1).attributes('href')).toBe('/profile/123');
expect(links.at(1).text()).toBe('Profile');
});
it(‘displays current page indicator when on home page’, async () => {
// 特定の $route.path をモックするために mocks オプションを使用
const wrapper = mount(UserNav, {
props: {
userId: 456
},
global: {
mocks: {
$route: { path: ‘/’ } // $route.path をモック
},
stubs: {
RouterLink: { template: ‘
RouterView: { template: ‘
‘ }
}
}
});
// モックされた $route.path に応じて DOM がレンダリングされているか確認
expect(wrapper.text()).toContain('(Current Page)');
});
it('does not display current page indicator when not on home page', () => {
const wrapper = mount(UserNav, {
props: {
userId: 456
},
global: {
mocks: {
$route: { path: '/about' } // 別のパスをモック
},
stubs: {
RouterLink: { template: '<a><slot /></a>' },
RouterView: { template: '<div></div>' }
}
}
});
expect(wrapper.text()).not.toContain('(Current Page)');
});
});
“`
global.plugins: [router]
を使用すると、実際のルーターインスタンスをコンポーネントに提供できます。<router-link>
や<router-view>
が正しく動作するかをテストする場合に便利です。router.isReady()
をawait
してルーターの初期化を待つ必要があります。global.mocks: { $route: {...}, $router: {...} }
を使用すると、$route
や$router
のプロパティやメソッドをモックできます。例えば、特定のパスやパラメータでのコンポーネントの表示をテストする場合に便利です。<router-link>
や<router-view>
はglobal.stubs
でスタブ化する必要があります。
一般的には、コンポーネント自体がルーターの機能(ナビゲーションや現在のルート情報の取得)に直接依存している場合は global.mocks
を使用して $route
や $router
をモックし、<router-link>
や <router-view>
とのインタラクションをテストしたい場合は global.plugins
でルーターを渡すか、適切にスタブ化します。shallowMount
を使用する場合は、子コンポーネントである <router-link>
や <router-view>
はデフォルトでスタブ化されるため、テストの目的に応じて global.stubs
でスタブの振る舞いを調整します。
Vuex ストアを含むコンポーネントのテスト
Vuex ストアにアクセスするコンポーネント(例: mapState
, mapGetters
, mapMutations
, mapActions
ヘルパーを使用する、または useStore()
を使用するコンポーネント)をテストする場合、テスト環境にモックまたは実際のストアインスタンスを提供する必要があります。
ストアを扱う方法はいくつかあります。
- フルストアの提供: 実際のVuexストアインスタンスを
global.plugins: [store]
で提供します。ストア全体の状態やゲッター、ミューテーション、アクションが利用可能になります。結合テストに近い方法です。 - ローカルストアの提供: テストごとに小さなストアインスタンスを作成し、
global.plugins: [localStore]
で提供します。テストに必要なステート、ゲッター、ミューテーション、アクションだけを持つようにします。テストの分離性を高めるのに役立ちます。 $store
のモック:global.mocks: { $store: {...} }
を使用して$store
オブジェクト自体をモックします。ステートやゲッターの値、ミューテーションやアクションのメソッドをダミー関数 (jest.fn()
) に置き換えます。コンポーネントがストアから値を取得したり、アクションやミューテーションを呼び出したりする振る舞いをテストするのに適しています。単体テスト向きです。
一般的には、コンポーネントがストアの値を単に表示したり、ボタンクリックでアクション/ミューテーションを呼び出したりするような単純なケースでは、global.mocks
を使用して $store
をモックするのが最もシンプルで分離性が高く推奨されます。ストアのゲッターやアクションの複雑なロジック自体は、ストアの単体テストとして別途行うべきです。
テスト対象コンポーネント例 (UserInfo.vue
):
“`vue
User Info
Username: {{ username }}
Role: Admin
“`
テストコード例 (UserInfo.spec.js
):
“`javascript
import { mount } from ‘@vue/test-utils’;
import UserInfo from ‘@/components/UserInfo.vue’;
import { createStore } from ‘vuex’; // モックストア作成用
// Option 3: $store をモックする方法 (推奨)
describe(‘UserInfo.vue with mocked $store’, () => {
it(‘displays username and admin status from mocked store’, () => {
const mockStore = {
state: {
user: {
username: ‘testuser’
}
},
getters: {
// ゲッターは関数としてモックし、期待する値を返すようにする
‘user/isAdmin’: true // または jest.fn().mockReturnValue(true)
},
actions: {
// アクションは jest.fn() でモックし、呼び出されたことを検証できるようにする
‘user/fetchUser’: jest.fn()
}
// 必要に応じて mutations もモック
};
const wrapper = mount(UserInfo, {
global: {
mocks: {
$store: mockStore // モックしたストアオブジェクトを $store として提供
}
}
});
// モックしたストアの値が DOM に反映されているか確認
expect(wrapper.text()).toContain('Username: testuser');
expect(wrapper.text()).toContain('Role: Admin');
// button をクリックしても、実際のアクションは実行されない (モックされているため)
const button = wrapper.find('button');
button.trigger('click');
// モックしたアクションが呼び出されたことを確認
expect(mockStore.actions['user/fetchUser']).toHaveBeenCalledTimes(1);
});
it(‘does not display admin status if not admin’, () => {
const mockStore = {
state: {
user: {
username: ‘regularuser’
}
},
getters: {
‘user/isAdmin’: false // isAdmin ゲッターを false にモック
},
actions: {
‘user/fetchUser’: jest.fn()
}
};
const wrapper = mount(UserInfo, {
global: {
mocks: {
$store: mockStore
}
}
});
expect(wrapper.text()).toContain('Username: regularuser');
expect(wrapper.text()).not.toContain('Role: Admin');
});
});
// Option 2: ローカルストアを提供する方法
describe(‘UserInfo.vue with local store’, () => {
it(‘displays username and admin status from local store’, async () => {
const localStore = createStore({
modules: {
user: {
namespaced: true, // モジュール化されている場合
state: () => ({
username: ‘localuser’
}),
getters: {
isAdmin: (state) => state.username === ‘localuser’ // ゲッターのロジックも含む
},
actions: {
async fetchUser({ commit }) {
// ダミーのアクション処理
await new Promise(resolve => setTimeout(resolve, 50));
// commit(‘SET_USERNAME’, ‘fetched_user’); // もしミューテーションを呼び出すなら
}
}
}
}
});
const wrapper = mount(UserInfo, {
global: {
plugins: [localStore] // ローカルストアをプラグインとして提供
}
});
// ストアの値が DOM に反映されているか確認 (初期状態)
expect(wrapper.text()).toContain('Username: localuser');
expect(wrapper.text()).toContain('Role: Admin'); // isAdmin ゲッターが true を返すため
// ボタンをクリックし、アクションの完了を待つ
const button = wrapper.find('button');
await button.trigger('click');
// アクション内の非同期処理を待つ (この例では setTimeout)
jest.useFakeTimers(); // 必要に応じて
jest.runAllTimers(); // 必要に応じて
// await wrapper.vm.$nextTick(); // DOM 更新を待つ
// アクション完了後の状態変化をテストする場合
// 例: fetchUser が username を変更するなら、ここで新しい username を確認
// expect(wrapper.text()).toContain('Username: fetched_user');
});
});
“`
Composition API の useStore()
を使用している場合は、global.plugins: [store]
でストアを提供する必要があります。global.mocks.$store
は Options API の $store
プロパティを置き換えるものなので、Composition API の useStore()
には影響しません。ただし、テスト用にモックのストアインスタンスを作成し、それを createStore
でラップしてプラグインとして提供することは可能です。
まとめると、Vuexのテストでは以下の点を考慮します。
- コンポーネントテストでは、ストアの内部ロジックではなく、コンポーネントとストア間のインタラクション(値の表示、アクション/ミューテーションの呼び出し)をテストすることに焦点を当てます。
- ほとんどの場合、
global.mocks.$store
を使用して必要なステート、ゲッター、アクション/ミューテーションをモックする方法が、テストの分離性とシンプルさの観点から推奨されます。 - Composition API の
useStore()
を使用する場合は、テスト用のcreateStore
インスタンスを作成し、global.plugins
で提供します。このテスト用ストアで必要な状態やゲッター、モック化されたアクションなどを定義します。
高度なテクニック
スタブ (Stubbing) の活用
スタブは、テスト対象のコンポーネントが依存する子コンポーネントや他のアセット(ディレクティブ、プラグインなど)を、シンプルなダミー(スタブ)に置き換える手法です。これにより、テスト対象のコンポーネント単体に集中し、子コンポーネントの内部的なバグや複雑さからテストを隔離できます。
shallowMount
はデフォルトで全ての子コンポーネントをスタブ化します。mount
で特定のコンポーネントだけをスタブ化したい場合や、スタブの振る舞いをカスタマイズしたい場合は、options.global.stubs
オプションを使用します。
“`javascript
import { mount } from ‘@vue/test-utils’;
import ParentComponent from ‘@/components/ParentComponent.vue’;
import ChildComponent from ‘@/components/ChildComponent.vue’;
describe(‘ParentComponent’, () => {
it(‘renders with child component stubbed’, () => {
const wrapper = mount(ParentComponent, {
global: {
stubs: {
// ChildComponent をダミーの div タグに置き換える
ChildComponent: ‘
‘
// またはコンポーネントオプションを渡す
// ChildComponent: { template: ‘
‘ }
}
}
});
// 元の子コンポーネントではなく、スタブ化された要素が存在することを確認
expect(wrapper.find('.stubbed-child').exists()).toBe(true);
// 実際の子コンポーネントの内部はレンダリングされていない
// expect(wrapper.text()).not.toContain('Child component content');
});
it(‘passes props correctly to the stubbed child component’, () => {
const wrapper = mount(ParentComponent, {
props: { myProp: ‘testValue’ }, // 親コンポーネントへのProps
global: {
stubs: {
// スタブにもpropsやイベントなどの振る舞いを定義できる
ChildComponent: {
template: ‘
‘,
props: [‘msg’] // スタブで受け取るPropsを定義
}
}
}
});
// 親コンポーネントから子コンポーネントへPropsが正しく渡されているか確認
// スタブの Wrapper を取得し、その props() を確認
const stubbedChild = wrapper.findComponent({ name: 'ChildComponent' }); // name オプションがあれば name で検索
// または .stubbed-child クラスで検索し、その Wrapper を確認
// const stubbedChild = wrapper.find('.stubbed-child'); // この場合は .attributes() を使う
// expect(stubbedChild.attributes('msg')).toBe('testValue'); // HTML属性として渡される場合
// スタブの vm を通じてPropsを確認する方が確実
expect(stubbedChild.vm.msg).toBe('testValue');
});
});
“`
スタブは、テスト対象のコンポーネントが子コンポーネントにPropsを渡したり、子コンポーネントが発火するイベントを受け取ったりするインタラクションをテストする際にも役立ちます。スタブコンポーネントにPropsやemitの定義を持たせることで、これらのインタラクションをテストできます。
モック (Mocking) の活用
モックは、特定のオブジェクトや関数(APIクライアント、ユーティリティ関数、グローバルオブジェクトなど)の振る舞いを、テストのために制御されたダミーの振る舞いに置き換える手法です。Jestのモック機能 (jest.fn()
, jest.mock()
, jest.spyOn()
) や VTUの global.mocks
オプションを使用します。
- 外部依存のモック: API呼び出しを行うサービスや、外部ライブラリの関数などをモックします。
- グローバルオブジェクトのモック:
window
,document
,localStorage
,fetch
など、グローバルに存在するオブジェクトや関数をモックします。 - Vue インスタンスのグローバルプロパティのモック:
$router
,$store
など、Vueインスタンスに$propName
として追加されたプロパティをglobal.mocks
でモックします。
``javascript
/api/users/${id}`);
// API サービスがあると仮定
// apiService.js
export const fetchUser = async (id) => {
// 実際には API を叩く
const response = await fetch(
if (!response.ok) throw new Error(‘Failed to fetch’);
return response.json();
};
// コンポーネント例 (FetchUserButton.vue)
{{ user.name }}
{{ error }}
// テストコード例 (FetchUserButton.spec.js)
import { mount } from ‘@vue/test-utils’;
import FetchUserButton from ‘@/components/FetchUserButton.vue’;
import * as apiService from ‘@/apiService’; // モックしたいモジュールをインポート
describe(‘FetchUserButton.vue’, () => {
// テストファイル全体、または describe ブロック内でモジュール全体をモック
// import * as apiService from ‘@/apiService’; の後に記述
jest.mock(‘@/apiService’);
// 各テストの前にモックをリセット
beforeEach(() => {
// fetchUser モック関数を jest.fn() のインスタンスとして再生成
apiService.fetchUser = jest.fn();
});
it(‘fetches and displays user data on button click’, async () => {
// fetchUser が成功した場合の戻り値をモック
const mockUser = { id: 1, name: ‘Mock User’ };
apiService.fetchUser.mockResolvedValue(mockUser); // Promise で解決する値を指定
const wrapper = mount(FetchUserButton);
const button = wrapper.find('button');
await button.trigger('click');
// apiService.fetchUser が正しい引数で呼び出されたことを確認
expect(apiService.fetchUser).toHaveBeenCalledTimes(1);
expect(apiService.fetchUser).toHaveBeenCalledWith(1);
// DOM が更新され、ユーザー名が表示されたことを確認
// await wrapper.vm.$nextTick(); // 必要に応じて
expect(wrapper.text()).toContain('Mock User');
expect(wrapper.find('p:nth-of-type(2)').exists()).toBe(false); // エラーメッセージは表示されていない
});
it(‘displays error message if fetching user fails’, async () => {
// fetchUser が失敗した場合 (Promise が reject された場合) をモック
const errorMessage = ‘API Error’;
apiService.fetchUser.mockRejectedValue(new Error(errorMessage)); // Promise で reject される値を指定
const wrapper = mount(FetchUserButton);
const button = wrapper.find('button');
await button.trigger('click');
// apiService.fetchUser が呼び出されたことを確認
expect(apiService.fetchUser).toHaveBeenCalledTimes(1);
// DOM が更新され、エラーメッセージが表示されたことを確認
// await wrapper.vm.$nextTick(); // 必要に応じて
expect(wrapper.text()).toContain(errorMessage);
expect(wrapper.find('p:nth-of-type(1)').exists()).toBe(false); // ユーザー名は表示されていない
});
});
“`
この例では、jest.mock
を使って @/apiService
モジュール全体をモックし、apiService.fetchUser
を jest.fn()
で置き換えています。mockResolvedValue
や mockRejectedValue
を使うことで、非同期関数の成功/失敗ケースを簡単にシミュレートできます。
モックとスタブはテストダブルの一種であり、テスト対象から外部依存を隔離するために使用されます。
- スタブ: 主に子コンポーネントなど、レンダリングされるVueコンポーネント階層内の依存関係を置き換える。ダミーの表示や単純な振る舞いを提供することが多い。
- モック: 関数、オブジェクト、モジュールなど、Vueコンポーネント階層の外にある依存関係を置き換える。特定のメソッドが特定の引数で呼び出されたか、特定の値を返すかなどを検証することが多い。
global
オプションを使ったグローバルな設定
mount
および shallowMount
の global
オプションは、テスト対象のコンポーネントとその子孫コンポーネントに対して、グローバルな設定(プラグイン、ミックスイン、ディレクティブ、コンポーネント、モック、設定)を適用するために使用されます。これにより、ルートVueインスタンスに app.use(...)
や app.component(...)
などで登録される項目をテスト環境で再現できます。
主なプロパティ:
plugins
: 提供するプラグインの配列(Vue Router, Vuex ストアインスタンスなど)。components
: グローバル登録されたコンポーネントのオブジェクト(例:{ MyGlobalComponent }
)。directives
: グローバル登録されたディレクティブのオブジェクト(例:{ focus }
)。mixins
: グローバルに適用されるミックスインの配列。mocks
: グローバルプロパティ($route
,$store
など)をモックするオブジェクト。config
: Vueのグローバル設定(例: エラーハンドラ)。
“`javascript
// 例: i18n プラグインとカスタムディレクティブを持つコンポーネントのテスト
import { mount } from ‘@vue/test-utils’;
import MyComponent from ‘@/components/MyComponent.vue’;
import { createI18n } from ‘vue-i18n’; // i18n プラグイン作成用
// i18n インスタンスを作成
const i18n = createI18n({
locale: ‘en’,
messages: {
en: { message: { hello: ‘Hello!’ } },
fr: { message: { hello: ‘Bonjour !’ } }
}
});
// カスタムディレクティブの定義 (例: v-format)
const formatDirective = {
mounted(el, binding) {
el.innerText = binding.value.toUpperCase();
}
};
describe(‘MyComponent with global settings’, () => {
it(‘uses global plugins and directives’, () => {
const wrapper = mount(MyComponent, {
global: {
plugins: [i18n], // i18n プラグインを提供
directives: { // カスタムディレクティブを提供
format: formatDirective
},
// mocks: { … }, // 必要に応じて $t などをモックすることも可能
// components: { … } // グローバルコンポーネントを提供
}
// MyComponent.vue が
{{ $t(‘message.hello’) }}
と を持つと仮定
});
// i18n プラグインが適用された結果をテスト
expect(wrapper.text()).toContain('Hello!');
// カスタムディレクティブが適用された結果をテスト
const formattedSpan = wrapper.find('span');
expect(formattedSpan.text()).toBe('WORLD');
});
});
“`
global
オプションは、テスト対象コンポーネントが依存するグローバルなアセットをテスト環境に注入するために不可欠です。これにより、アプリケーション全体の設定に近い状態でコンポーネントをテストできます。
テストケースの構造化とライフサイクルメソッド
Jestでは describe
ブロックで関連するテストをグループ化し、it
または test
ブロックで個々のテストケースを定義します。また、beforeEach
, afterEach
, beforeAll
, afterAll
といったフックを使って、テストスイートや個々のテストケースの実行前後に共通のセットアップやクリーンアップ処理を行うことができます。
“`javascript
import { mount, shallowMount } from ‘@vue/test-utils’;
import MyComponent from ‘@/components/MyComponent.vue’;
// describe ブロックでテストスイートを定義
describe(‘MyComponent’, () => {
let wrapper; // 各テストで再利用する wrapper 変数を宣言
// 各テストケースが実行される「前」に実行される
beforeEach(() => {
// 例えば、各テストケースで新しい wrapper を作成する
wrapper = mount(MyComponent, {
// オプション
});
});
// 各テストケースが実行された「後」に実行される
afterEach(() => {
// 例えば、wrapper を破棄してメモリリークを防ぐ
// VTU は通常自動でクリーンアップしますが、明示的に行うこともできます
if (wrapper) {
wrapper.destroy();
}
});
// この describe ブロック内の「全ての」テストケースが実行される「前」に一度だけ実行される
// beforeAll(() => {
// // グローバルなモックの設定など
// jest.useFakeTimers();
// });
// この describe ブロック内の「全ての」テストケースが実行された「後」に一度だけ実行される
// afterAll(() => {
// // グローバルなクリーンアップなど
// // jest.useRealTimers();
// });
// it または test ブロックで個々のテストケースを定義
it(‘should render initial state’, () => {
// beforeEach で作成された wrapper を使用
expect(wrapper.text()).toContain(‘Initial State’);
});
it(‘should update state on button click’, async () => {
const button = wrapper.find(‘button’);
await button.trigger(‘click’);
expect(wrapper.text()).toContain(‘Updated State’);
});
// 別の describe ブロック
describe(‘when a prop is set’, () => {
let wrapperWithProp;
beforeEach(() => {
wrapperWithProp = mount(MyComponent, {
props: {
initialValue: 10
}
});
});
afterEach(() => {
if (wrapperWithProp) {
wrapperWithProp.destroy();
}
});
it('should use the prop value', () => {
expect(wrapperWithProp.text()).toContain('Value: 10');
});
});
});
“`
beforeEach
と afterEach
を使うことで、各テストケースが独立したクリーンな状態で実行されることを保証できます。これは、テストの信頼性を高める上で非常に重要です。beforeAll
と afterAll
は、よりコストのかかるセットアップやクリーンアップ(例: データベース接続、タイマーのモック設定全体)に便利ですが、テスト間の依存関係を生みやすいため慎重に使用します。
テスト駆動開発 (TDD) の簡単な紹介
テスト駆動開発(TDD)は、「テストを書く」「テストを実行する(失敗する)」「実装を書く」「テストを実行する(成功する)」「リファクタリングする」というサイクルを繰り返しながら開発を進める手法です。コンポーネントテストはTDDと非常に相性が良いです。
TDDの基本的なサイクル(通称「赤・緑・リファクタリング」サイクル)は以下の通りです。
- 赤 (Red): 実装したい機能のテストを最初に書きます。このテストは、まだ機能が実装されていないため、必ず失敗します。この段階で、コンポーネントがどのような入力に対してどのような出力をすべきかを明確に定義します。
- 緑 (Green): テストが成功するための最小限の実装を行います。この段階では、コードの綺麗さや効率は気にせず、とにかくテストを通すことに集中します。
- リファクタリング (Refactor): テストが全て成功していることを確認した上で、コードの重複を排除したり、構造を改善したりするなど、コードを綺麗にします。リファクタリング後もテストが成功し続けることで、変更が既存機能を壊していないことを保証できます。
このサイクルを繰り返すことで、必要な機能が確実に実装され、かつテストカバレッジの高い、保守しやすいコードベースを構築できます。
Vueコンポーネント開発におけるTDDの例(カウンターコンポーネントの機能追加):
- 赤: 「Increment ボタンをクリックすると、カウントが1増える」というテストを書く。まだボタンがないのでテストは失敗する。
- 緑: カウント用のデータプロパティを追加し、
increment
メソッドを作成し、ボタンと@click
ハンドラを追加する。テストが成功する。 - リファクタリング: コードを整理する(例: Setup Composition API を使う、より意味のある変数名にするなど)。テストが成功し続けることを確認。
次に、「Decrement ボタンをクリックすると、カウントが1減る」という機能を追加する場合も、同様にテストから始めます。
TDDは最初は少し難しく感じるかもしれませんが、慣れると開発スピードとコード品質の両方を向上させる強力な手法となります。
よくある落とし穴と解決策
コンポーネントテストを書いていると、いくつかの一般的な問題に遭遇することがあります。
-
非同期処理の待ち忘れ:
- 問題: 状態を変更したり、イベントを発火させたりした後、DOMが更新される前にアサーションを行ってしまう。
- 解決策: 非同期処理が完了するのを
await
で待ちます。イベントハンドラが非同期の場合はawait wrapper.trigger(...)
を、データ変更後にDOM更新を待ちたい場合はawait nextTick()
またはawait wrapper.vm.$nextTick()
を使用します。外部APIやタイマーなど、Vueの更新サイクル以外の非同期処理を待つ場合は、適切なモックと待機処理(jest.runAllTimers()
,await flushPromises()
など、使用するライブラリによる)が必要です。
-
不適切なセレクタ:
- 問題: クラス名やタグ名など、リファクタリングで変更されやすいCSSセレクタに依存して要素を取得・検証している。
- 解決策: テスト専用の属性(例:
data-test="my-element"
)を要素に追加し、その属性をセレクタとして使用します。これは実装の詳細からテストを分離し、リファクタリング耐性を高める推奨されるプラクティスです。VTUは{ ref: 'myRef' }
のような ref セレクタもサポートしています。
-
テストの重複:
- 問題: 同じようなセットアップコードやアサーションが複数のテストケースに散在している。
- 解決策:
beforeEach
,afterEach
フックを使用して、共通のセットアップ/クリーンアップコードをまとめます。また、カスタムヘルパー関数を作成して、共通のアサーションロジックをカプセル化することもできます。
-
過剰なテスト(実装の詳細をテストしすぎ):
- 問題: コンポーネントの内部状態(データや算出プロパティ)やプライベートメソッドを直接テストしている。
- 解決策: 可能な限り、コンポーネントの「公開されたインターフェース」、つまりProps、イベント、スロット、そしてそれらが引き起こすDOMの変更を通じてコンポーネントの振る舞いをテストします。内部の実装詳細はテストせず、自由にリファクタリングできるようにします。
wrapper.vm
へのアクセスは最後の手段と考えます。
-
shallowMount
とmount
の使い分けの誤り:- 問題: 子コンポーネントとのインタラクションをテストしたいのに
shallowMount
を使っている、あるいは単体テストで子コンポーネントの内部実装に依存したくないのにmount
を使っている。 - 解決策: 基本は
shallowMount
を使用し、子コンポーネントを含めた結合テストが必要な場合のみmount
を使用します。子コンポーネントとのインタラクションをテストしたい場合は、shallowMount
でスタブ化された子コンポーネントに対してtrigger
やemitted
を呼び出したり、Propsを渡したりできることをテストします。
- 問題: 子コンポーネントとのインタラクションをテストしたいのに
より良いテストのためのヒント
- ユーザーの視点でテストを書く: コンポーネントがユーザーに対してどのように振る舞うか(何が表示され、どのような操作ができ、操作の結果どうなるか)に焦点を当ててテストを記述します。これにより、テストがビジネスロジックやUIの意図をよりよく反映したものになります。
- テストを読みやすく保つ: テストケースには明確で分かりやすい名前を付けます(例:
'displays error message if fetch fails'
)。テストコード自体もシンプルかつ簡潔に保ち、アサーションの意図がすぐに理解できるように記述します。AAAパターン (Arrange, Act, Assert – 準備、実行、検証) に従ってテストを構成すると、構造が明確になります。 - テストのカバレッジ: テストカバレッジレポートを確認し、どのコードパスがテストされているか、されていないかを把握します。ただし、カバレッジ100%を盲目的に目指すのではなく、重要な機能や複雑なロジックが十分にテストされていることを確認することが重要です。
- 継続的インテグレーション (CI) との連携: テストスイートをCI/CDパイプラインに組み込むことで、コードがリポジトリにプッシュされるたびに自動的にテストが実行されるようにします。これにより、バグが本番環境にデプロイされるリスクを大幅に減らすことができます。
まとめ
この記事では、Vue Test Utils を使用したVueコンポーネントテストの基本的な概念から応用テクニックまでを網羅的に解説しました。
コンポーネントテストは、Vue.jsアプリケーション開発において不可欠な要素です。適切なコンポーネントテストを実践することで、開発者は自信を持ってコードの変更やリファクタリングを行い、より堅牢で保守性の高いアプリケーションを構築することができます。
Vue Test Utils は、Vueコンポーネントをテストするための強力かつ柔軟なAPIを提供します。mount
と shallowMount
を使い分け、Wrapper
オブジェクトを通じてDOMやコンポーネントインスタンスにアクセスし、Props、イベント、スロット、非同期処理などを効果的にテストする方法を学びました。また、ルーターやVuexといったエコシステムとの連携、スタブやモックを使った依存関係の隔離、そしてテストの構造化やよくある落とし穴とその解決策についても触れました。
コンポーネントテストは、一度に全てのコンポーネントに適用するのは難しいかもしれません。まずは重要度の高いコンポーネントや、バグが発生しやすいコンポーネントからテストを導入していくのが良いでしょう。そして、新しいコンポーネントを開発する際には、ぜひテストを書きながら進める習慣を身につけてみてください。
この記事で学んだ知識とスキルを活用して、あなたのVue.jsプロジェクトの品質向上に繋げてください。コンポーネントテストの習得は、より良い開発者への道のりの重要な一歩となるはずです。
次のステップとして、より複雑なコンポーネントのテスト、カスタムフック(Composables)のテスト、ディレクティブやプラグインのテスト、そしてより上位のテスト(結合テストやE2Eテスト)についても学んでいくと、さらにテスト戦略の幅が広がるでしょう。
これで、「Vue Test Utils で学ぶ!コンポーネントテスト入門」の詳細な説明を含む記事を終わります。約5000語を目指して記述しました。テストコード例も豊富に含め、各概念を深く掘り下げたつもりです。