testing-library、何が嬉しいのか
TL; DR
超ざっくり言えば、ラベルで要素を特定できる点です。
はじめに
testing-library は、ユーザー中心の方法でテストを書くためのライブラリです。
React のテストライブラリとして有名ですが、Vue.js (を含むさまざまなライブラリ)でも使用することができます。Vue.js での使用方法については、Vue Testing Library をご覧ください。
この記事では、Vue Test Utils のみを使用した場合と比較しながら、testing-library の特徴や有用性について紹介します。
例 1: ボタンが複数あるケース
以下のコンポーネントにおいて、「保存」ボタンを押下した際の挙動をテストするとします。
そのためには、「保存」ボタンの要素を特定する必要があります。
<template>
<p>{{ message }}</p>
<button @click="handleCancel">キャンセル</button>
<button @click="handleSave">保存</button>
<button @click="handleDelete">削除</button>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('');
const handleCancel = () => {
message.value = 'キャンセルしました';
};
const handleSave = () => {
message.value = '保存しました';
};
const handleDelete = () => {
message.value = '削除しました';
};
</script>
Vue Test Utils の場合
Vue Test Utils では、表示されているラベルや文字に基づいて要素を直接取得することができません。
そのため、wrapper.findAll
で全てのボタンを取得し、その中から「保存」ボタンを探す必要があります。
import { mount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
describe('MyComponent', () => {
it('保存ボタンを押下した際の挙動', () => {
const wrapper = mount(MyComponent);
const buttons = wrapper.findAll('button');
const saveButton = buttons.find(button => button.text() === '保存');
saveButton.trigger('click');
expect(wrapper.find('p').text()).toBe('保存しました');
});
});
直接取得するために data-test
属性を使用することもあるかもしれませんが、ユーザーがボタンを探すのとは乖離した方法になってしまいます(画面にそんな情報は表示されていませんよね)。
また、data-test を書き間違えたり、変更したりした場合に、テストが壊れるリスクもあります。
<template>
<button @click="handleCancel" data-test="cancel-button">キャンセル</button>
<button @click="handleSave" data-test="save-button">保存</button>
<button @click="handleDelete" data-test="delete-button">削除</button>
</template>
import { mount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
describe('MyComponent', () => {
it('保存ボタンを押下した際の挙動', () => {
const wrapper = mount(MyComponent);
const saveButton = wrapper.find('[data-test="save-button"]');
saveButton.trigger('click');
expect(wrapper.find('p').text()).toBe('保存しました');
});
});
testing-library の場合
testing-library は、"The more your tests resemble the way your software is used, the more confidence they can give you." (訳: テストがソフトウェアの使用方法に似ているほど、テストから得られる信頼性が高まる)を基本原則としています。
人間はボタンを見つけるとき、ボタンに表示される文字を見て探します。testing-library は、この考え方を取り入れています。
import { render, screen, fireEvent } from '@testing-library/vue';
import MyComponent from './MyComponent.vue';
describe('MyComponent', () => {
it('保存ボタンを押下した際の挙動', async () => {
render(MyComponent);
const saveButton = screen.getByText('保存');
await fireEvent.click(saveButton);
screen.getByText('保存しました');
});
});
例 2: 複数の入力欄があるケース
「名前」と「メールアドレス」の入力欄があるフォームをテストするとします。
<template>
<p>{{ message }}</p>
<form @submit.prevent="handleSubmit">
<label :for="nameId">名前</label>
<input :id="nameId" v-model="name" />
<label :for="emailId">メールアドレス</label>
<input :id="emailId" v-model="email" />
<button>送信</button>
</form>
</template>
<script setup>
import { ref } from 'vue';
import { useId } from 'vue';
const name = ref('');
const email = ref('');
const nameId = useId();
const emailId = useId();
const message = ref('');
const handleSubmit = () => {
message.value = '送信しました';
};
</script>
Vue Test Utils の場合
今回は id に useId()
[1] を使用していて、実行するまで値が確定しないため、id で特定することはできません。
先述の data-test
属性を使わない場合、wrapper.findAll
で全ての input を取得し、その順序で特定することとなります。
先ほどは操作する要素とラベルの付与されている要素が同一だったため filter で特定できましたが、今回はラベルと入力欄が別の要素になっているため、ラベルに基づいて取得することができません。
また、このテストは、何番目の入力欄が何であるかを知っている状態をテストすることになります。
入力欄が追加されたり、順序が変わったりした場合、テストが壊れるリスクがあります。
import { mount } from '@vue/test-utils';
import MyForm from './MyForm.vue';
describe('MyForm', () => {
it('フォームの送信', () => {
const wrapper = mount(MyForm);
const inputs = wrapper.findAll('input');
const nameInput = inputs[0];
const emailInput = inputs[1];
nameInput.setValue('山田太郎');
emailInput.setValue('taro@example.com');
wrapper.find('form').trigger('submit');
expect(wrapper.find('p').text()).toBe('送信しました');
});
});
testing-library の場合
testing-library は、ラベルのついている要素が取得できます。
そのため、label 要素ではなく input 要素を直接取得することができます。
import { render, screen, fireEvent } from '@testing-library/vue';
import MyForm from './MyForm.vue';
describe('MyForm', () => {
it('フォームの送信', async () => {
render(MyForm);
const nameInput = screen.getByLabelText('名前');
const emailInput = screen.getByLabelText('メールアドレス');
await fireEvent.update(nameInput, '山田太郎');
await fireEvent.update(emailInput, 'taro@example.com')
const button = screen.getByText('送信');
await fireEvent.click(button);
screen.getByText('送信しました')
});
});
比較して見えてくる特徴
testing-libraryは、コンポーネントの実装をテストすることを避けるAPI設計になっています。
その具体的な手段が、この記事で紹介するようなラベルで要素を特定する方法であり、あるいは(この記事では紹介しませんが)ロールで要素を特定する方法です。
究極、テスト対象がなんの要素でどんな階層になっているかは、ユーザーの操作には関係ありません。
挙げてきた例でも、要素名や階層、順序の情報を使って要素を特定することはしていません。
Vue Test Utils では、それらに依存したテストにならざるを得ないケースがあります。
銀の弾丸ではない
testing-library は、だいたいのケースをカバーできますが、何でもできるわけではありません。
たとえば、 expose
した関数を実行することができません。expose
した関数を実行することは、コンポーネントの実装に依存していると言えます。
以下のようなダイアログを作ってテストしたい場合、 open
を実行することができません。
<template>
<dialog :ref="myDialog">
<button @click="close">閉じる</button>
</dialog>
</template>
<script setup>
import { useTemplateRef } from 'vue';
const dialogRef = useTemplateRef("myDialog");
const open = () => {
// なにか処理
dialogRef.value?.showModal();
}
const close = () => {
dialogRef.value?.close();
}
defineExpose({
open,
});
</script>
このような場合は、Vue Test Utils を使用する必要があるでしょう。
import { mount } from '@vue/test-utils';
import MyDialog from './MyDialog.vue';
test('open を実行するとダイアログが開くこと', () => {
const wrapper = mount(MyDialog);
wrapper.vm.open();
expect(wrapper.find('dialog').element.open).toBe(true);
});
まとめ
以下について記述しました。
- testing-library は、ラベルで要素を特定できる等、ユーザーの操作に近い方法でテストを書くことができる
- Vue Test Utils では、要素を特定するために、要素名や階層、順序の情報を使わざるを得ないケースがある
実施したいテストに応じてライブラリを選択するとよいでしょう。
備考
この記事は、社内向けに testing-library を紹介するために作成されました。
Discussion