豆腐とコンソメ

豆腐とコンソメ

もろもろのプログラム勉強記録

jest + Vue.js でテストコード入門に至る道のり

jest + Vue.js でテストコード入門に至る道のり

フロントエンドでもテストコード書いてったほうがよさそうだから、Vue.jsでもテストコード書いてみるか!ということでいざ始めてみると、書けはするんだけどなにやら設定やら必要なモジュールが多くってねぇ、、、と思ったので、そこに至る道のりを書いてみようと思います!

まずは、シンプルにjestを使って、ただのjsのテストコードから始めて、そっからフレームワークを使わないDOMレンダリングのテスト、Vue.jsに移っていき、必要なモジュールは都度導入していこうと思います。

とはいえ、各モジュールの使い方や、具体的パターンに応じたなテストの書き方にはあんま触れてなかったりするのでご注意ください。

書いてる人はテストコードを書くことに多少慣れた程度なので、なにかあれば突っ込んでくれるとうれしいです。

ではさっそく初めてみよう!

なにはともあれ、準備

今回のお試し用のプロジェクトを作っていきます。
yarnをつかっていますが、npmであれば適宜置き換えてください。

プロジェクト作成

$ mkdir hellojest
$ cd hellojest
$ yarn init -y
$ mkdir src

jestを導入

テスト対象のコードまだ何もありませんが、ひとまずjestを追加します。

jestを導入

$ yarn add --dev jest

jestは、javascriptのテスト用のフレームワークで、facebookが開発を進めているOSS。
テストには、大きくテストを実行するテストランナーと、テストの結果を検証するアサーションとがあって、jestはテストランナーもアサーションの機能も含んでいるフレームワークとのこと。

過去にwebpack + mochaを一瞬試したことがあったんだけれども、こちらは、テストランナー部分だけがwebpack + mochaでアサーションは別の機能をつかったりとしていたから、ものによっては、オールインワンではなく好みのものを組み合わせるという方法もあるみたいだね!

jestの追加ができたら、テスト対象となるコード/src/sum.jsをこんな感じにつくってみます。

sum.js

const sum = (a, b) => {
  return a + b
}

export default sum

引数を足した結果を返すだけのシンプルな関数ですね。

これをテストコードとして書くと以下のようになります。

sum.test.js

import sum from './sum'

it('関数sumに1と2を渡すと3が返ってくる', () => {
  // 関数sumの結果を格納
  const result = sum(1, 2)
  // sumの結果は3だよね!
  expect(result).toBe(3)
})

上記のようにテストコードは、テスト対象の関数だったり、コンポーネントだったりを準備して、その結果をexpectで検証(アサーション)する構成になっているかと思います。 上記のテストコードをsum.jsと同じディレクトリにおいて実行してみましょう。

実行してみる

$ yarn jest

jestはファイル名に.test.js.spec.jsが含まれている、もしくは、__tests__ディレクトリ配下にあるコードをテストコードと判断し、実行してくれます。

よっしゃ!実行と思ったら、SyntaxError: Unexpected identifierでこけちゃいます。

というのも、sum.test.jsでは、import文をつかっていますが、これはES6の記法になります。

普段ES6のものをブラウザで実行させるときはwebpackでバンドルして依存解決したものを実行していますよね。

ですが、jestはブラウザではなくNode.jsの環境で実行されます。 なので、ES6の記法で書かれたコードをNode.jsが実行できるCommonJSの形にトランスパイルする必要があります。

Babelを導入

トランスパイルにはBabelを使います。
BabelはES6記法だったり、日々進化しているjavascriptの新しい文法で書かれているものを、それに対応していないブラウザが使えるように変換するものです。
というのがなんとなくの認識だったのですが、CommonJSにもトランスパイルができるみたいです。

※ 他にもAMD、UMDにトランスパイルできるみたいなんだけどあんまりよくわかっていない。

ためしに、トランスパイルしてみることにしましょう。 以下のパッケージを追加します。

babelを導入

$ yarn add --dev @babel/core @babel/cli

@babel/cliは、CLIからトランスパイルを実行するために追加しています。

以下のように、トランスパイルしたいファイルを指定して、実行してみると、トランスパイル後のソースコードがコンソールに表示されます。

Babelを実行

$ yarn babel ./src/sum.js
// ↓トランスパイル後のコード
const sum = (a, b) => {
  return a + b;
};

export default sum;

✨  Done in 1.72s.

