一から始めるJavaScriptユニットテスト

この記事は、はてなエンジニアアドベントカレンダー2016の5日目の記事です。

こんにちは、はてなでアプリケーションエンジニアをしている id:shiba_yu36 です。先日、buildersconにおいて、現在所属しているプロジェクトでJavaScriptのユニットテストを導入した知見について、「一から始めるJavaScriptユニットテスト」というタイトルで発表しました。

speakerdeck.com


この発表は、実際にJavaScriptのユニットテスト環境を作ってみると非常にハードルが高いと感じたので、そのハードルを少しでも下げられればという思いで、非常にシンプルな例で一から環境を作る例を紹介しました。アジェンダは次のとおりでした。

  • カクヨムのJS環境
  • JSのテストツールを整理する
  • 通常の関数のユニットテスト
  • DOM操作する機能のユニットテスト


カクヨムのJS環境や、JSのテストツールを整理する部分、またユニットテストを導入してどのようになったかなどは発表資料を見てもらえば良いです。しかし、その後のユニットテストの導入方法の紹介で、コードの部分は発表での紹介が難しく、発表資料だけでは少しわかりづらいところもあると思います。

そこで、「通常の関数のユニットテスト」「DOM操作する機能のユニットテスト」辺りについてこの記事で文字に起こして説明したいと思います。また、この発表で触れられなかった「その他様々なユニットテスト方法」についても言及します。



本記事の目次

  • サンプルコードの紹介
  • 通常の関数のユニットテストを導入する
  • DOM操作する機能のユニットテスト
  • その他様々なユニットテスト方法
    • setTimeoutなどtimerを利用する機能のテストを書く
    • localStorageを利用する機能のテストを書く
    • XMLHttpRequestを使う機能のテストを書く


サンプルコードの紹介

今回の発表にあたって、シンプルなユニットテストのサンプルコードを用意しました。https://github.com/shibayu36/bcon-js-unit-test においてあります。

README.mdに書いてあるとおり、発表の流れに沿ってブランチを切ったり、PullRequestを作ったりしてコードを追加しています。対応関係は次のとおりです。

それぞれ発表資料と合わせて参考にしてください。



通常の関数のユニットテストを導入する

まずは、通常の関数のユニットテストを簡単に書ける環境を導入してみます。対応するbranchはassert-mocha 、PRは#1 です。

テストしたい実装は、addNumberという数字の足し算をする関数です。

src/js/number-util.js

function addNumber(num1, num2) {
    return num1 + num2;
}

export { addNumber }

関数のユニットテストを行いたい場合、アサーションライブラリとテストフレームワークがあればテスト可能です。今回はアサーションライブラリに一番シンプルなassert 、テストフレームワークにMocha を使ってみます。

セットアップ

まずassertとMochaをnpmを使ってインストールします。

$ npm install assert --save-dev
$ npm install mocha --save-dev


今回はES2015のimportやexportの記法も使っているので、babel関係のセットアップも行います。

$ npm install babel-preset-es2015 --save-dev
$ npm install babel-register --save-dev

.babelrc

{
    "presets": ["es2015"],
}

テストを書く

以下がaddNumberのテストコードです。

src/js/test/number-util.js

import assert from 'assert';
import { addNumber } from "../number-util";

describe('addNumber', function () {
  it('足し算できる', function () {
    assert.equal(addNumber(1, 2), 3, '1 + 2 = 3');
    assert.equal(addNumber(10, -2), 8, '10 + -2 = 8');
  });
});

assertと実装関数をimportして、Mochaの機能であるdescribeやitを使ってテストをグルーピング詩、assert.equalでテストを書いているだけです。簡単ですね。

テストを実行する

実装ファイルとテストファイルが用意できたので、あとはテストを実行するだけです。mochaコマンドを使うと実行できます。

