【Nuxt.js + Jest】初学者向けにフロントエンドの単体テスト実装解説と、中々実装方法が見つからなかった点の備忘録

初めまして。弊社エンジニアでは現在一番新参です!

フロントエンジニアの福留(@fal_engineer)です。

ビール派です!

以下は先輩方の大変素晴らしい記事です。

とてもわかりやすく楽しく読むことの出来る構成となっております。まだ読まれていない方はぜひご覧ください!

xmart-techblog.hatenablog.com

xmart-techblog.hatenablog.com

Jestについて

JestとはMeta(旧Facebook)社が開発しているJavaScriptのテストフレームワークで、TypeScript, Babel, React, Vue, Angularをはじめとした様々なソフトウェアで利用されています。

パブリックだけでも640万リポジトリにタグ付けされており、独立したテストを並列で実行できること、ドキュメントの豊富さやナレッジの蓄積度の高さからデファクトスタンダードとなっています。

jestjs.io

ユーティリティをパッケージとして読み込むことで仮想DOMを仮想のブラウザ上で再現しテストを実行可能なため、Vue, React, Solid, Angularのコンポーネント単体テストも可能です。

フロントエンドの単体テストを実装するメリット

  • 設計通りに実装が行われているかを確認することが出来る
  • 変更時、既存の動作に支障を来していないかを確認することが出来る
  • テストコードがあることで、仕様を読み解きやすくなる

Jestの書き方

例えば、以下のようなVueコンポーネントです。

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="$emit('click')"> + 1 </button>
  </div>
</template>
<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'count',
  props: {
    count: {
      type: Number,
      default: 0
    }
  }
})
</script>

当該コンポーネントの要件をまとめると、 - propsとしてcountを受け取り、pタグ内で表示している - button要素クリック時にclickイベントをemitする

となります。

こちらのUTをJestで実装すると、以下のようになります。

import { mount } from '@vue/test-utils'
import Count from '~/components/Count.vue'

describe('<Count />', () => {
  const propsData = {
    count: 1,
  }
  const count = mount(Count, {
    propsData,
  })

  it('props.countがpタグ内で表示されていること', () => {
    expect(count.find('p').text()).toBe(props.count.toString())
  })

  it('button要素クリック時、clickイベントがemitされること', () => {
    count.find('button').trigger('click')
    expect(count.emitted()).toHaveProperty('click')
  })
})

vue/test-utilsは読んでの通り、vueコンポーネントをテストで使用する上でのユーティリティです。

vue/test-utilsからはmountをインポートしています。

describe文では第一引数にテストスイート名をテキストで渡します。

テスト結果が以下のように一覧で表示されていくため、今回ではわかりやすいようにコンポーネント名を記述しています。

読んでみる

  const propsData = {
    count: 1,
  }
  const count = mount(Count, {
    propsData,
  })

の部分についてです。

propsDataという名前でCountコンポーネントに渡すプロパティを定義しています。

countという定数にはCountコンポーネントをユーティリティからインポートしたmount関数を使い、コンポーネントのラッパーを代入します。

またmount関数の第二引数はマウントのオプションです。 v1.test-utils.vuejs.org

今回はpropsのモックを準備してあげたかったのでpropsDataを渡しています({ propsData } は { propsData: propsData }の糖衣構文です)。

mount関数についてですが、shallowMountというものもあります。子コンポーネントをスタブとしてHTMLに描画するか、展開してHTMLに描画するかが違いとしてありますが、今回はコンポーネントを使用しているコンポーネントではないため、どちらを使っても問題ありません。

通常はshallowMountの方がテスト実行が早くなります。


  it('props.countがpタグ内で表示されていること', () => {
    expect(count.find('p').text()).toBe(propsData.count.toString())
  })

