Nuxt.js vuex-module-decoratorsで書かれたストアをJestを使ってテストする


はじめに

こちら記事では、vuex-module-decoratorsを使用してストア記述することによって、Nuxt.js + TypeScriptにおける型課題を解決しました。
今回は、前回作成したストアをもとに、テストコードを書いていきます。

前提条件

  • create-nuxt-app で言語にTypeScript、テストフレームワークにJestを選択してプロジェクトを作成済
  • vuex-module-decoratorsで作成したストアがある
  • Jestに対する基礎的な理解

前回作成したコードの再掲

~/store/todo.ts
import { Module, VuexModule, Mutation, Action } from 'vuex-module-decorators'
import { $axios } from '~/utils/api'

interface Todo {
  id: number
  title: string
  description: string
  done: boolean
}

@Module({
  name: 'todo',
  stateFactory: true,
  namespaced: true
})
export default class Todos extends VuexModule {
  private todos: Todo[] = []

  public get getTodos() {
    return this.todos
  }

  public get getTodo() {
    return (id: number) => this.todos.find((todo) => todo.id === id)
  }

  public get getTodoCount() {
    return this.todos.length
  }

  @Mutation
  private add(todo: Todo) {
    this.todos.push(todo)
  }

  @Mutation
  private remove(id: number) {
    this.todos = this.todos.filter((todo) => todo.id !== id)
  }

  @Mutation
  set(todos: Todo[]) {
    this.todos = todos
  }

  @Action({ rawError: true })
  public async fetchTodos() {
    const { data } = await $axios.get<Todo[]>('/api/todos')
    this.set(data)
  }

  @Action({ rawError: true })
  public async createTodo(payload: Todo) {
    const { data } = await $axios.post<Todo>('/api/todo', payload)
    this.add(data)
  }

  @Action({ rawError: true })
  async deleteTodo(id: number) {
    await $axios.delete(`/api/todo/${id}`)
    this.remove(id)
  }
}

tsconfig.tsに追記

最新のcreate-nuxt-app(今回はv2.15.0)を使用しているのなら、言語にTypeScript、テストフレームワークにJestを選択している場合テスト環境を自動で構築してくれます。

ただし、一点変更を加えなければいけない箇所があるのでそこを修正します。
テストコードをTypeScriptで記述するために、tsconfig.tsファイルに、"@types/jest"を追加します。

