環境
- macOS Monterey 12.6
- VSCode
- node.js v18.6.0
- npm 8.13.2
- Typescript ^5.0.2
- vite ^4.4.5
- vitest ^0.34.2
前置き
本記事で説明すること
以下の記事で関数テストの環境構築と実装、テストの作成、パスエイリアス、カバレッジ設定まで完了した。本記事では、Vueコンポーネントのテスト環境構築、コンポーネントの実装とテストの作成を行う
【Vitestで始める単体・結合テスト】関数テストの環境構築と作成
reactを使用したケースはこちら
【Vitestで始める単体・結合テスト】ReactコンポーネントとカスタムHooksのテストの環境構築と作成(testing-library/react)
testing-library/vueを使用したケースはこちら
【Vitestで始める単体・結合テスト】VueコンポーネントとComposable関数のテストの環境構築と作成(testing-library/vue)
コンポーネントテスト全般のTIPSなどまとめはこちら
【Vitestで始める単体・結合テスト】保守性の高いテストのTIPSやポイント
構成
Vue Test Utils
- コンポーネントテスト用
- DOMのレンダー、要素の取得、ユーザーのイベント処理用のメソッドなどを提供
Vue3
- UIフレームワーク
コンポーネントテスト
環境構築
コンポーネントテスト用の依存関係を追加
- @vue/test-utils
- コンポーネントテスト用のライブラリ
- happy-dom
- 本来ブラウザでしかDOMを使用できないが、サーバーサイドなどで仮想的にDOMツリーを生成するためのライブラリ
npm install -D @vue/test-utils happy-dom
Viteの設定ファイル(vite.config.ts)調整
Domを単体テストの中で扱えるように、environmentにhappy-domを追加
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
alias: {
"@": path.join(__dirname, "/src"),
},
environment: "happy-dom", // here
},
});
コンポーネントの実装
対象のコンポーネント
以下3種類についてコンポーネントとテストをそれぞれ作成する。
- inputコンポーネントを作成し、文字入力イベントを検証する
- Formコンポーネントを作成し、文字入力とボタンのクリックイベントを検証する
- TodoListコンポーネントを作成し、クリックイベントとAPI通信を検証する
inputコンポーネント
labelやid、valueとvalueを変更するための関数onChangeなどをpropsとして受け取る。inputに対して入力されるとonChangeが発火する
src/components/TextInput.vue
interface TextInputProps {
label: string;
id: string;
name?: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const TextInput: React.FC<TextInputProps> = ({
label,
id,
name,
value,
onChange,
}) => {
return (
<div className="mb-4">
<label
className="block text-gray-700 text-sm font-bold mb-1"
htmlFor={id}
>
{label}
</label>
<input
name={name}
type="text"
id={id}
className="w-full px-3 py-2 border rounded-lg border-gray-300 focus:outline-none focus:ring focus:border-blue-300"
value={value}
onChange={onChange}
/>
</div>
);
};
export default TextInput;
Formコンポーネント
src/components/Form.vue
<script setup lang="ts">
import TextInput from "@/components/TextInput.vue";
import { ref } from "vue";
const inputValue = ref<string>("");
const submitted = ref<boolean>(false);
const message = ref<string>("");
const submit = (inputValue: string) => {
message.value = inputValue;
submitted.value = true;
};
</script>
<template>
<section>
<p v-if="submitted" data-testid="submitted-message">
{{ `${message}を送信しました` }}
</p>
氏名:<TextInput v-model="inputValue" />
<div style="margin-top: 20px">
<button
@click="submit(inputValue)"
style="background-color: white; color: #000; padding: 5px 10px"
>
送信する
</button>
</div>
</section>
</template>
TodoListコンポーネント
依存関係の追加
axiosをインストール
npm install axios
src/components/TodoList.vue
<script setup lang="ts">
import axios from "axios";
import { ref } from "vue";
// ほんとは型定義ファイルを作るべき
export type Todo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
const todos = ref<Todo[]>([]);
const fetchTodos = async () => {
axios
.get("https://jsonplaceholder.typicode.com/todos")
.then((res) => {
console.log(res.data);
todos.value = res.data;
})
.catch((err) => {
// デモなので割愛
console.log(err);
});
};
</script>
<template>
<button @click="fetchTodos">記事取得</button>
<ul>
<li v-for="todo in todos">{{ todo.title }}</li>
</ul>
</template>
画面上ではこのようなかんじ
テスト作成
inputコンポーネントのテスト
- mountメソッドによりTextInputコンポーネントをレンダリングする
- mountメソッドの返り値から描画された Vue コンポーネントを含む Wrapperを取得できる
- Wrapperに対してfindやfindAllなどの要素取得のメソッドが使用できる
- input要素に対してsetValueで任意の文字列を入力する
- インタラクティブな操作は非同期処理なのでasync awaitにする必要があるので注意
- 最後に、期待する文字列がinput要素のvalueとして保持されているか、update:modelValueが期待する回数呼び出されているかなどをアサートする
tests/components/TextInput.test.ts
import { mount } from "@vue/test-utils";
import TextInput from "@/components/TextInput.vue";
describe("TextInput", () => {
it("テキストインプットに文字を入力できる", async () => {
const wrapper = mount(TextInput);
const input = wrapper.find("input[type='text']");
await input.setValue("大阪太郎");
// ↓wrapper.emitted()の返り値
// {
// 'update:modelValue': [ [ '大阪太郎' ] ],
// input: [ [ [Event] ] ],
// change: [ [ [Event] ] ]
// }
console.log(wrapper.emitted());
// setValueしてinputのvalueを確認するだけではテストとして足りない。emittedでv-modelが機能していることを検証する
expect(wrapper.emitted("update:modelValue")).toBeTruthy(); // 未発火の場合はundefinedとなりfalsyなので失敗する
expect(wrapper.emitted("update:modelValue")).toHaveLength(1); // 発火回数
expect(wrapper.emitted("update:modelValue")?.at(0)).toEqual(["大阪太郎"]); //値
});
});
Formコンポーネントのテスト
tests/components/Form.test.ts
- mountメソッドによりFormコンポーネントをレンダリングする
- mountメソッドの返り値から描画された Vue コンポーネントを含む Wrapperを取得できる
- inputタグへの文字入力はすでに説明済みなので割愛
- 送信ボタンの要素を取得しクリックイベントを発火させる(trigger)
- 最後に、期待する文字列が画面上に表示されているかアサートする
import Form from "@/components/Form.vue";
import { mount } from "@vue/test-utils";
describe("Form", () => {
it("Formに文字を入力し送信すると、入力した内容が画面上にメッセージとして表示", async () => {
const wrapper = mount(Form);
const input = wrapper.find("input[type='text']");
await input.setValue("京都花子");
const submitButton = wrapper.find("button");
await submitButton.trigger("click");
const submittedMessage = wrapper.find('[data-testid="submitted-message"]');
expect(submittedMessage.text()).toBe("京都花子を送信しました");
});
});
TodoListコンポーネントのテスト
- vitestのvi.spyOnによりaxiosのgetメソッドをモックにし、返り値を固定化する
- jsonplaceholderよりtodoリストのデータを取得する処理をモックに置き換える
- mountメソッドによりTodoListコンポーネントをレンダリングする
- TODO取得ボタンの要素を取得しクリックイベントを発火させる
- 最後にアサート
- モック関数が期待する引数を与えられて呼び出されたか
- Todoのタイトルが画面上に表示されているか
tests/components/TodoList.test.ts
import TodoList from "@/components/TodoList.vue";
import { mount } from "@vue/test-utils";
import axios from "axios";
describe("TodoList", () => {
it("送信ボタンを押すとTODOリストの一覧が表示", async () => {
const mockTodos = [
{ userId: 1, id: 1, title: "title1-1", completed: false },
{ userId: 1, id: 2, title: "title1-2", completed: true },
{ userId: 2, id: 1, title: "title2-1", completed: false },
{ userId: 3, id: 1, title: "title3-1", completed: true },
{ userId: 3, id: 2, title: "title1-2", completed: false },
];
const axiosGetMock = vi
.spyOn(axios, "get")
.mockResolvedValue({ data: mockTodos });
const wrapper = mount(TodoList);
const submitButton = wrapper.find("button");
await submitButton.trigger("click");
expect(axiosGetMock).toHaveBeenCalledWith(
"https://jsonplaceholder.typicode.com/todos"
);
expect(axiosGetMock).toHaveBeenCalledOnce();
// リアクティブ値の更新後、DOMの更新を待つ
await wrapper.vm.$nextTick();
expect(wrapper.findAll("li").length).toBe(5);
});
});
実行
npm run test
全てのテストが通っていればOKです。
composable関数のテスト
Vue3のComposition API を活用して状態を持つロジックをカプセル化して再利用するための関数
composables関数の実装
テスト対象の関数。カウンターをプラスしたりマイナスしたりする
src/composables/counter.ts
import { ref } from "vue";
export function useCounter() {
const counter = ref(0);
function inc() {
counter.value++;
}
function dec() {
counter.value--;
}
return { counter, inc, dec };
}
テスト作成
- composable関数を実行する
- リアクティブな値が更新されているか等をアサートする
tests/composables/counter.test.ts
import useCounter from "@/composables/counter";
import { describe, expect, it } from "vitest";
describe("composable関数: useCounter", () => {
it("カウンターをインクリメントとデクリメントできる", () => {
const { counter, inc, dec } = useCounter();
inc();
dec();
inc();
inc();
expect(counter.value).toEqual(2);
});
});
実行
npm run test
全てのテストが通っていればOK。
まとめ
いかがでしたでしょうか。本記事では、viteベースのvitestでvue-test-utilsを用いてvueコンポーネントの実装と単体・結合テスト、composable関数の環境構築、テストの作成などについて説明しました。コンポーネントテストを行うために必要な依存関係や設定が少し複雑なのと、ネット上にはまだそれほど充実した記事はないので、ぜひ参考にして構築してみてください。