まず、it('',()=>{})についてですが、こちらはdescribeと同様にテストの名前付けです。(test('',()=>{})と同義です

通常、itが一つのテストケースであり、目的は一つであるべきと考えられます。

it内の全てのexpect関数の結果がfalseでない時にCLIに正常完了のチェックマークが表示されます。

expectはマッチャと呼ばれる、引数に対してプロパティの値を比較して正となるかをチェックする関数です。 jestjs.io

expect(true).toBe(false)

のように書くと、(args) === falseをチェックするのでfalseとなり、テストは失敗します。

また、

expect(1).toBeTruthy()

と書くと !!(args) === true をチェックするので、テスト結果は正となります。

今回はcount(コンポーネントのラッパー)に対してpタグをfindを使ってエレメントを取得しています。

findは条件に当てはまるDOM要素を取得します。

findではセレクタを使って要素を検索することもでき、classNameから取得したい場合はfind(.class-name)、idからはfind(#id-name)となります。

取得したDOMのラッパーにはtext()プロパティがあり、要素内のテキストを取得します。

今回はpタグにprops.countが表示されていることを確かめる必要があったので、props.countを文字列として変換し比較しています。


  it('button要素クリック時、clickイベントがemitされること', () => {
    count.find('button').trigger('click')
    expect(count.emitted()).toHaveProperty('click')
  })

コンポーネントや画面要素に対してイベントをシミュレートしたい場合はDOM要素のラッパーに対してtriggerを発火させます。

今回はbutton要素のclickイベントを発火させたかったので、文字列としてclickを渡しました。(カスタムイベントも同様です。) またイベント発火時にPromiseを返す場合(非同期処理の場合)はasync-awaitを使用します。

その後、コンポーネントのラッパーのプロパティであるemitted()をexpect関数に渡し、toHavePropertyと比較を行います。

第一引数としてプロパティ名(この場合は発生したイベント名)、第二引数としてvalueを渡しexpect内との比較が可能です。 Expect · Jest

環境

jestを実行する上で以下が必要だったのでインストールしました。

    "@types/jest": "^29.0.0",
    "@vue/test-utils": "^1.2.2",
    "babel-core": "^6.26.3",
    "jest": "^28.1.3",
    "jest-environment-jsdom": "^29.0.2",
    "jsdom": "^20.0.0",
    "ts-jest": "^28.0.8",
    "ts-node": "^10.9.1",
    "typescript": "^4.8.2",
    "vue-jest": "^3.0.7",
    "vue-loader": "^15.10.0"

その他: - nuxt: 2.15.8 - vue: 2.6.14

jestのバージョンについてですが、通常にインストールすると現在(2022/9)は29.x.x系がインストールされます。 29系はvue-3用のバージョンなので、28系をインストールする必要があります。

jest.config.js

module.exports = {
  roots: ['.'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
    '^~/(.*)$': '<rootDir>/$1',
    '^vue$': 'vue/dist/vue.common.js',
  },
  moduleFileExtensions: ['ts', 'js', 'vue', 'json', 'svg'],
  transform: {
    '.*\\.(vue)$': 'vue-jest',
  },
  collectCoverage: true,
  collectCoverageFrom: [
    '<rootDir>/components/**/*.vue',
    '<rootDir>/pages/**/*.vue',
  ],
  testEnvironment: 'jsdom',
  testPathIgnorePatterns: ['/node_modules/'],
}

tsconfig.json

paths: ["jest"] <- 追加

vue.shim.d.ts

declare module '*.vue' {
  import Vue from 'vue'
  export default Vue
}

中々実装方法が見つからなかったもの

モジュール分割されたVuexのモック作成

Vuexがモジュール分割されている場合のモック作成です。

本来ならばこのようにしてStoreのモックを作ってあげるのですが、

import Vuex from 'vuex'
const localVue = createLocalVue()
localVue.use(Vuex)

describe('', () => {
  const store = new Vuex.Store({
    state: {},
    actions: {}
  })
  const wrapper = shallowMount(component, { store, localVue })

Vuexのモジュールは名前解決で呼び出されるため、コンポーネントがAModuleとBModuleを呼び出している場合はStore連携がうまく行きません。

そういった場合は、このような実装になります。

import Vuex from 'vuex'
const localVue = createLocalVue()
localVue.use(Vuex)

describe('', () => {
  const AModule = {
    state: {},
    actions: {},
    namespaced: true
  }

  const BModule = {
    state: {},
    actions: {},
    namespaced: true
  }

  const store = new Vuex.Store({
    modules: { AModule, BModule }
  })
  const wrapper = shallowMount(component, { store, localVue })

Storeのモックをそのままオブジェクトとして渡すのではなく、namespaced = trueとしてmodulesにkey-valueで渡してあげると名前解決が実行され、上手く動きます。

@nuxt/deviceのモック作成

@nuxt/deviceを使ったコンポーネントでは、this.$xxxのようにして呼び出しているオブジェクトがundefinedとなるためテスト実行が上手くいきません。そのため、モックとしてコンポーネントに渡してあげる必要があります。

$device等が挙げられます。

使うオプションはマウント(mount, shallowMount)関数のmocksプロパティです。

const localVue = createLocalVue()

describe('', () => {
  const mocks = {
    $device: {
      isMobile: true,
      isDesktop: false,
    }
  }
  const wrapper = shallowMount(component, { mocks, localVue })

localForageのモック作成

nuxt/deiceと同様で、mocksに格納した状態でコンポーネントオプションとして渡してあげます。

this.$route.query, this.$router.push等も同様です。

まとめ

最後まで読んでいただいて、ありがとうございました!

フロントエンドのテストは「どこまでやるか」等がハッキリとアーキテクチャとしてスタンダードになっておらず、まだまだ黎明期だと思われます。

しかしプロダクトの品質管理として、「画面の主要機能が動作するか」、「JSONを渡した時の状態の網羅」はしていきたいと思っています。

弊社のフロントエンドのテスト実装はまだまだ導入を行い始めたところですが、お客さまに安心して使って貰うために、品質向上を目指しテストのカバレッジを上げていきたいです。

次回はバックエンドのエンジニアの山田さんの記事です!楽しみです!!


弊社ではバックエンド、フロントエンドエンジニアの方を募集しています。

社員を第一に考える、とても働きやすくチャレンジしやすい・スキルを上げながら働くことの出来る会社です。

クロスマート株式会社について気になった方がいらっしゃいましたら、以下のリンクから「話を聞きに行きたい」をお願いします!

www.wantedly.com