10/27 Vue-next版をこちらに書きました!この記事はVue 2.0用の記事です。
最初に
ついにComposition APIがrfc上でmergeされました
rfc上でmergeされたこともあって、本格的に触っていく人も増えていくのではないか、と思います。
Composition APIを使うことで型推論がよくなる、IDEの恩恵を受けやすいだけでなく、
少し工夫するだけでなんちゃってSingle Store patternなどができたりします。
しかしながら、Composition API自体が新しい、ということもあってテストに関する情報がほとんどありません。
そこで、今回はComposition Functionを分離した状態でのテストの書き方について説明します。
Composition Functionを分離するって?
公式DocumentだとLogic reuse code organizationと書かれている部分です。
これを使うことで今までコンポーネント内で書いていたstateやstateの変更を行う関数などをすべて別ファイルに引き剥がすことが可能となります。
例えば、単純なカウントアプリを例にとります。
<template>
<div class="count">
<h1>{{countValue}}</h1>
<div class="button-box">
<button class="plus" @click="increment">+</button>
<button class="minus" @click="decrement">-</button>
</div>
</div>
</template>
<script lang="ts">
import { createComponent } from '@vue/composition-api';
import { ref } from '@vue/composition-api';
export default createComponent({
name: 'Count',
setup() {
const countValue = ref(0);
const increment = () => {
countValue.value += 1;
};
const decrement = () => {
countValue.value -= 1;
};
return {
countValue,
increment,
decrement,
};
},
});
</script>
<style scoped>
</style>
今回、すべてComposition APIを使って書いていますが、通常の場合、カウントを保存するstateだったり、カウントするための処理をComponentに書くと思います。(Vuexを使えばそんなもんComponentに書かないやん、といわれそうですが...)
Composition APIの場合、このようなロジックをComponent外部の別ファイルに移動させることができるようになっています!
今回の例だと、
const countValue = ref(0);
const increment = () => {
countValue.value += 1;
};
const decrement = () => {
countValue.value -= 1;
};
の部分を別ファイルに移動し、Component側ではこれらを呼び出す、といったことができます。
実際にテストを書いてみる
コード自体はGitHubにあげております。
環境は
ライブラリ | バージョン |
---|---|
@vue/cli | 4.0.5 |
vue | 2.6.10 |
@vue/composition-api | 0.3.2 |
@vue/test-utils | 1.0.0-beta.29 |
などを利用しています。
Refを使う場合
Refを使う例として、今回、単純なカウントアプリを例にしていきます。
コンポーネントをつくる
<template>
<div class="count">
<h1>{{countValue}}</h1>
<div class="button-box">
<button class="plus" @click="increment">+</button>
<button class="minus" @click="decrement">-</button>
</div>
</div>
</template>
<script lang="ts">
import { createComponent } from '@vue/composition-api';
import { useCount } from '@/composition/count';
export default createComponent({
name: 'Count',
setup() {
// composition functionは次のところで見せます
const { countValue, increment, decrement } = useCount();
return {
countValue,
increment,
decrement,
};
},
});
</script>
<style scoped>
</style>
Componentとしては単純で、カウンターが表示されて、+/-ボタンがあるだけです。
Composition functionを書いてみる
import { ref } from '@vue/composition-api';
// composition function
const useCount = () => {
// dataみたいなもの
const countValue = ref(0);
// 1増やすやつ
const increment = () => {
countValue.value += 1;
};
// 1減らすやつ
const decrement = () => {
countValue.value -= 1;
};
// 作ったやつをここで返す
return {
countValue,
increment,
decrement,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useCount };
composition functionを外部モジュールにする際、関数内で生成し、それをreturnで返す、といった例が多いです。
テストを書いてみる
それでは、実際にテストを書いていきます。
今回はJestを利用しています。
Componentをテストする
基本的にはVueのComponentのテストと変わりません。
しかし、compositionAPIを使っている部分(dataとか)をMockする必要があります。
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueCompositionApi from '@vue/composition-api';
import Count from '@/components/Count.vue';
import * as composition from '@/composition/count';
// localVueを使ってComposition APIを有効にする
const localVue = createLocalVue();
localVue.use(VueCompositionApi);
let incrementMock: jest.Mock;
let decrementMock: jest.Mock;
describe('Count.vue', () => {
beforeEach(() => {
jest.mock('@/composition/count');
incrementMock = jest.fn();
decrementMock = jest.fn();
jest.spyOn(composition, 'useCount').mockReturnValue({
countValue: 0 as any,
increment: incrementMock,
decrement: decrementMock,
});
});
// snapshotを使って描画をチェック
it('correctly renders initial html', () => {
const wrapper = shallowMount(Count, {
localVue,
});
expect(wrapper.html()).toMatchSnapshot();
});
// mockした関数が呼ばれたかチェック
it('call increment when plus buttons is clicked', () => {
const wrapper = shallowMount(Count, {
localVue,
});
wrapper.find('button.plus').trigger('click');
expect(incrementMock).toHaveBeenCalled();
});
// mockした関数が呼ばれたかチェック
it('call increment when minus buttons is clicked', () => {
const wrapper = shallowMount(Count, {
localVue,
});
wrapper.find('button.minus').trigger('click');
expect(decrementMock).toHaveBeenCalled();
});
});
今回、jestのspyOn
を使って関数のmockを行いました。
jest.spyOn(composition, 'useCount').mockReturnValue({
countValue: 0 as any,
increment: incrementMock,
decrement: decrementMock,
});
ここで注意しなければいけないのが、ref
のmock方法です。
Composition APIのコードを見てみると
export interface Ref<T> {
value: T;
}
といった記述があるため、この構造と同じようにmock値を作らなければいけない、と思いがちですが、これだとテストが実行できません。
そのため直接mockしたい値を入れる必要があります
。
Composition Functionのテスト
import VueCompositionApi from '@vue/composition-api';
import { createLocalVue } from '@vue/test-utils';
import { useCount } from '@/composition/count';
// localVueを使ってComposition APIを有効にする
const localVue = createLocalVue();
localVue.use(VueCompositionApi);
describe('count.spec.ts', () => {
it('increment should work properly', () => {
const { countValue, increment, decrement } = useCount();
increment();
expect(countValue.value).toEqual(1);
});
it('decrement should work properly', () => {
const { countValue, increment, decrement } = useCount();
decrement();
expect(countValue.value).toEqual(-1);
});
});
ここはjestでモジュールをテストする場合と変わらずに簡単にかけます。
ただし、注意として、
// localVueを使ってComposition APIを有効にする
const localVue = createLocalVue();
localVue.use(VueCompositionApi);
という行を書いてComposition APIを有効にする必要があります。
Reactiveを使う場合
Reactiveを使う例として、今回、単純なTodoアプリを例にしていきます。
コンポーネントをつくる
<template>
<div class="count">
<input id="todo-input" v-model="text"/>
<button class="add-btn" @click="onSubmit">追加</button>
<ul>
<li v-for="(task, i) in todo.todos" :key="i">
<p>{{task}}</p>
<button class="delete-btn" @click="deleteTodo(i)">Delete</button>
</li>
</ul>
</div>
</template>
<script lang="ts">
import { createComponent, ref } from '@vue/composition-api';
import { useTodo } from '@/composition/todo';
export default createComponent({
name: 'Todo',
setup() {
// textフォームのv-model
const text = ref('');
const { todo, addTodo, deleteTodo } = useTodo();
const onSubmit = () => {
addTodo(text.value);
text.value = '';
};
return {
text,
onSubmit,
todo,
addTodo,
deleteTodo,
};
},
});
</script>
<style scoped>
</style>
こちらも、基本的には前回のTodoアプリと同様、シンプルな構成になっています。
Composition Functionを書く
import { computed, reactive } from '@vue/composition-api';
const useTodo = () => {
const todo = reactive({
todos: [] as string[],
length: computed(() => todo.todos.length),
}) as any;
const addTodo = (item: string) => {
todo.todos.push(item);
};
const deleteTodo = (index: number) => {
todo.todos.splice(index, 1);
};
return {
todo,
addTodo,
deleteTodo,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useTodo };
ロジックとstateがきれいに分離できますね...
今回、computed
なども追加しています。
テストを書いてみる
これについてもテストを書いてみます。
Componentをテストする
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueCompositionApi from '@vue/composition-api';
import Todo from '@/components/Todo.vue';
import * as composition from '@/composition/todo';
const localVue = createLocalVue();
localVue.use(VueCompositionApi);
let addTodoMock: jest.Mock;
let deleteTodoMock: jest.Mock;
describe('Todo.vue', () => {
beforeEach(() => {
jest.mock('@/composition/todo');
addTodoMock = jest.fn();
deleteTodoMock = jest.fn();
const TODOS = [
'アドベントカレンダー',
'修論',
'筋トレ',
];
jest.spyOn(composition, 'useTodo').mockReturnValue({
todo: {
todos: TODOS,
length: () => TODOS.length,
},
addTodo: addTodoMock,
deleteTodo: deleteTodoMock,
});
});
it('correctly renders initial html', () => {
const wrapper = shallowMount(Todo, {
localVue,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('correctly call addTodo when `追加` button is clicked', () => {
const wrapper = shallowMount(Todo, {
localVue,
});
wrapper.find('#todo-input').setValue('ポスターセッション');
wrapper.find('.add-btn').trigger('click');
expect(addTodoMock).toHaveBeenCalledWith('ポスターセッション');
expect(wrapper.html()).toMatchSnapshot();
});
it('correctly call deleteTodo when `Delete` button is clicked', () => {
const wrapper = shallowMount(Todo, {
localVue,
});
const INDEX = 1;
wrapper.findAll('.delete-btn').at(INDEX).trigger('click');
expect(deleteTodoMock).toHaveBeenCalledWith(INDEX);
});
});
基本的にカウントの際と同じですが、ReactiveのMockは、Objectで渡してあげます。
Composition Functionをテストする
import VueCompositionApi from '@vue/composition-api';
import { createLocalVue } from '@vue/test-utils';
import { useTodo } from '@/composition/todo';
const localVue = createLocalVue();
localVue.use(VueCompositionApi);
describe('todo.spec.ts', () => {
it('addTodo should work properly', () => {
const { todo, addTodo, deleteTodo } = useTodo();
addTodo('hogehoge');
expect(todo.todos).toEqual(['hogehoge']);
});
it('addTodo should work properly', () => {
const TODOS = [
'アドベントカレンダー',
'修論',
'筋トレ',
];
const EXPECTED = [
'アドベントカレンダー',
'筋トレ',
];
const { todo, addTodo, deleteTodo } = useTodo();
todo.todos = TODOS;
deleteTodo(1);
expect(todo.todos).toEqual(EXPECTED);
});
it('computed prop `length` should work properly', () => {
const TODOS = [
'アドベントカレンダー',
'修論',
'筋トレ',
];
const { todo, addTodo, deleteTodo } = useTodo();
todo.todos = TODOS;
expect(todo.length).toEqual(TODOS.length);
});
});
これもCountのcompsitionと同じように書けますね
最後に
どうでしたでしょうか。VueのComposition APIを利用することで、単体テストも、より書きやすくなった印象があります。
Componentに依存していたロジックなども別ファイルにすることでロジックそのもののテストも容易となり、
Componentのテストも、UIの操作や表示などにより特化した形になります。
以前よりは簡単にテストがかけるようになっていますので、テスト駆動を始めたい方などもぜひComposition APIから始めていきましょう!!
明日は@dayjournalさんです。