Yes!とおもいきや、なんもかわってないですね。 というのも、Babelの設定でソースコードをCommonJSにしてね!という設定をしていないからです。

なので設定をしていきます。

プロジェクトのルートディレクトリ(package.jsonと同じところ)に.babelrcを作ります。ここに設定を書くと、Babel実行時にこちらを参照してくれるみたいです。

ちなみに.babelrc以外にもbabel.config.jsだったりpackage.jsonに書いたりと、いろいろなBabelの設定方法があるみたいですね。

さて、肝心の設定ですが、以前は大変だったみたいなのですが、今は環境に合わせていい感じに設定してくれる機能してくれる@babel/preset-envがあるのでこちらを利用することにします。

presetを導入

$ yarn add --dev @babel/preset-env

パッケージを追加したら先ほど作成した.babelrcを以下のように編集します。

.babelrc

{
  "presets": ["@babel/preset-env"],
}

さきほど同様にBabelを実行してみると、

Babel実行

$ yarn babel ./src/sum.js
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = void 0;

var sum = function sum(a, b) {
  return a + b;
};

var _default = sum;
exports["default"] = _default;

✨  Done in 2.56s.

ES6のexportexports["default"]にかわりました! これで無事、CommonJSに変換されました! (といいたいのですが、CommonJSに自信がないのでちょっと不安。)

余談 @babel/preset-env をもうすこしだけみる

@babel/preset-envって何をやっているのだろうと思い、もう少し調べて見ることにする。

以下の公式ドキュメントに書かれている通り、そもそもBabelはコンパイラのように、コードをパースして、変換して、結果を出力するということをしてくれるもの。 https://babeljs.io/docs/en/plugins/

コードを変換する処理は、プラグインという形でBabel本体からは切り出されているんだと思う。なのでプラグインを指定しない場合、Babelはコードをパースして、そのまま出力するだけになる。

そこで、以下のようにプラグインを指定して、Babelを実行すると、CommonJSの形式になることがわかった。

.babelrc

{
  "plugins": ["@babel/plugin-transform-modules-commonjs"]
}

なので、@babel/preset-envを使わない場合、必要な環境に応じて、プラグインを記載していく必要があるんだけれども、これがたぶん面倒。

つまり@babel/preset-envを使うと環境に応じて必要なプラグインを追加してくれているんだと思う。

@babel/preset-envdebugオプションがあったので、さきほどの.babelrcに追加して、Babelを実行してみたところ、以下のようにプラグインtransform-xxxがいっぱいで表示されることが確認でき、想定通りプラグインを追加していることが確認できる。

@babel/preset-env: `DEBUG` option

Using targets:
{}

Using modules transform: auto

Using plugins:
  transform-template-literals {}
  transform-literals {}
  transform-function-name {}
  transform-arrow-functions {}
  ・・・省略

ちなみに、肝心のCommonJSに変換するであろう@babel/plugin-transform-modules-commonjsが、上記のプラグインのリストには見当たらなかった。

あれ?と思い、さきほどのログを見返すとmodulesautoになっており、ググって見ると、以下のissueがみつかる。 https://github.com/babel/babel/pull/8485

ものすごくざっくりとした理解だと、autoにしとくとBabelがどのように実行されたかによって、どのモジュールタイプに変換するかを制御してくれているってことかな。 webpackでbabel-loaderを使ってBabelを実行した場合は、モジュール変換用のプラグインは使わなくって、それ以外はtransform-modules-commonjsをプラグインに追加してるっぽい。

jestを再実行

だいぶ話がそれましたが、無事CommonJSに変換できることが確認できたので、jestを再実行してみます。

Babelを再実行

