Help us understand the problem. What is going on with this article?

CompositionAPIを使ってcomposition functionを分離した状態でテストする

10/27 Vue-next版をこちらに書きました!この記事はVue 2.0用の記事です。

最初に

ついにComposition APIがrfc上でmergeされました:bangbang:
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を使う例として、今回、単純なカウントアプリを例にしていきます。

コンポーネントをつくる

components/Count.vue
<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を書いてみる

composition/count.ts
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する必要があります。

tests/components/Count.spec.ts
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のテスト

composition/count.spec.ts
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アプリを例にしていきます。

コンポーネントをつくる

components/Todo.vue
<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を書く

composition/todo.ts
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をテストする

components/Todo.spec.ts
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をテストする

composition/todo.spec.ts
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と同じように書けますね:ok_hand:

最後に

どうでしたでしょうか。VueのComposition APIを利用することで、単体テストも、より書きやすくなった印象があります。
Componentに依存していたロジックなども別ファイルにすることでロジックそのもののテストも容易となり、
Componentのテストも、UIの操作や表示などにより特化した形になります。
以前よりは簡単にテストがかけるようになっていますので、テスト駆動を始めたい方などもぜひComposition APIから始めていきましょう!!
明日は@dayjournalさんです。

jiko21
Vue.js + TypeScriptが多くなりましたが趣味でサーバーサイドとかインフラ, モバイルアプリ(Kotlin, Swift)もやってます.
https://jiko21.me
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away