Vue 3.0にてcomposition apiを使ったcomposableパターンでテストを書く


昔Advent Calendarのために書いた記事をvue-next版に書き直しました。
https://qiita.com/jiko21/items/12c79b7b831276e9a088

TL;DR

  • composition-apiを使うことでロジックをUIから分離してテストできる
  • ただし、少し考えてテストを書く必要あり

はじめに

9/18にVue 3.0がリリースされました!
これにより、composition-apiがプラグイン無しで利用できるようになりました。
composition-apiの詳しい使い方はここでは省略しますが、これにより、コンポーネントからロジックを別ファイルへと分離することもできます。

今回は、コンポーネントからロジックを分離した際のテストの書き方を説明します。

コードはすべてこちらにあります。

ロジックとUIの分離

例えば、TODOアプリの場合は、TODOリストに追加、削除する処理や、TODOリストのデータなどをComponentに直接記述するのではなく、
あくまで別のファイルに記述し、Component側でそれらを呼び出す、といったことがComposition-apiでは可能となります。

@/composable/todo.ts
import { computed, reactive } from 'vue';

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,
  };
};

export default useTodo;
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 { defineComponent, ref } from 'vue';
import useTodo from '@/composable/todo';

export default defineComponent({
  name: 'Todo',
  setup() {
    const text = ref('');
    const { todo, addTodo, deleteTodo } = useTodo(); // 外部ファイルに書いたロジックを読み込む
    ...
  },
});
</script>

<style scoped>
</style>

テストの実装

このようにロジック(composable)とUIを分離したので次はテストの実装方針を説明します。
まずは、ロジック側からですが、

実際にモジュールとして正しく動くか

を検証します。(当たり前っちゃ当たり前かもですが)
そして、UI側では

ボタンタップ時にロジックをcallできているか

を検証します。

ロジック側のテスト

サービス層やutilで書いたテストコードと同様に、実際に呼び出しを行い検証を行います。
(もしcomosableに何らかの依存関係がある場合はそれをmockしてください)

todo.spec.ts
import useTodo from '@/composable/todo';

describe('todo.spec.ts', () => {
  it('addTodo should work properly', () => {
    const { todo, addTodo, deleteTodo } = useTodo();
    addTodo('hogehoge');
    expect(todo.todos).toEqual(['hogehoge']);
  });
  ...
});

UI側のテスト

ロジック(composable)をモックしてやり、それが呼ばれたかを検証します。
compsable内にstateがある場合はそれが表示されるかも確認しておきましょう。

Todo.spec.ts
import { mount, VueWrapper } from '@vue/test-utils';
import Todo from '@/components/Todo.vue';
import * as composable from '@/composable/todo';

describe('Todo.vue', () => {
  let wrapper: VueWrapper<any>;
  let addTodoMock: jest.Mock;
  let deleteTodoMock: jest.Mock;

  beforeEach(() => {
    jest.mock('@/composable/todo');
    addTodoMock = jest.fn();
    deleteTodoMock = jest.fn();
    const TODOS = [
      'アドベントカレンダー',
      '修論',
      '筋トレ',
    ];
    jest.spyOn(composable, 'default').mockReturnValue({
      // Reactiveはデータ構造そのままでOK!
      todo: {
        todos: TODOS,
        length: () => TODOS.length,
      },
      addTodo: addTodoMock,
      deleteTodo: deleteTodoMock,
    });
    wrapper = mount(Todo);
  });

  it('correctly renders initial html', () => {
    expect(wrapper.html()).toMatchSnapshot();
  });

  it('correctly call deleteTodo when `Delete` button is clicked', () => {
    const INDEX = 1;
    wrapper.findAll('.delete-btn')[INDEX].trigger('click');
    expect(deleteTodoMock).toHaveBeenCalledWith(INDEX);
  });
  ...
});

ここで重要なのは、Reactiveのモック方法です。
データ構造そのままのObjectをmockしてやればできます。

これはRefも同様で、

const countValue = ref(0);
countValue.value;

のように使用するので

jest.spyOn(composable, 'default').mockReturnValue({
  countValue: {
    value: 0,
  }, 
  increment: incrementMock,
  decrement: decrementMock,
});

と書いてしまいたくなりますが

count.spec.ts
jest.spyOn(composable, 'default').mockReturnValue({
  countValue: 0 as any,
  increment: incrementMock,
  decrement: decrementMock,
});
wrapper = mount(Count);

のように書いてやる必要があります。

最後に

このようにすれば、composableとしてロジックを分離した場合でもテストがかけます。
ただし、vue-test-utilsが9/28時点でまだ2.0.0-beta.5なのでまだまだ安定しないかもしれません。