$ $(npm bin)/mocha  --require babel-register src/js/test/*.js


  addNumber
    ✓ 足し算できる


  1 passing (19ms)

CIでテストを実行する

以上で手元でテストを実行する環境はできました。しかし、もちろんCI環境でもテストしたいでしょう。

CIで利用するには、reporterを指定して出力を切り替えるだけです。以下のコマンドでJenkins用に出力できます。

$ $(npm bin)/mocha --require babel-register --reporter xunit src/js/test/*.js
<testsuite name="Mocha Tests" tests="1" failures="0" errors="0" skipped="0" timestamp="Wed, 30 Nov 2016 09:21:15 GMT" time="0.015">
<testcase classname="addNumber" name="足し算できる" time="0.001"/>
</testsuite>

ここまでのまとめ

ここまでで、通常の関数のユニットテストを作る環境を用意できました。CIへの出力もオプション指定のみで簡単にできました。



DOM操作する機能のユニットテスト

続いて、DOM操作する機能のユニットテストを作れる環境を用意してみましょう。

テストしたい実装

例えばあるulに対し、liを動的に追加していく機能を考えてみます。以下にそのような機能を実装したappendList関数を示します。

src/js/append-list.js

function appendList(container, text) {
  let li = document.createElement('li');
  li.textContent = text;
  container.appendChild(li);
}

export { appendList }

このappendList関数はdocument.createElementやappendChildなどDOM操作のAPIを利用しています。

この機能が期待する初期のHTMLは下のようなもので、

<ul class="list"></ul>

次のように動かすことができます。

let container = document.querySelector('.list');
appendList(container, '要素1'); // <ul class="list"><li>要素1</li></ul> となる

2つの課題

上に挙げたようなDOM操作する機能のユニットテストを行うには、課題が2つ存在します。

1つ目は、どうやってDOM操作できるブラウザでテストを動かすかです。単にnodeでJSを実行しても、DOM APIは存在しないため当然DOM操作をする機能は動きません。そこでDOM APIが存在するブラウザでJSを動かし、テストする必要があります。

2つ目は、どうやってJSのみで機能を単体テストするかです。E2Eテストでは実際に動いているサーバーに対してリクエストを送りテストします。しかし、気軽さの観点では実際のアプリケーションサーバを立ち上げずにJSのみでテストを行いたいです。

この2つの課題を1つずつ解決していきます。

テストランナーのKarmaを導入する

1つ目の、どうやってDOM操作できるブラウザでテストを動かすかという課題は、テストランナーのKarmaを導入すると解決できます。

KarmaはCUIコマンドだけで、ブラウザを使ってテストを動かしてくれるツールです。ブラウザで動かすためにはbrowserifyなどの前処理も必要ですが、このような前処理の機能も提供してくれます。

動作イメージを図で表すと次のとおりです。テストJSを用意し、karmaコマンドを実行することで、前処理が走り、ブラウザを立ち上げ、テストを実行してくれた後に、結果をコマンドを実行したterminalに出力してくれます。


それではKarmaを導入してみましょう。Karmaの導入と同時にDOM操作のテストを作ろうとすると理解が難しいので、ひとまず先程のaddNumberのテストをブラウザ上で動かすことを目標とします。今回はChromeを用いてテストを動かします。

テストランナーのKarmaを導入する部分に対応するbranchはkarma です。PRは #2 です。

セットアップ

まず必要なモジュールをnpmでインストールします。本体に加え、前処理・ブラウザつなぎ込み・テストフレームワークつなぎ込みに必要なものを全てインストールします。

# 本体
$ npm install karma --save-dev
# 前処理用プラグイン
$ npm install karma-browserify browserify babelify watchify --save-dev
# ブラウザつなぎ込みプラグイン
$ npm install karma-chrome-launcher --save-dev
# テストフレームワークつなぎ込みプラグイン
$ npm install karma-mocha --save-dev

続いてKarmaの設定を作ります。まずは次のコマンドを打ち、適当に質問に答えていくと、karma.conf.jsができます。

$ $(npm bin)/karma init

その後、フレームワーク設定、テストファイルの場所、前処理設定、ブラウザ設定などをkarma.conf.jsに書いていきます。重要な部分を以下にピックアップします。

// フレームワーク設定
frameworks: ['mocha', 'browserify'],

// テストファイル場所
files: [
  'src/js/test/*.js'
],

// 前処理設定
preprocessors: {
  'src/js/test/*.js': ['browserify']
},
browserify: {
  transform: ['babelify'],
},

// 利用するブラウザ設定
browsers: ['Chrome'],

テストを実行する

ここまでで設定は全て終わったので、あとはテストを実行するだけです。karma startで実行できます。コマンドを実行すると、browserifyとbabelifyで前処理が走り、Chromeが自動で立ち上がりテストが実行され、結果がterminalに表示されていることが確認できると思います。

$ $(npm bin)/karma start
30 11 2016 18:51:30.652:INFO [framework.browserify]: registering rebuild (autoWatch=true)
30 11 2016 18:51:32.206:INFO [framework.browserify]: 92352 bytes written (0.80 seconds)
30 11 2016 18:51:32.207:INFO [framework.browserify]: bundle built
30 11 2016 18:51:32.210:WARN [karma]: No captured browser, open http://localhost:9876/
30 11 2016 18:51:32.216:INFO [karma]: Karma v1.3.0 server started at http://localhost:9876/
30 11 2016 18:51:32.216:INFO [launcher]: Launching browser Chrome with unlimited concurrency
30 11 2016 18:51:32.231:INFO [launcher]: Starting browser Chrome
30 11 2016 18:51:33.326:INFO [Chrome 54.0.2840 (Mac OS X 10.12.1)]: Connected on socket /#sPS9jtxxR46jwKU1AAAA with id 78528794
Chrome 54.0.2840 (Mac OS X 10.12.1): Executed 1 of 1 SUCCESS (0.015 secs / 0 secs)

DOM操作する機能のユニットテストを書く

ここまででテストをブラウザで動かすことは出来ました。続いて2番目の、「どうやってJSのみで機能を単体でテストするか」という課題の解決を行います。対応するbranchはdom-api-unit-test で、PRは#3 です。

アイデア

アイデアとしては、テスト前にその機能が初期に期待する最小限のHTML断片を読み込み、JSを実行しながらHTML構造の変化などが期待するものであることを確認していく、というものが考えられます。先程のappendList関数を例にあげると、テスト実行前にブラウザに表示されているHTMLを

<html><body>
  <ul class=“list”></ul>
</body></html>

というものにし、あとはappendListを実行しながら構造の変化が意図通りか確かめるということです。

appendListのテストを書く

このアイデアで、appendListのテストを書いてみると次のようになります。

src/js/test/append-list.js

import assert from 'assert';
import { appendList } from '../append-list';

describe('appendList', function () {
  it('コンテナにリストを追加できる', function () {
    // テスト前にdocument.body.innerHTMLを置き換えてしまう
    document.body.innerHTML = '<ul class="list"></ul>';

    let container = document.querySelector('.list');

    assert.equal(container.children.length, 0, '最初は0件');

    // appendListを実行しながら、構造の変化をチェックしていく
    appendList(container, 'リスト1');
    assert.equal(container.children.length, 1, '件数が1件に');
    assert.equal(container.children[0].textContent, 'リスト1', '1件目のテキストが指定したものに');

    appendList(container, 'リスト2');
    assert.equal(container.children.length, 2, '件数が2件に');
    assert.equal(container.children[0].textContent, 'リスト1', '1件目のテキストはそのまま');
    assert.equal(container.children[1].textContent, 'リスト2', '2件目のテキストは指定したものに');
  });
});

document.body.innerHTMLに初期のHTMLを流し込むだけと考えれば、テストは非常に簡単に作れますね。テストを作ったらあとはkarma startを実行するだけで、このテストも実行されます。

これで最初に挙げた2つの課題の両方を解決でき、DOM操作のある機能のテストを簡単に作れる環境を導入できました。

HTML断片が巨大になったらどうするか

ここまでで課題は2つとも解決でき、DOM操作する機能のユニットテスト環境は作れています。しかし、実際にDOM操作する機能を作っていると、最初にロードしたいHTMLが10行を超えることはざらにあります。このような場合、テストJSにベタッと書いてしまうと、テストJSが汚くなってしまうという問題があります。

この問題は、karma-html2js-preprocessorというKarmaの前処理プラグインを使うことで解決できます。karma-html2js-preprocessorはHTML断片をファイルで置いておくことで、テスト実行時にその内容を簡単に読み込めるようにしてくれるツールです。これを導入してみます。

まずインストール。

$ npm install --save-dev karma-html2js-preprocessor

さらにkarma.conf.jsの前処理設定を少し変更します。

// htmlも追加
files: [
  'src/js/test/*.js',
  'src/js/test/*.html'
],

// 前処理設定にプラグイン追加
preprocessors: {
  'src/js/test/*.js': ['browserify'],
  'src/js/test/*.html': ['html2js']
},

src/js/test/append-list.htmlというファイルに断片を保存します。

<ul class="list"></ul>

これで、テストファイル中で__html__というオブジェクトが定義され、ファイル名のキーからファイルの中身の文字列を取得することが出来ます。よって先程のdocument.body.innnerHTMLへの代入を次のようにできます。

document.body.innerHTML = __html__['src/js/test/append-list.html'];

これでHTML断片が大きくなってもテストファイル自体とは別にHTMLファイルを作ればよくなるため、テストファイルが汚くなってしまうという問題も解決されました。

CIでテストを実行する

Karmaを導入しても、もちろんCI環境で動かすことができます。必要なのはCI環境で動くブラウザと、CIが解釈する出力を出すことだけです。

CI環境で動くようなブラウザとしてはPhantomJSやjsdomなどがあります。jsdom というのはnode環境さえあれば動かせるヘッドレスブラウザのようなものです。もちろん全てのAPIを実装しているわけではないのですが、テストをする目的では十分に利用でき、最近の自分のプロジェクトではこちらを利用しています。

ブラウザの切り替えはこれまでやったとおりで、karma-jsdom-launcherというブラウザつなぎ込みプラグインをインストールし、karma.conf.jsをちょっと変更するだけでKarmaからjsdomを利用できます。

$ npm install jsdom karma-jsdom-launcher --save-dev

karma.conf.js

browsers: ['jsdom'],

これでkarma start時にjsdomのブラウザが使われるようになりました。あとはCI用の出力をするだけです。karma startコマンドでreportersの指定もできるので、次のコマンドをCI用のコマンドとして利用すると良いでしょう。

$ $(npm bin)/karma start --single-run --reporters junit

最終的な構成

最終的に以下のような構成となり、通常の関数もDOM操作する機能もユニットテストできる環境を作ることができました。

通常の関数の実装をしたら対応するテストを作ります。DOM操作も関わる機能を実装したら、対応するHTMLとテストを作ります。その後karma startすると、設定通りに自動的に前処理され、ブラウザに転送され、テストが実行され、結果が出力されます。あとは実装をして、テストを書いて、karma startをして、の繰り返しで開発できますね。ここまで行ければ誰でも簡単にJSのユニットテストを作る環境ができたと思います。


その他様々なユニットテスト方法

続いて、発表では時間が足りず言及できなかった、様々なユニットテスト方法について書きます。対応するbranchはvarious-test で、PRは#4 です。

次のようなテスト方法について紹介します。

  • setTimeoutなどtimerを利用する機能のテストを書く
  • localStorageを利用する機能のテストを書く
  • XMLHttpRequestを使う機能のテストを書く

setTimeoutなどtimerを利用する機能のテストを書く

一定時間表示し、その後消えるようなメッセージをJSで出したい場合があります。そのような機能を作る場合、setTimeoutなどのtimer系のAPIを利用することになります。例えばサンプルの実装は以下のとおりです。

src/js/timeout-message.js

function timeoutMessage(container, message, timeout) {
  container.textContent = message;

  setTimeout(function () {
    container.textContent = '';
  }, timeout);
}

export { timeoutMessage }

このようなテストを書く時でも、Sinon.js というスタブやモックなどを行えるテストツールを利用すると非常に簡単にテストを作ることができます。

ではSinon.jsを使って上記の実装のテストを書いてみましょう。まずはSinon.jsをインストールします。

$ npm install --save-dev sinon

最低限のHTMLを用意します。
src/js/test/timeout-message.html

<div class="js-timeout-message">
</div>

テストを書きます。
src/js/test/timeout-message.js

import assert from 'assert';
import sinon from 'sinon';
import { timeoutMessage } from '../timeout-message';

describe('timeoutMessage', function () {
  it('一定時間表示される', function () {
    document.body.innerHTML = __html__['src/js/test/timeout-message.html'];

    // useFakeTimersを使うことで、JS上の時間がストップする
    let clock = sinon.useFakeTimers();

    let container = document.querySelector('.js-timeout-message');

    // 3秒間メッセージを表示するようにする
    timeoutMessage(container, 'メッセージ', 3000);
    assert.equal(container.textContent, 'メッセージ', '指定した文章が表示');

    // tickを利用することで、時間を経過させることができる
    clock.tick(1000);
    assert.equal(container.textContent, 'メッセージ', '1000ms後も表示中');

    clock.tick(2500);
    assert.equal(container.textContent, '', '3500ms後はきえる');

    // restoreを使うことで、時間を通常に戻すことができる
    clock.restore();
  });
});

このテストのポイントはsinon.useFakeTimers()です。この関数を呼ぶことでテスト中で時間の経過を自由に取り扱うことができるようになります。最後にrestoreを呼ぶのを忘れないようにしましょう。

これでsetTimeoutなどtimerを利用する機能のテストを書くことができました。簡単ですね。

localStorageを利用する機能のテストを書く

JSでユーザ設定などをlocalStorageに保存しておきたいということが、まれにあります。例えばユーザー設定をするユーティリティのJS実装は次のようなものがあります。

src/js/user-config.js

function setUserConfig(key, value) {
  window.localStorage.setItem('user_config:' + key, value);
}

function getUserConfig(key) {
  return window.localStorage.getItem('user_config:' + key);
}

export { setUserConfig, getUserConfig };

setUserConfigを使うと指定したキーでlocalStorageに値を保存してくれて、getUserConfigで取り出せるという簡単な実装です。ではこれをテストしてみます。

アイデアとしては、Storage interfaceをダミー実装したクラスを用意し、window.localStorageを特定期間だけfakeするという方法が考えられます。このアイデアでテストしてみましょう。

まずは、Storage interfaceをダミー実装したクラスを用意します。

src/js/test/modules/fake-storage.js

// Web Storageのインターフェースを簡易的に実装したクラス
// JSのオブジェクト上にデータを保存する
// テストでlocalStorageやsessionStorageなどを一時的にfakeするために利用
class FakeStorage {

  constructor() {}

  get length() {
    return this.keys().length;
  }

  getItem(key) {
    return this[key];
  }

  setItem(key, data) {
    this[key] = data;
  }

  removeItem(key) {
    delete this[key];
  }

  key(index) {
    return this.keys()[index] || null;
  }

  clear() {
    this.keys().forEach((key) => {
      this.removeItem(key);
    });
  }

  keys() {
    return Object.keys(this);
  }
}

さらに、特定期間だけwindow.localStorageを置き換えるユーティリティ関数を用意します。

src/js/test/modules/fake-storage.js

// localStorageをfakeするためのユーティリティ
// 利用例)
// let fake = useFakeLocalStorage();
// (localStorageを使ったテスト)
// fake.restore(); // これでfakeが終了する
function useFakeLocalStorage() {
    let origLocalStoragePropertyDescriptor = Object.getOwnPropertyDescriptor(
        window, 'localStorage'
    ) || { value: undefined, enumerable: true, configurable: true };

    let fakeLocalStorage = new FakeStorage();
    Object.defineProperty(window, 'localStorage', {
        value: fakeLocalStorage,
        enumerable: true,
        configurable: true,
    });

    return {
        restore() {
            Object.defineProperty(window, 'localStorage', origLocalStoragePropertyDescriptor);
        }
    };
}

あとはこのユーティリティを使ってテストを書くだけです。src/js/test/user-config.jsを作っていきます。

import assert from 'assert';
import { useFakeLocalStorage } from './modules/fake-storage';
import { setUserConfig, getUserConfig } from '../user-config';

describe('UserConfig', function () {
  it('設定を保存できる', function () {
    // メモリに保存するまっさらなlocalStorageにfake
    let fake = useFakeLocalStorage();

    setUserConfig('hoge', 'fuga');
    assert.equal(window.localStorage.getItem('user_config:hoge'), 'fuga', 'localStorageに保存されている');
    assert.equal(getUserConfig('hoge'), 'fuga', 'getUserConfigで取得できる');

    // restoreするとfakeが解除される
    fake.restore();
    assert.equal(window.localStorage.getItem('user_config:hoge'), null);
  });
});

useFakeLocalStorage()でwindow.localStorageがメモリ上に設定を保存するまっさらなFakeStorageオブジェクトに置き換えられます。あとはテストを書いて、最後にrestoreを呼ぶだけです。簡単ですね。

XMLHttpRequestを使う機能のテストを書く

XMLHttpRequestやjQueryのajaxメソッドなどを使って、サーバと通信しながら動く機能を作りたいときがあります。例えば、実行するとサーバの特定のURLにアクセスし、レスポンスが返ってくるまではloading表示、レスポンスが返ってきたら、そのコンテンツを特定のDOM要素の中に入れるというような機能です。これを実装したコードは次のとおりです。

src/js/load-server-html.js

function loadServerHTML(container, url) {
  let xhr = new XMLHttpRequest();

  xhr.onreadystatechange = function () {
    if (xhr.readyState === 1) {
      // openしたらloading中表示
      container.innerHTML = 'loading';
    }
    else if (xhr.readyState === 4) {
      // 取得したHTMLを表示
      container.innerHTML = xhr.responseText;
    }
  };

  xhr.open('GET', url);
  xhr.send();
}

export { loadServerHTML };

これもテストを書いてみましょう。実際のサーバを立てずに、これをテストするためには、サーバが立ち上がっているように見せかける必要があります。このような機能もSinon.jsが提供しているので、これを使ってテストを書いてみます。

src/js/test/load-server-html.html

<div class="js-container"></div>

src/js/test/load-server-html.js

import assert from 'assert';
import { loadServerHTML } from '../load-server-html';
import sinon from 'sinon';

describe('loadServerHTML', function() {
  it('サーバのHTMLを読み込める', function() {
    document.body.innerHTML = __html__['src/js/test/load-server-html.html'];

    // XMLHttpRequestの処理をフェイクして、
    // 配信サーバのように動くオブジェクトを作成する
    let server = sinon.fakeServer.create();

    let container = document.querySelector('.js-container');

    assert.equal(container.innerHTML, '', '最初は何も存在しない');

    // loadServerHTMLを実行すると、最初はloading状態にになるはず
    loadServerHTML(container, '/path/to/server_html');
    assert.equal(container.innerHTML, 'loading', 'リクエストを送ったら中身がloadingに');

    // サーバがレスポンスを返したらHTMLが表示されるはず
    // fakeServerを用いて以下のように書ける
    server.respondWith([
      200, { "Content-Type": "text/html" }, '<p>サーバから返したHTMLです</p>'
    ]);
    server.respond();

    assert.equal(container.innerHTML, '<p>サーバから返したHTMLです</p>', '返ってきたHTMLがロードされる');

    // フェイクしていた処理を元に戻す
    server.restore();
  });
});

以上でloadServerHTMLのテストを書くことができました。ポイントとしては、sinon.fakeServer.create()の部分です。これによりXMLHttpRequest内の関数が書き換わり、実際のサーバへリクエストを送らないようになります。そしてrespondWithやrespondメソッドを使うことで、あたかもサーバがレスポンスを返したかのように動作させることができます。あとは普通にテストを書いてあげれば済みますね。

これでXMLHttpRequestやajaxでサーバと通信するような機能もユニットテストを書くことができました。



まとめ

ここまでで、JavaScriptにおいて、通常の関数でも、DOM操作のある機能でも、timerを使った機能でも、サーバと通信する機能でも、localStorageを使った機能でも全てユニットテストができるようになりました。実際に今のプロジェクトではJSの全実装ファイルに、全てユニットテストを付けることができています。

全てのJSにユニットテストを付けた結果として、JS開発での安心感は大きく向上しました。またそれだけではなく、開発中にいちいちブラウザを見ることがなくなったため、開発スピードの向上も体感で感じています。

現在はJSのユニットテスト環境が揃ってきています。そのため、意外とどんなテストでも簡単にテストを書くことができるようになり、昔のようにフロントエンドでのテストは難しいという状況ではなくなってきました。是非今回のエントリを参考にして、プロジェクトにJSのユニットテストを導入する参考にしてもらえるとと思います。


アドベントカレンダー、明日はid:haya14busaです!