tsconfig.ts
{
  "compilerOptions": {
    "target": "es2018",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["esnext", "esnext.asynciterable", "dom"],
    "esModuleInterop": true,
    "allowJs": true,
    "sourceMap": true,
    "strict": true,
    "noEmit": true,
    "experimentalDecorators": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./*"],
      "@/*": ["./*"]
    },
    "types": [
      "@types/node",
      "@nuxt/types",
      "@nuxtjs/axios",
++    "@types/jest"
    ],
  },
  "exclude": ["node_modules", ".nuxt", "dist"]
}

これを追加すると、Jestのdescribe()などのメソッドを使用したときvscodeに怒られなくなります。
変更が反映されないときには、一度vscodeを再起動してみてください。

~/test/store/todo.tsファイルを作成

テストコードは、基本的にtestフォルダ配下に作成していくことになります。testフォルダ配下にまずはstoreフォルダを作成し、その下に今回のテスト対象である~/store/todo.tsに対するtodo.spec.tsを作成します。

Jestはデフォルトで*.test.js*.spec.jsもしくは__tests__という名前のディレクトリ以下のファイルをテストファイルとみなします。

ファイル名に.spec.を使用してテストファイルだということを表現します。

テストを記述していく

どのようなテストを書くか

Vuexのテストでは、ActionsGettersを対象にテストを記述してきます。なぜなら、MutationsActionsで、StateGettersでテストの担保が取れているからです。(また今回のケースにおいては、MutationsStateprivateで実装したという側面もあります。)

また、Gettersのテストに関しても、ただ単位Stateの値を返しているようなテストを記述する必要はないでしょう。もしGettersが複雑な計算を行っているならば、テストコードを書く価値があります。

テストの中心は、Actionsに対するテストになります。
Actionsに対するテストは複雑になりがちです。Actionsは、非同期でAPIを呼び出すことが多いので、モックを作る必要があるでしょう。さらに、APIが常に結果を返すとも限らないですから、その点も考慮する必要があるでしょう。APIから受け取ったレスポンスを正しくコミットできているかがテストの中心となります。

幸いなことに、今回はTypeScriptを使用しているため、しっかりと実装されているのならばpayloadが正しいかの観点は既に担保されていることになります。

ストアを初期化する

それでは、実際にテストコードを記述していきます。
まずは、ストアをインポートしてbeforeEach()メソッドで各テストの前にストアが初期化されるようにします。

~/test/store/todo.spec.ts
import { createStore } from '~/.nuxt/store'
import { initialiseStores, TodoStore } from '~/utils/store-accessor'

describe('store/todo', () => {
  beforeEach(() => {
    initialiseStores(createStore())
  })
})

これで、テストの中でコンポーネントで使用するようにストアを利用することができます。

モックを作成する

Actionsのテストを書いていく前に、モックを作成しましょう。テストのたびAPIに接続していると、壊れやすく時間のかかるものになってしまいます。

axiosをモック化して、APIに接続する代わりに、ダミーデータを返すようにします。

モジュールをモックする

Jestを使用して、モジュールをモックするのは簡単です。テスト元のファイルtodo.tsでは、axiosを以下のように呼び出していました。

~/store/todo.ts
import { $axios } from '~/utils/api'

このモジュールをjest.mock()メソッドを使用することで乗っ取ることができます。

~/test/store/todo.spec.ts
jest.mock('~/utils/api')

~/utils/api{ $axios }関数をexportしていますが、jest.fn()に置き換えられている状態になります。すごい。

モックを実装する

モジュールのモックができたので、モックを実装していきます。

~/utils/apiモジュールは$axios関数をexportしており、$axios関数はget()メソッドを持っているので次のようになります。

~/test/store/todo.spec.ts
jest.mock('~/utils/api', () => ({
  $axios: {
    get: jest.fn(() =>
      Promise.resolve({
        data: res
      })
    )
  }

jest.fn()メソッドでモック関数を作成できます。axiosは非同期に動作するので、get()メソッドの返り値はPromise.resoleve()を渡しています。

ダミーデータも作成しておきましょう。

~/test/store/todo.spec.ts
const res = [
  {
    id: 1,
    title: 'リスト1',
    description: 'lorem ipsum',
    done: true
  },
  {
    id: 2,
    title: 'リスト2',
    description: 'lorem ipsum',
    done: false
  },
  {
    id: 3,
    title: 'リスト3',
    description: 'lorem ipsum',
    done: true
  }
]

これで準備は整いました。テストを書いていきましょう。

fetchTodosをテストする

それでは、実際にfetchTodos()に対するテストコードを書いてきます。
fetchTodos()アクションを実行した結果、stateに正しく反映されているかを見ていきます。

~/test/store/todo.spec.ts

describe('TodosModule', () => {
  beforeEach(() => {
    initialiseStores(createStore())
  })

  describe('Actions', () => {
    test('fetchTodos', async () => {
      await TodoStore.fetchTodos()
      expect(TodoStore.getTodos).toEqual(res)
    })
  })
})

fetchTodos()アクションは非同期に実行されるので、async/awaitで呼び出す必要があります。

テストを実行するには、yarn test <ファイル名>です。<ファイル名>を省略した場合には、すべてのテストが実行されます。

yarn test test/store/todos.spec.ts
yarn run v1.22.0
$ jest test/store/todos.spec.ts
 PASS  test/store/todos.spec.ts
  store/todo
    Actions
      ✓ fetchTodos (12ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        5.116s
Ran all test suites matching /test\/store\/todos.spec.ts/i.
✨  Done in 6.46s.

テストが無事成功しましたね。

Gettersテスト

次に、Gettersもテストしておきましょう。getTodo()getTodoCount()をテスト対象とします。冒頭で述べたとおり、getTodos()はただ値を返しているだけなので、テスト対象からは外します。

describe('Getters', () => {
    beforeEach(async () => {
      await TodoStore.fetchTodos()
    })

    test('getTodo 存在するID', () => {
      expect(TodoStore.getTodo(2)).toEqual(res[1])
    })

    test('getTodo 存在しないID', () => {
      expect(TodoStore.getTodo(4)).toBeUndefined()
    })

    test('getTodoCount', () => {
      expect(TodoStore.getTodoCount).toEqual(3)
    })
  })

ストアの初期状態は空の配列なので、適切にテストを実施できるようにbeforeEach()で各Gettersテストの前にはfetchTodos()が実行されるようにします。

正しくテストされているか実施してみましょう。

yarn test test/store/todos.spec.ts
yarn run v1.22.0
$ jest test/store/todos.spec.ts
 PASS  test/store/todos.spec.ts
  store/todo
    Actions
      ✓ fetchTodos (9ms)
    Getters
      ✓ getTodo 存在するID (1ms)
      ✓ getTodo 存在しないID (2ms)
      ✓ getTodoCount (1ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        2.554s
Ran all test suites matching /test\/store\/todos.spec.ts/i.
✨  Done in 4.33s.

最後に、コードの全体像です。

~/test/store/todo.spec.ts
import { createStore } from '~/.nuxt/store'
import { initialiseStores, TodoStore } from '~/utils/store-accessor'

const res = [
  {
    id: 1,
    title: 'リスト1',
    description: 'lorem ipsum',
    done: true
  },
  {
    id: 2,
    title: 'リスト2',
    description: 'lorem ipsum',
    done: false
  },
  {
    id: 3,
    title: 'リスト3',
    description: 'lorem ipsum',
    done: true
  }
]

jest.mock('~/utils/api', () => ({
  $axios: {
    get: jest.fn(() =>
      Promise.resolve({
        data: res
      })
    )
  }
}))

describe('store/todo', () => {
  beforeEach(() => {
    initialiseStores(createStore())
  })

  describe('Actions', () => {
    test('fetchTodos', async () => {
      await TodoStore.fetchTodos()
      expect(TodoStore.getTodos).toEqual(res)
    })
  })

  describe('Getters', () => {
    beforeEach(async () => {
      await TodoStore.fetchTodos()
    })

    test('getTodo 存在するID', () => {
      expect(TodoStore.getTodo(2)).toEqual(res[1])
    })

    test('getTodo 存在しないID', () => {
      expect(TodoStore.getTodo(4)).toBeUndefined()
    })

    test('getTodoCount', () => {
      expect(TodoStore.getTodoCount).toEqual(3)
    })
  })
})

終わりに

vuex-module-decoratorsを使用したストアでも、変わりなくテストを実行することができました。