all articles

understanding unit test & e2e test in front end development

2016-11-14 @sunderls

js unit test e2e

jsアプリケーションがどんどん複雑になって、フロントエンドのテストも大事になってきていますね。2013年正式にエンジニアに転身したときはまだAngularの時代で、karmaなんかでテストを書いたことがあったけど、書くにはすごく手間がかかってた。unit testはまあまあですが、e2eは全然追いつかないですね。webアプリケーションなので、仕様の変更もよくありますし、e2eのテストはコスパーがあんまりよくない気がしてました。

それからいろいろプロジェクトに参加し、テストの重要性をどんどん感じてきました。

  1. リファクタリングに助かる。リファクタリングではよくやってる場合は、テストがないと安心できないですね。
  2. 共通モジュールの改修など、複数のプロジェクトに関わってるので、テストがないとすごく心配。

とにかく、粒度をちゃんと考えて、大事な部分だけにテストコードを書いておけば得。今日はテストについてちょっと話します。

記事のコードはgithubにあげてる https://github.com/sunderls/lstest/blob/master/README.md

例

例えばボタン一つあって、クリックしたら同じurlにあるclickの数値を変える。こんなシンプルのページをつくりましょう。 src/state.js

'use strict';

/**
 * a helper state object
 * to parse/generate url
 */

class State {
    constructor(url) {
        this.base = '';
        this.params = {};
        this.parse(url);
    }

    // parse a url, get params
    parse(url){
        let segs = url.split('?');
        this.base = segs[0];

        if (segs.length < 2){
            return;
        }

        segs[1].split('&').forEach((chunk) => {
            let pair = chunk.split('=');
            if (pair.length){
                this.params[pair[0]] = pair[1];
            }
        });
    }

    // generate a url
    generate(){
        return this.base + '?' + Object.keys(this.params).map((key) => `${key}=${this.params[key]}`).join('&');
    }
}

if (typeof module !== 'undefined'){
    module.exports = State;
}

src/clickButton.js

'use strict';

/**
 * a button changing location when clicked
 */

class ClickButton {
    constructor($dom){
        $dom.addEventListener('click', this.onClick.bind(this))
    }

    onClick(){
        let state = new State(window.location.href);
        if (typeof state.params.click === 'undefined'){
            state.params.click = 0;
        }

        state.params.click = state.params.click * 1 + 1;
        window.history.replaceState({}, 'click', state.generate());
    }
}

if (typeof exports !== 'undefined'){
    exports.ClickButton = ClickButton;
}

これでどうテストしますかね。まずclassは二つあって、分ける必要がある。

事前に結果をイメージしたらわかりやすいかも
sampl

1. class Stateのテスト

1.1 console.assertを使えばいけそう

chromeではconsole.assertがある。node.jsでもサポートしてるので、幾つかの例で実践しましょう。

let state = new State('http://colla.me?a=3');
console.assert(state.params.a === '3');

state = new State('http://colla.me?a=3&b=4');
console.assert(state.params.a === '3');
console.assert(state.params.b === '4');

state = new State('http://colla.me?a=3&b');
console.assert(state.params.a === '3');
console.assert(state.params.b === undefined);

うまくいけば、上記のテストコードが通るはず。でなければ、AssertionError が発生します。

1.2 node.jsのassertを使う

node.jsではビルトインのassert libraryがある。上記の使い方と似てるが、他にいろいろ便利なファンクションがある。: https://nodejs.org/api/assert.html。

const assert = require('assert');

let state = new State('http://colla.me?a=3');
assert.equal(state.params.a, '3');

state = new State('http://colla.me?a=3&b=4');
assert.equal(state.params.a, '3');
assert.equal(state.params.b, '4');

state = new State('http://colla.me?a=3&b');
assert.equal(state.params.a, '3');
assert.equal(state.params.b, undefined);

1.3 exit問題

assert失敗したら全てのテストが中断される。これは望ましくないので、helper functionを作りましょう。エラーをtry catchして、全てのテストケースを行うように。

test.js

const assert = require('assert');
const test = (title, spec) => {
    try {
        spec();
        console.log(`spec:${title}: ok`);
    } catch (e){
        console.log(`spec:${title}: fail`);
    }
};

