Vue3 で単体テストを記述する

## はじめに

本記事では Vue3 で構築したコンポーネントに単体テストを実装する方法について解説します。

### 課題

従来、ウェブサービスのページ内容はサーバーサイドで動的生成されたHTMLにjQuery等でクライアントサイドでインタラクティブ性を付加する実装が一般的で、ブラウザに表示された内容の単体テストの実装はほぼ不可能でした。

Seleniumで出来るじゃないか、という人もいるかもしれませんが、あれは基本的にプログラムで普通のブラウザを自動制御する技術であり、サービス全体のページ遷移テストには使えるかもしれませんが、各ページごとの表示内容のチェック用として実装に手間がかかりすぎ、なおかつ実行速度も極めて遅く、しかもシステムの改変に極めて弱いためおよそ実用とは言えない代物でした。

### 現在の環境

現在ではVue.js、React、Angular.jsなど、アプリ構築が構造化され自動化された高度なフレームワークがいくつもあり、いずれもブラウザを用意しない環境でnode.jsのみでの単体テストの記述が可能となりました。

### 新しい課題

しかしながら、実際にVue.js 3でコンポーネントを開発し、それに対して単体テストを実装しようとしたところ、作って初めて分かる制限が色々とあることがわかりましたので、今回記事にしておきます。

具体的には以下のものです。

- パッケージの厳しいバージョン制限
- Vue2で実装できた一部のテストが記述不可(2021年09月時点)

## コンポーネントのプロジェクト例

本記事を検証するために必要最小限に簡略化したプロジェクトをGitHubに用意しました。

- vue-cli3-test
  - https://github.com/kanryu/vue-cli3-test

このプロジェクトの構成は以下のとおりです。

- プロジェクト制御コマンドは vue-cli
- WebPackを使用
- TypeScript不使用(話を単純化するため)
- 単体テストはJestで実装

### Vueコンポーネントの内容

サンプル用に実装したVue.js 3コンポーネントは以下のような仕様です。

- 任意の固定ページ、動的生成ページで動作可能
- `<div id="mainMenuApp"></div>`と書いた箇所でコンポーネントを動的生成する
- `mainMenuApp`の直前に指定されたIDでJSONを記述するとコンポーネントの表示内容を変更できる

JSONはHTML中にこのように記述します。

```html
<script id="menuJson" type="application/json">{
  "lang":"jp",
  "availableMenus": {
    "products": true,
    "customers": true,
    "admin": false
  }
}</script>
<div id="mainMenuApp"></div>
```

JSONの記述内容を変更することで、コンポーネントの初期表示を変更する仕組みですね。少し応用すればコンポーネントがWeb APIを叩いて設定内容を動的に取ってくるようにもできるでしょう。
コンポーネントはデフォルトではこのような表示内容となります。

![スクリーンショット 2021-10-20 23.44.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30331/a6e9f46e-a1e0-0f49-a180-a7d055f0289d.png)

### Jestの設定

JestはVue.js専用というわけでなくJavaScript周りの汎用テストフレームワークなので、Vue.jsのWebPack形式プロジェクトでテストを回すにはそれなりの初期設定が必要です。本プロジェクトではこのように記述しました。

`package.json`:

```json
{
  "jest": {
    "verbose": true,
    "testTimeout": 10000,
    "moduleFileExtensions": [
      "js",
      "json",
      "vue"
    ],
    "transformIgnorePatterns": [
      "<rootDir>/(build|docs|node_modules)/"
    ],
    "transform": {
      ".*\\.(vue)$": "vue-jest",
      "^.+\\.js$": "babel-jest"
    },
    "moduleNameMapper": {
      "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/tests/__mocks__/assetsTransformer.js",
      "\\.(css|less)$": "<rootDir>/tests/__mocks__/assetsTransformer.js",
      "^@/(.*)$": "<rootDir>/src/$1"
    }
  }
}
```

`transform` の設定で、拡張子が `*.vue`のファイルは **vue-jest**を。拡張子が`*.js`のファイルは **babel-jest**にテストさせます。
Vue.jsのプロジェクトではしばしばCSSやJPEG、TTFなどの固定リソースが必要になりますが、何も設定しないとJestが誤動作するため、適宜無視させる設定( `moduleNameMapper` )が必要になりますが、これにはJavaScriptのプログラムが必要です。