$ yarn jest
  ✓ 関数sumに12を渡すと3が返ってくる (3ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        5.886s
Ran all test suites.
✨  Done in 8.12s.

ほんのり遅いのですが無事テストがpassさせることに成功しました!

特段、jest向けにBabelの設定はしていないのですが、テスト実行時に.babelrcを見てくれているみたいですね。

ちなみにyarn jest --watchでwatchオプション付きで実行すると、テスト対象のコード、テストコードを監視してくれて、変更が入るたびにテストコードが走ります。 これぞTDDだ!という体験ができるのでおすすめです。

DOMがからむテストを書く

次にDOMをレンダリングするアプリケーションのテストを書いてみようと思います。

以下のようにボタンを押すと、カウントアップするDOMをつくってbodyにつっこむ、counterPage.jsをつくってみました。

フレームワークもライブラリも使っていないので、単純にdocument.createElementでDOMノードをつくっています。

/src/counterPage.js

import { increment, getCount } from './counter'

 // ボタンを押した分だけその回数をラベルに表示する
const counterPage = () => {
  const div = document.createElement('div');

  // 回数を表すラベル
  const label = document.createElement('label');
  label.textContent = '0'

  // カウントアップボタン
  const button = document.createElement('button');
  button.textContent = 'count up'
  button.addEventListener('click', () => {
    increment()
    label.textContent = getCount()
  })

  div.appendChild(button)
  div.appendChild(label)

  // つくったdivをbodyにつっこんじゃう
  document.body.appendChild(div)
}

export default counterPage

また、あんまり意味がないのですが、カウンターを管理する機能をcounter.jsとして、別のファイルに切り出しています。

/src/counter.js

let counter = 0

export const increment = () => {
 return counter++
}

export const getCount = () => {
 return counter

では早速counterPage.jsをテストしていきます。 テストコードは、counterPage.test.jsとしてつくります。

こんな感じに書いてみました。

/src/counterPage.test.js

import counterPage from './counterPage'

it('count upボタンをおすと、ラベルのテキストがカウントアップすること', () => {
  // 初期レンダリング
  counterPage()

  //ボタンとラベルの要素を取得取得する
  const button = document.querySelector('button')
  const label = document.querySelector('label')

  // 最初は0で
  expect(label.textContent).toBe('0')
  // ボタンをクリックすると
  button.click()
  // 1になる
  expect(label.textContent).toBe('1')
})

普段javascriptでDOM要素を扱っている方なら、すらすら書けるのではないでしょうか。 buttonタグで要素をとってきたりとちょっと乱暴ですが、実際はクラス名だったりIDだったりをつけて取得するイメージでしょうか。

ちなみに自分は「あれjestってNode.js環境なのにdocument.createElement()とか使っているコードを問題なく実行しているんだろう」と混乱しました。

これは、jsdomというパッケージがNode.js環境でもDOMを扱えるようしてくれているみたいです!すごい!
https://github.com/jsdom/jsdom

そしてjestを追加するとjsdomも追加されるので、個別で追加する必要はなさそうです。

jest-domを使う

とはいえアサーションする度に、label.textContentのように書いていくのは、DOMの構造を意識する必要があり、めんどうです。

例えば以下のようにDOMの構造がかわった場合、label.textContentではなく、span.textContentにテストコードを修正する必要があります。

変更前

<label>0</label>

変更後

<label>
  カウント:
  <span>0</span>
</label>

テスト観点としては「count upボタンを押したら、ラベルのテキストがカウントアップすること」という点はかわっていないのですが、DOMの構造がかわってしまったために、テストコードの修正が発生してしまいました。

これを回避するために、jest-domを使うことにします。

https://github.com/testing-library/jest-dom

jest-domは、以下のtoBeの部分(Matcherと呼ぶみたい)をDOM用に便利に拡張したものを用意してくれます。

expect(button.textContent).toBe('1')
                          // ↑Matcher

さっそくつかってみます。

パッケージを追加

$ yarn add --dev @testing-library/jest-dom

counterPage.jsにさきほどの例のようにspanを追加することにします。

/src/counterPage.js

import { increment, getCount } from './counter'

const counterPage = () => {
  const div = document.createElement('div');

  const label = document.createElement('label');
  // spanを追加
  const span = document.createElement('span');
  span.textContent = 0

  label.textContent = 'カウント:'
  label.appendChild(span)

  const button = document.createElement('button');
  button.textContent = 'count up'
  button.addEventListener('click', () => {
    increment()
    // spanにカウント回数を設定する
    span.textContent = getCount()
  })

  div.appendChild(button)
  div.appendChild(label)

  document.body.appendChild(div);
}

export default counterPage

この状態でテストを実行すると、テストが失敗するかと思います。

これを、jest-domのカスタムマッチャーを使って置き換えてみるとこんな感じになります。

/src/counterPage.test.js

import '@testing-library/jest-dom/extend-expect'
import counterPage from './counterPage'

it('count upボタンをおすと、カウントラベルがカウントアップすること', () => {
  // 初期レンダリング
  counterPage()

  //ボタンとラベルの要素を取得取得する
  const button = document.querySelector('button')
  const label = document.querySelector('label')

  // 最初は0で
  expect(label).toHaveTextContent('0')

  // もしくは、以下のようにしてもいい!
  // expect(label).toHaveTextContent('カウント:0')
  // ボタンをクリックすると
  button.click()
  // 1になる
  expect(label).toHaveTextContent('1')

})

上記で使用したtoHaveTextContentは、対象のDOMノード配下に、期待するテキストがあるかをちゃんと判断してくれます。 そのため、DOMの構造がかわったとしてもテストコードの修正が不要になるという優れもの。

これは大したことではないように感じるかもしれませんが、テストコードを書くにあたっては、結構大事なことなんじゃないかなと思います。

テストコードを書き始めていって量が増えてくると、ちょっとしたリファクタリングの度にテストコードもセットで直さないといけない、という状況はなかなか厳しいものです。

最後には、テストをskipしていくという悲しい結末にならないように、DOMに関係するテストコードはできるだけ内部実装に依存させないことが大事かもしれませんね。

jest-domには他にも便利なマッチャーがあるので公式を参照してください。
個人的にはボタンが非活性になっているかどうか判断するtoBeDisabledや、フォーカスがあっているかどうかを判断するtoHaveFocus、要素が存在しているかを確認するtoBeInTheDocumentをよくつかったりします。

dom-testing-libraryを使う

内部実装に依存させないという点から、さらにもう一歩進んで、よりユーザーの操作を意識したテストにかえていきます。

なんのこっちゃという感じなのですが、まずは以下のパッケージを追加してみます。

パッケージを追加

$ yarn add --dev @testing-library/dom

パッケージを追加したら以下のように使うことができます。

import '@testing-library/jest-dom/extend-expect'
import { getByText } from '@testing-library/dom'
import counterPage from './counterPage'

it('count upボタンをおすと、カウントラベルがカウントアップすること', () => {
  // 初期レンダリング
  counterPage()
  const body = document.querySelector('body')

  const button = getByText(body, 'count up')
  const label = getByText(body, 'カウント:')

  // 最初は0で
  expect(label).toHaveTextContent('0')
  // ボタンをクリックすると
  button.click()
  // 1になる
  expect(label).toHaveTextContent('1')
})

getByTextは第一引数に与えられたHTMLElementから、第2引数の文字列を持つDOMを取得して返してくれるメソッドです。

これにより、ボタンやカウントの回数を表示するラベルを取得する際に、ユーザーが画面上に見える言葉をつかってテストを書いていくことができます。 これにより、見た目を変更する為にクラス名をかえたり、DOMの構造をかえても、テストが壊れる頻度がぐっと減ります。

getByText以外にも、ラベルに紐づく要素を取得するgetByLabelTextや、以下のようにdata-testidを定義して、それを取得するdata-testid等あるので、こちらも公式を参照してみてください。

  <!--data-testidはlabelもtextもなにもなく、どうしようもないときに使うというスタンスっぽい -->
  <div data-testid="wrapper"></div>

Vue.jsのテストコードを書く

ようやく目標のVue.jsのテストコードを書いていきます!

まずはVue.jsをいれましょう。

パッケージを追加

$ yarn add vue 

また、後ほどでてくるvue-jestvue-template-compilerが必要になるので、こちらも追加しておきます。

パッケージを追加

$ yarn add --dev vue-template-compiler

次に、単一ファイルコンポーネントであるApp.vueを作成します。 ひとまず、機能はおいておいて、Hello Jest!を表示するだけのコンポーネントです。

App.vue

<template>
  <h1>Hello Jest!</h1>
</template>

準備ができたらテストコードに移りましょう!

vue-test-utilsを使う

単一ファイルコンポーネントのテストは、まずはVue.js公式の単体テストライブラリである、vue-test-utilsを使ってみます。 https://vue-test-utils.vuejs.org/ja/

パッケージを追加

$ yarn add --dev vue-test-utils

また、こちらも公式に記載されている通り、jestの設定を追加します。 https://vue-test-utils.vuejs.org/ja/guides/testing-single-file-components-with-jest.html

package.jsonに追加しようとありますが、今回はjest用の設定ファイルを作成することにしました。

プロジェクトのドキュメントルートにjest.config.jsをつくり、公式の設置を追加します。

jest.config.js

module.exports = {
  "moduleFileExtensions": [
    "js",
    "json",
    // *.vue ファイルを処理するように Jest に指示する
    "vue"
  ],
  "transform": {
    // transformの定義を追加すると、デフォルトでjestがやってくれていた
    // babelのトランスパイルが上書きされてしまうとのことなので
    // 以下も書いておく
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    // vue-jest で *.vue ファイルを処理する
    ".*\\.(vue)$": "vue-jest"
  }
}

vue-jestが必要とのことなので追加します。

パッケージを追加

$ yarn add --dev vue-jest

vue-jestですが、冒頭のES6記法のファイルをNode.js環境で実行できるようにBabelの設定を行ったのと同じ様な話ですね!
Vue.jsを使う際はwebpackでvue-loaderを使ってトランスパイルしていますが、jestvue-jestを使ってトランスパイルしてくれているということかと思います。 なお、公式に書いてありますが、vue-jestvue-loaderのすべての機能を担保していないですよっていうのだけ、ちょっと気になりますが、ひとまず進めます。

テストコードは以下のようにしました。

App.test.js

import { mount } from '@vue/test-utils'
import App from './App.vue'

it('初期表示時にHello Jest!が表示されていること', () => {
  const wrapper = mount(App)
  expect(wrapper.html()).toBe('<h1>Hello Jest!</h1>')
})

mountを使うことでコンポーネントをマウント(そのまんま)した状態のオブジェクトWrapperを返してくれるとのこと。

このWrapperからそのコンポーネントのプロパティだったり、レンダリングされている要素に取得できるみたいで 今回はhtmlを使って、描画されるHTMLが想定通りであることを確認しています。

jestを実行すると、テストが無事passしました!

ですが、Vue.jsを使わないでDOMに関わるテストコードのときにもあったのですが、このテストの仕方だと、Vueのテンプレートの構造がかわると、テストが失敗していまいます。
あくまで、このテストはHello Jest!が表示されることを確認したいのであって、HTMLの構造が<h1>Hello Jest!</h1>を確認したいわけではないです。

jest-domdom-testing-libraryを使っていったように、Vue.jsでも同じことがしたい!というのを解決するのがvue-testing-libraryになります。

babel-coreがないよでテストが失敗する場合

jestを実行してみると、悲しいことにbabel-coreが見つからないよというエラーで落ちたので対応方法を記載します。

この記事を書いていた時点だとvue-jestのv3.0.4でした。 ※v4系からはなおっていそうです。

こちらですが、vue-jestのpeerDependeciesにbabel-core": "^6.25.0 || ^7.0.0-0,と書いているのですが、Babelはパッケージ名がv7から@babel/coreにかわっています。

冒頭で@babel/coreをインストールしていたので、babel-coreがねえよといわれてしまっています。

こちらですが、babel-bridgeを使うことで、babel-coreの名前で@babel/coreを参照してくれるようになるみたいです。 https://github.com/babel/babel-bridge

なのでこちらも追加します。

パッケージを追加

$ yarn add --dev babel-core@^7.0.0-bridge.0 

vue-testing-libraryを使う

https://github.com/testing-library/vue-testing-library

vue-testing-librarydom-testing-libraryをVue用にラップしたものになります。内部では、vue-test-utilsを使ってコンポーネントをマウントしてるみたいです。

では、さきほどのテストコードをvue-testing-libraryを使って置き換えてみたいと思います。

App.test.js

import '@testing-library/jest-dom/extend-expect'
import { render} from 'vue-testing-library'
import App from './App.vue'

it('render App', () => {
  const { getByText } = render(App)
  expect(getByText('Hello Jest!')).toBeInTheDocument()
})

dom-testing-libaryを使ったときは、getByTextに検索対象のDOMノードを渡していましたが、vue-testing-libraryだとrenderメソッドの返り値として、検索対象のDOMノードが指定された状態の関数を受け取ることができます。

これで、DOMの構造を意識することなくテストを行うことができるようになりました!

この他にもボタンを押下したり、非同期処理だったり、モックを使ったテスト等、いろいろとあるのですが、とりあえず目標であるVue.js用のテストコードを書くことができました。

まとめ

長くなりましたが、書きたかったことはこれだけでした。

  • フロントのテストは、内部実装にできるだけ依存せず、ユーザーの観点でテストできると、テストコードで消耗する機会は減ると思うよ!

  • それにはvue-testing-libraryが便利だよ!でもそれに至るまでにでてくる登場人物がちょっと多くって混乱するよ!

  • とはいえ、複雑な機能だったりは、共通コンポーネントだけをテストしたい等の場合はvue-test-utilsを使う等、使い分けをするといいかもね!