test('when one param', () => {
    let state = new State('http://colla.me?a=3');
    assert.equal(state.params.a, '3');
});

test('when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    assert.equal(state.params.a, '3');
    assert.equal(state.params.b, '4');
});

test('when params not valid', () => {
    let state = new State('http://colla.me?a=3&b');
    assert.equal(state.params.a, '4');
    assert.equal(state.params.b, undefined);
});

実際テストしたら、ちゃんとまとまったテスト結果が出力される(最後のエラーはわざと用意したw)。 error

1.4 テスト結果をshellにしらせる

上記のtestでは結果はshellに伝わってないので、CIを使う場合は、続きの分岐ができない。なのでjsの終了時にreturn valueを設定しましょう。process.exitCode: https://nodejs.org/api/process.html#process_process_exitcode 同時にtestを別のファイルに移動する。

test.js

'use strict';
/**
 * test helper
 * @param {title} title - spec title
 * @param {function} spec - the spec function
 * it console.log the result
 */
module.exports = (title, spec) => {
    try {
        spec();
        console.log(`✓ ${title}`);
    } catch (e){
        process.exitCode = 1;
        console.log(`✘ ${title}`);
    }
};

1.5 テストスペークをtest/stateSpec.jsへ

'use strict';
const State = require('../src/state.js');
const test = require('../lib/test.js');

test('should works when one param', () => {
    let state = new State('http://colla.me?a=3');
    console.assert(state.params.a === '3');
});

test('should works when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    console.assert(state.params.a === '3');
    console.assert(state.params.b === '4');
});

test('should ignore invalid param', () => {
    let state = new State('http://colla.me?a=3&b');
    console.assert(state.params.a === '4');
    console.assert(state.params.b === undefined);
});

test.jsがなんらかのテストランナーみたいになってきましたね。テストしたら:

test exit

うまく行った!

1.6 testを自動的にinject

上記のテストスペックでは、手動で都度require('/path/to/test.js')を書かないといけない。単純なテストスペックならこれは自動化したい。unit.jsを作りましょう。 unit.js

'use strict';
const fs = require('fs');

// accept second param to be spec file
let file = fs.readFileSync('./' + process.argv[2], 'utf8');
const Module = require('module');

// adjust folder
file = file.replace('require((.*)?)', 'require(../$1)');

new Module()._compile('\'use strict\';const test = require(\'./lib/unitTest.js\');' + file);

unit.jsはbinとして、specのファイルパスを受け取って、中身を読み取り、test.jsのrequireをinjectし、_compileする。ここのModuleはnode.jsのmoduleシステムで、興味を持つ方は私の別の記事を参考にしてもらったら「Node.js moduleのrequireについてもっと読んでみた」

spec.jsを下記のように修正する

const State = require('../src/state.js');

test('should works when one param', () => {
    let state = new State('http://colla.me?a=3');
    console.assert(state.params.a === '3');
});

test('should works when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    console.assert(state.params.a === '3');
    console.assert(state.params.b === '4');
});

test('should ignore invalid param', () => {
    let state = new State('http://colla.me?a=3&b');
    console.assert(state.params.a === '4');
    console.assert(state.params.b === undefined);
});

テストしたら:

test unit

ナイス!完璧!

1.7 TDD -> BDD

TDD (Test Driven Development),BDD (Behavior Driven Development)。まあ、自分もすごいエンジニアではないですが、先作ってたassertionがちょっと硬くて使いにくい、例えばassert.equal。 TDDの上に、もうちょっと自然言語に近い、ユーマンフレンドリーな書き方が発明された:

// TDD
let state = new State('http://colla.me?a=3');
assert.equal(state.params.a, '3');

// BDD
let state = new State('http://colla.me?a=3');
expect(state.params.a).to.equal('3');

書き方の違いしか見えないが、思想的には大きな違いがありません。BDDの方は個人的にすき。

BDDを実現するために、test.jsを修正すればいけそう。でもここで車輪は作らない、既存のライブラリーを使いましょう。