`assetsTransformer.js`:

```javascript
const path = require('path')

module.exports = {
    process(src, filename, config, options) {
        return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';'
    }
}
```

Vue.js 3に対してJestを実行するためには

- jest: "<27"
- vue-jest: "5.0.0"
- babel-jest: "<27"
- "@vue/test/utils": "2.0.0"

といった感じでかなりバージョンを固定する必要がありました。いずれも2021年9月時点の話なので、あなたがこの記事を読んだ時点では前提が変わっているかもしれません。

### 単体テストの記述方法

単体テストでは `[コンポーネント名].spec.js` というファイル名でtests以下に置くのが通例です。
サンプルの `Menus.spec.js` を見てみましょう。

```js
import { mount } from '@vue/test-utils'
import Component from '@/components/Menus.vue'

const wrapper = mount(Component, {
  global: {
    mocks: {
      '$t': (msg) => msg,
      '$i18n': {}
    }
  }
})

describe('英語環境', () => {
  it('On/Offチェック - Product On', () => {
    wrapper.vm.initMainMenu({
      availableMenus: {
        products: true,
        customers: false,
        admin: false,
      }
    })
    wrapper.vm.$nextTick().then(() => {
      let text = wrapper.text()
      expect(text).toEqual(expect.stringContaining('Products'))
      expect(text).toEqual(expect.not.stringContaining('Customers'))
      expect(text).toEqual(expect.not.stringContaining('Administrators'))
    })
  })
})
```

- `@vue/test-utils`をインポート
- テスト対象のコンポーネントをインポート
  - `@/components/～～`で参照できるようにするには`package.json`で設定が必要
- wrapperはブラウザ上でのコンポーネントの初期化をnode.js(Jest)環境下で代行してくれるエミュレータのような役割
- Jestでは各テストを `describe`、 `it` という形で2段階で記述する
- `initMainMenu()`はコンポーネントが初期化される際に呼び出されるメソッド
  - 実際の運用では、JSONに記述した内容が引数として渡される

### テストの実行結果

```bash
vue-cli3-test> yarn test
yarn run v1.22.10
$ jest
 PASS  tests/Menus.spec.js
  英語環境
    √ On/Offチェック - Product On (4 ms)
    √ On/Offチェック - Customers On (2 ms)
    √ On/Offチェック - Administrators On (1 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        13.971 s
Ran all test suites.
Done in 24.14s.
vue-cli3-test>
```


### Vueコンポーネントにおける非同期テスト

Vueの概念的にはコンポーネントの初期化が完了したタイミングと実際にブラウザ上で初期表示が完了するまでにはタイムラグがあり、非同期的にレンダリングがされるという扱いになりますが、Jestでも同じ状況です。

つまり、JSONで設定した内容によってコンポーネントの表示内容がHTMLのタグとしてどうなっているかを確認する場合、

- JSON読み込み完了
- コンポーネント初期化完了
- HTMLレンダリング完了

が別々のタイミングとなるため、Jestのテストとしては同期的に書くことができません。
そこで、一旦コンポーネントの初期化を実行させたあと、イベントハンドラを別途登録してHTMLレンダリングが完了するまで待機する必要があります。 `vue-jest`では以下のような形でイベントハンドラを登録します。

```js
wrapper.vm.$nextTick().then( () => {
/// HTMLレンダリングのチェック
})
```

レンダリングの内容は、テキストリソースのみでよければ `wrapper.text()`を呼べばいいですし、タグ含めてチェックしたい場合は `wrapper.html()` で取得できます。

個別のチェック方法についてはJestのマニュアル通りですので特に説明する必要はないかなと思います。

### Vue3でまだ書けないテストについて

本サンプルプロジェクトは簡略化のため、生成後のHTMLの内容は固定の内容となりますが、実際には`$emit`などを用いて何らかのインタラクティブ要素を入れると思います。

ところが 2021年9月時点の `vue-jest`では、`wrapper.findComponent()`をHTMLタグに書いた `ref` に対して実行するとJest自体が落ちてしまう不具合があります。

- findComponent() cause error "TypeError: Cannot read property 'emits' of undefined"(GitHub)
  - https://github.com/vuejs/vue-test-utils-next/issues/716

まだ治ってないっぽいですね…。


以上。