この記事の目的
- ボタンを押したときに API 呼び出しを実行するケースを想定する。 この時、この時にAPI呼び出しが終了するまで再度ボタンを押されたくない、といった制御を行いたい。 これを実現するための Nuxt.js Component を作成する
- コンポーネントを作成して、これに Jest によるユニットテストを実施したい。 どのような手順でユニットテストを記載できるのかをコンポーネントの作成と合わせてみていく
実施バージョン
- vue: 2.6.12
- nuxt.js: 2.15.3
- yarn: 1.22.10
- jest: 26.6.3
- vue-jest: 3.0.4
また、コンポーネントボタンに Vuetify の v-btn
を用いているので、
- vuetify: 2.4.2
- @nuxtjs/vuetify: 1.11.3
も併せて利用している。
Component の作り方
npx create-nuxt-app
で生成されたディレクトリ内の components
内に vue
ファイルを作成する。 create-nuxt-app
で作成した場合は、ここに Logo.vue
が入っているはず。
ここでは、~/components/ui/CustomButton.vue
としてファイルを作成した。
Logo.vue
をある程度参考にできるわけだが、コンポーネントを作る中でいくつか疑問が出てきたので、その内容と解決策を述べる。
Component の利用方法
以下では省略するが、コンポーネントの呼び出し側のvueファイルでは以下のような定義で Component を登録済にしているとする。 これで、同 vue ファイル内で custom-button
として利用可能になる。 補足ではあるが、components への登録がパスカルケースにも関わらず、タグがケバブケースで使える理由についてはこちらを参照。
f<script>
import CustomButton from '~/components/ui/CustomButton.vue';
export default {
components: {
CustomButton,
},
// ... 後略
}
</script>
コンポーネントに親から属性を渡す
今回作りたい構成のイメージは以下のような感じになる。
ボタンのラベルを自由に設定できて、ボタンがクリックされるときに実行する関数を渡せるような感じ。
<template>
<button onclick="(ここにクリックしたときに実行させたいイベントが入る)">
{{ ここにラベルが入る }}
</button>
</template>
slot: タグの内容を渡す
まずは「ラベル」の部分(つまり、タグに挟まれている部分)をどのように渡すのかから見ていくと、これを実現するにはスロットを使う。
これは挿入したい場所に <slot></slot>
を書いておけばよい。
<template>
<button onclick="(ここにクリックしたときに実行させたいイベントが入る)">
<slot></slot>
</button>
</template>
<template>
<!-- 前略 -->
<custom-button>Component Button</custom-button>
<!-- 後略 -->
</template>
これで、ボタンのラベルに "Component Button" が表示される。
ここでは1つの値のみを渡したが、テンプレートによっては複数の slot を渡したい場合もある。Vueの2.6以降なので名前付きスロットが利用できる。 名前付き slot を呼び出すには <template v-slot:[slot名]>
を利用する。
<template>
<button onclick="(ここにクリックしたときに実行させたいイベントが入る)">
<slot name="front"></slot>
+++
<slot name="back"></slot>
</button>
</template>
<template>
<!-- 前略 -->
<custom-button>
<template v-slot:front>Before</template>
<template v-slot:back>After</template>
</custom-button>
<!-- 後略 -->
</template>
これでボタンのラベルには "Before +++ After" が表示される。
props: タグの属性で値を渡す
コンポーネントの利用者側で準備している変数や関数をコンポーネントに渡して利用したい場合、プロパティを用いる。 コンポーネント側に props
を定義することで、プロパティを渡すことができる。
ここでは、ボタンを押したときに呼び出したい関数を渡すこととする。 このような場合、以下のようにして clickCallback
で関数を渡せるようにした。 色々と短縮形はあるが、type
+ required: true
か type
+ default
(required
が false
の時) は最低限書くようにしたい。
<script>
export default {
props: {
clickCallback: {
type: Function,
required: false,
default: () => {},
}
},
// ... 後略
}
</script>
<template>
<!-- 前略 -->
<custom-button :clickCallback="clickFunction">
Component Button
</custom-button>
<!-- 後略 -->
</template>
<script>
import CustomButton from '~/components/ui/CustomButton.vue';
export default {
components: {
CustomButton,
},
methods: {
clickFunction() {
// ボタンが押されたときの挙動を記載
},
},
};
</script>
これで、呼び出し側で定義した関数を Component に渡すことができた。
渡された非同期関数が終了するまでの間ボタンが押せなくなるボタンコンポーネントの実装
どのような処理が行われるかが分からないので、clickCallback は Promise を返す関数 (つまり、await で処理が終了するまで待つような関数) を受け取るものとする。
あとは、この関数を呼び出す前後で内部に持つ変数を適時変化させてやればよい。 具体的なコードは以下の通りで、
- ボタンを押すとコンポーネント内に定義された click 関数が呼び出される
- click 関数の最初で
disabled = true
となり、ボタンが押せなくなる -
await this.clickCallback();
で callback 用に渡された関数を呼び出し、Promise が完了するまで待つ - その後、
disabled = false
となり、ボタンが押せるようになる
という流れを処理できる。
<template>
<v-btn :color="color" :block="block" :disabled="disabled" @click="click">
<slot></slot>
</v-btn>
</template>
<script>
export default {
props: {
clickCallback: {
type: Function,
default: () => {},
},
color: {
type: String,
default: 'success',
},
block: {
type: Boolean,
default: true,
},
},
data() {
return {
disabled: false,
};
},
methods: {
async click() {
this.disabled = true;
try {
await this.clickCallback();
} catch (error) {
console.log(error);
}
this.disabled = false;
},
},
};
</script>
<template>
<!-- 前略 -->
<custom-button color="info" :clickCallback="click">
Component Button
</custom-button>
<!-- 後略 -->
</template>
<script>
import CustomButton from '~/components/ui/CustomButton.vue';
export default {
components: {
CustomButton,
},
methods: {
async click() {
try {
// 外部API呼び出し
await this.$axios.get('...');
} catch (error) {
console.log(error);
}
},
},
};
</script>
上記の動きを動画にしてみると以下の通り。 この例では sleep して5秒ぐらい返ってこない API を呼び出しているが、その間、ボタンが押せなくなっていることが確認できる。
Jest によるコンポーネントの単体テスト
ただ作るだけなら以上で終了だが、Jest によってコンポーネントをテストする方法もこれを例にして学んでみる。
正直なところ、バックエンド側や関数に対して単体テストをしようとは思うが、UIに対してどのように単体テストを行うんだ? という疑問が最初はあった。
しかし、Vue の単体テストフレームワークを使うことで、Vue コンポーネントをでマウント(仮想描画) してくれ、イベントの発火も行うことができる。 そのため、単体テスト可能なレベルの複雑さにコンポーネントを適切に分割できれば、ちゃんと単体テストを書いて確かめることができる。
単体テストでは「コンポーネントにこういった入力・条件・操作をしたとき、そのコンポーネントはどういう状態になるか」を検証できるので、コンポーネントを一つの(ミュータブルな)クラスであると考え、単体テストを実施すると分かりやすいかもしれない。
以下では npx create-nuxt-app
の中の CLI 設定で Jest + Vuetify
をインストールしているものとして話を進める。
最初のテスト: Vuetify用単体テストの設定とコンポーネントの存在チェック
テンプレートで作られた test
ディレクトリの中には Logo.spec.js
ファイルが存在する。
import { mount } from '@vue/test-utils'
import Logo from '@/components/Logo.vue'
describe('Logo', () => {
test('is a Vue instance', () => {
const wrapper = mount(Logo)
expect(wrapper.vm).toBeTruthy()
})
})
まずは、これと同じテストを今回作った CustomButton.vue
用に作成する。 ファイル名は ~/test/components/ui/CustomButton.spec.js
とした。 Vuetify 用の設定があるので、このページを参考にしつつ設定する。
import { mount, createLocalVue } from '@vue/test-utils';
import Vuetify from 'vuetify';
import CustomButton from '~/components/ui/CustomButton.vue';
describe('CustomButton', () => {
const localVue = createLocalVue();
let vuetify;
beforeEach(() => {
vuetify = new Vuetify();
});
test('is a Vue instance', () => {
const wrapper = mount(CustomButton, {
localVue,
vuetify,
});
expect(wrapper.vm).toBeTruthy();
});
});
しかし、これだけで yarn test
を実施すると、以下の通りエラーとなる。
$ jest
PASS test/components/ui/CustomButton.spec.js
CustomButton
✓ is a Vue instance (37 ms)
console.error
[Vue warn]: Unknown custom element: <v-btn> - did you register the component correctly? For recursive components, make sure to provide the "name" option.
found in
---> <Anonymous>
<Root>
at warn (node_modules/vue/dist/vue.common.dev.js:630:15)
at createElm (node_modules/vue/dist/vue.common.dev.js:5929:11)
at VueComponent.patch [as __patch__] (node_modules/vue/dist/vue.common.dev.js:6466:7)
at VueComponent.Vue._update (node_modules/vue/dist/vue.common.dev.js:3942:19)
at VueComponent.updateComponent (node_modules/vue/dist/vue.common.dev.js:4063:10)
at Watcher.get (node_modules/vue/dist/vue.common.dev.js:4474:25)
at new Watcher (node_modules/vue/dist/vue.common.dev.js:4463:12)
このエラーが出るのは元の Vuetify Unit Testing のページをちゃんとよんでいないからなのだが、以下のページ(とUnit Testingのページ)で説明されている通り、
- Bootstrapping Vuetify に従ったセットアップスクリプトを設置する
- これを
jest.config.js
で読み込むように設定する
といった作業が必要になる。
ここでは、test/unit/setup.js
に以下のファイルを作り、jest.config.js
に以下の追記を行った。
import Vue from 'vue'
import Vuetify from 'vuetify'
Vue.use(Vuetify);
module.exports = {
// 省略
setupFiles: ['./test/unit/setup.js'],
};
これを追加したのち、yarn test
を実行すると最初のテストをパスできる。
$ yarn test
yarn run v1.22.10
$ jest
PASS test/components/ui/CustomButton.spec.js
CustomButton
✓ is a Vue instance (133 ms)
(... 中略 ...)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.114 s
Ran all test suites.
Done in 2.72s.
コンポーネントのテストの追加
実際にコンポーネントを使う想定でいくつかのテストを追加した。
今回追加したケースでは、それぞれ以下の項目をテストした。
- props, slot で渡した値がレンダリングにきちんと反映されるか
- click イベントが発生している最中、および前後で disabled の値がどのようになっているか
- clickCallback で例外が発生する場合はどうなるか
どのような項目が単体テストで取得でき、また、どのように検証できるかは以下のリファレンスを参考にした。
- https://vue-test-utils.vuejs.org/ja/api/wrapper/
- https://vue-test-utils.vuejs.org/ja/api/options.html
- https://jestjs.io/ja/docs/expect
補足として、一般的な Vue の単体テストでは mount でなく shallowMount を使った方がよいと書かれているが、v-btn
という別のコンポーネントを内蔵するテンプレートをテストするため、ここでは mount
を使って再帰的にマウントを行っている。
import { mount, createLocalVue } from '@vue/test-utils';
import Vuetify from 'vuetify';
import CustomButton from '~/components/ui/CustomButton.vue';
describe('CustomButton', () => {
const localVue = createLocalVue();
let vuetify;
beforeEach(() => {
vuetify = new Vuetify();
});
it('is a Vue instance', () => {
const wrapper = mount(CustomButton, {
localVue,
vuetify,
});
expect(wrapper.vm).toBeTruthy();
});
it('passes component arguments which props and slot data', async () => {
// v-btn は別の Component なので Component をスタブする shallowMount を使うと失敗する
const wrapper = mount(CustomButton, {
localVue,
vuetify,
propsData: { color: 'error' },
slots: {
default: 'Hello, World!',
},
});
const button = wrapper.find('button');
const classes = button.classes();
// color 情報はそのまま class に書き出されるので default の success は存在しない
// props で渡した error はそのままクラスになっている
expect(classes).toContain('error');
expect(classes).not.toContain('success');
// button タグには slots.default で渡したラベルが入っている
expect(button.text()).toBe('Hello, World!');
// clickCallback なしで 初期状態で click した場合正常に動作する (デフォルト関数のテスト)
await wrapper.find('button').trigger('click');
});
it('checks button disable status', async () => {
const wrapper = mount(CustomButton, {
localVue,
vuetify,
propsData: {
clickCallback: () => {
// callback イベントが呼び出されるときはボタンは無効
expect(wrapper.vm.disabled).toBe(true);
},
},
});
// 初期状態では有効
expect(wrapper.vm.disabled).toBe(false);
await wrapper.find('button').trigger('click');
// click イベントが終わった時点でも有効
expect(wrapper.vm.disabled).toBe(false);
});
it('raises exception in clickCallback', async () => {
const wrapper = mount(CustomButton, {
localVue,
vuetify,
propsData: {
clickCallback: () => {
// callback イベントが呼び出されるときはボタンは無効
expect(wrapper.vm.disabled).toBe(true);
throw 'error';
},
},
});
// 初期状態では有効
expect(wrapper.vm.disabled).toBe(false);
await wrapper.find('button').trigger('click');
// clickCallback 内で例外が throw されても正常に終了する
expect(wrapper.vm.disabled).toBe(false);
});
});
$ yarn test
yarn run v1.22.10
$ jest
PASS test/components/ui/CustomButton.spec.js
CustomButton
✓ is a Vue instance (122 ms)
✓ passes component arguments which props and slot data (69 ms)
✓ checks button disable status (31 ms)
✓ raises exception in clickCallback (268 ms)
console.log
error
at VueComponent.click (components/ui/CustomButton.vue:34:1)
... (略) ...
components/ui | 100 | 100 | 100 | 100 |
CustomButton.vue | 100 | 100 | 100 | 100 |
... (略) ...
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 2.31 s
Ran all test suites.
Done in 2.91s.
このようにコンポーネントのテストを記載することができた。
まとめ
実際に有用性のある小さいコンポーネントを作成し、その単体テストを記載した。
コンポーネントの単体テストは直接関数やオブジェクトに実施するバックエンドの一般的な単体テストとはやや異なり、イベントの発火やDOMのレンダリング結果を適用できるライブラリ(Wrapper)があってこそ容易な単体テストが実行可能に見える。 そのため、よく語られる単体テストだけでなく、ライブラリの使い方を合わせて覚える必要があると改めて感じた。