こういったライブラリーはAssertion Libraryと言います。たくさんある、例えばChai, jasmine, power-testなど。個人的にはどっちでもよく、チーム内で合意できればとお思います。

一旦Chaiでspec.jsを書き直しましょう。

const State = require('./app.js').State;
const expect = require('chai').expect;

test('when one param', () => {
    let state = new State('http://colla.me?a=3');
    expect(state.params.a).to.equal('3');
});

test('when two params', () => {
    let state = new State('http://colla.me?a=3&b=4');
    expect(state.params.a).to.equal('3');
    expect(state.params.b).to.equal('4');
});

test('when params not valid', () => {
    let state = new State('http://colla.me?a=3&b');
    expect(state.params.a).to.equal('5');
    expect(state.params.b).to.equal(undefined);
});

$> node lib/unit.js stateSpec.jsで実行したら、同じ結果が出るはず。

1.8 unit.js > mocha

/lib/unit.jsとtest.jsはテスト結果をまとめてくれてるけど、レポートのフォマットがちょっとダサい。もっといい感じにできるが、一旦ここまで車輪作りはやめましょう。 mochaや tapeなどをつかいましょう!

mochaでspec.jsを書き直す

const State = require('./app.js').State;
const expect = require('chai').expect;

describe('State should', () => {
    it('detect one param', () => {
        let state = new State('http://colla.me?a=3');
        expect(state.params.a).to.equal('3');
    });

    it('detect more than one params', () => {
        let state = new State('http://colla.me?a=3&b=4');
        expect(state.params.a).to.equal('3');
        expect(state.params.b).to.equal('4');
    });

    it('ignore invalid params', () => {
        let state = new State('http://colla.me?a=3&b');
        expect(state.params.a).to.equal('5');
        expect(state.params.b).to.equal(undefined);
    });
});

これでunit testの基本を説明できたかなと思います。

1.8 unit testまとめ

アプリはちゃんとモジュールに分割するべき、そこから各細かい部分でunit testするべき。いままでの説明を踏むと、そんなに難しくないですね、ひとつのタスクランナー(mocha)でレポートを出し、良さげなAssertion Libraryがあればよい。テストがすごい細かいほどにできるが、実際の開発ではコスパーをよく模索しましょう。

ちょっと待って!先のアプリケーションではユーザーのクリックによりアクションがちゃんと動くかどう書くにしますか?

これですね、ブラウザ環境がないと確認できないですね。e2e testを見てみましょう。

2 ClickButton (e2e)のテスト

ClickButtonのロジックにはブラウザのAPIが使われてる。なのでclickのテストの中ではブラウザ環境が必要。

ブラウザ画面を開く => テスト => テスト結果をterminalに返す。

なんかいけそう!やってみよう

2.1 自作のe2eテスト

    1. 一時的なserverを作る
    1. ブラウザでテスト用のhtmlを開く、その中にspec.jsが含まれる
    1. spec.js テスト終わったら、結果をterminalに返す。
    1. terminalで結果をレポートにする。done

2.1.1 まずローカルサーバーを立ち上げる

const http = require('http');
const fs = require('fs');
const dir = '.';

const server = http.createServer((request, response) => {
    response.writeHead(200, {
        'Content-Type': 'text/html',
    });

    let file;
    let path = request.url;
    if (path === '/'){
        file = dir + '/e2e/test.html';
    } else if (/\.js$/.test(path)){
        file = dir + path;
    }
    response.end(file ? fs.readFileSync(file) : '');
}).listen(8888);

$> node e2e.js実行したら,http://localhost:8888/で結果見れるはず

2.1.2 spec.js

とにかく望ましいフォーマットでspec.jsを描きましょう。

spec.js

let state = new State(window.location.href);
let prevClick = state.params.click;
let clickbutton = new ClickButton(document.querySelector('button'));

test('first click ok', () => {
    clickbutton.onClick();
    state = new State(window.location.href);
    console.assert(state.params.click * 1 === (prevClick || 0) * 1 + 1);
});

test('second click ok', () => {
    prevClick = state.params.click;
    clickbutton.onClick();
    state = new State(window.location.href);
    console.assert(state.params.click * 1 === (prevClick || 0) * 1 + 2);
});

end();

2.1.3 test html

<button>click me</button>
<script src="/src/state.js"></script>
<script src="/src/clickButton.js"></script>

<script src="/lib/e2eTest.js"></script>
<script src="/test/clickButtonSpec.js"></script>

2.1.4 Chromeで開く

$> node e2e.js実行してからhttp://localhost:8888/にアクセス、devToolを開いたらassertion failedがある error

これはassertに技っとエラーを仕込んだのです。修正したらいい console.assert(state.params.click == (prevClick || 0) * 1 + 1);

これでテストが成功した。でも手動でブラウザを開くのはいやですね。

2.1.4 自動的にChromeを開く

自分でコマンドをかけるが、ここは既存のライブラリーを使う。e2e.jsの最後に以下のコードを追加:

//..
const open = require('open');
open('http://localhost:8888/');

2.1.6 terminalでレポートを出す

これはちょっと厄介ですが。思いついた方法はlocal serverにレポートするendpointを用意することです。

まずwrapper - test.jsを作ってみよう

lib/e2eTest.js

'use strict';
let result = {};

/**
 * test runnder
 * @param {string} title
 * @param {function} spec
 */
window.test = (title, spec) => {
    try {
        spec();
        result[title] = true;
    } catch (e){
        result[title] = false;
    }
};

/**
 * end e2e test, send result to local server
 */
window.end = () => {
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:8888/report', false);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(JSON.stringify(result));
    window.close();
}

test.jsではtest() とend()がある。end()ではレポートをまとめて送信する。window.close()でwindowを閉じる。

e2e.jsでレポートを生成する。

'use strict';

const http = require('http');
const fs = require('fs');
const open = require('open');
const dir = '.';

let server;

server = http.createServer((request, response) => {
    response.writeHead(200, {
        'Content-Type': 'text/html',
    });

    let path = request.url.split('?')[0];

    if (path === '/report'){
        var body = [];
        request.on('data', (chunk) => {
            body.push(chunk);
        }).on('end', function() {
            body = Buffer.concat(body).toString();
            let result = JSON.parse(body);
            let failedCount = 0;
            Object.keys(result).forEach( (key) => {
                if (!result[key]){
                    failedCount++;
                }
                console.log(`${result[key] ? '✓' : '✘'} ${key}`);
            });

            response.end();
            process.exit(failedCount > 0 ? 1 : 0);
        });
    } else {
        let file;
        if (path === '/'){
            file = dir + '/e2e/test.html';
        } else if (/\.js$/.test(path)){
            file = dir + path;
        }
        response.end(file ? fs.readFileSync(file) : '');
    }
}).listen(8888);

open('http://localhost:8888/');

2.1.7 試しに

以下のコマンドを打てば、chromeが自動的に立ち上がり、テスト終わったら自動的に消え、ちゃんとレポートも出てくれました!

e2e

でも実際の開発ではこれよりだいぶ複雑、ユーザーのアクションやブラウザの違う機能とかいろいろ大変そう。↑の方法ではリロードすらサポートできない(サポートするには、おそらくiframeを介するかね)。

とりあえず車輪作りはここまで。ちゃんとOSSのライブラリーを使いましょう。

2.2 他の車輪

phatom.jsはwebkitエンジンであり、e2eに使えば、chromeの管理が必要なくなる。でもheadlessってなんか不穏の気がして、やっぱりheadがあるselenium を使った方が安心。seleniumは簡単に言うとブラウザを利用してテストさせるツールです、3.0になりました。 seleniumの発展はブラウザにすごくいい影響を与えて、今Chrome, Safari, Firefox, IEなど全部webdriverをサポートしてる、なので例えばchromeだけにテストするなら、selenium serverは使わなくていい。

selenium/webdriverの使い方はめんどくさくて、その上にもいろいろツールがあります。protractorやnightwatchあどがあり、個人嗜好ぐらいの差かな。またはtestcafeが最近できて、使いやすそうですが、まだCI環境では不安定のようで、おすすめしないです。

車輪の紹介は以上です。unit testとe2e testの基礎を紹介しました。もし役に立てばと思います。