JavaScript UnitTest Patterns

0-9:

JavaScript UnitTest Patterns

ここでは以下の順番でSinonJSとJsTestDriverを使用したJavaScriptのUnitTest Patternsを紹介します。

初期化の遅延

UnitTestを行う場合、まずは初期化functionが自動的に実行されないようにしましょう。

初期化functionをこちらの任意のタイミングで実行できるようにすることにより事前に対象外のコードをstub化したり、必要なfunctionへspyを仕込んだ状態でfunctionを実行できるようになります。

UnitTestの場合のみ初期化を遅延する

一番簡単な方法はUnitTest実行時のみ条件分岐で初期化を止める方法です。

    $(function () {
        if (window.sinon) {
            init();
        }
    });

この方法は簡単かつ確実にタイミングを制御できますが、元コードを変更する必要があるため後からテストを追加する場合に問題になる可能性があります。

addEventListener経由での設定

window.onloadやDOMContentLoaded経由で初期化を行なっている場合、window.onloadやdocument.addEventListenerをstub化します。

    //元functionを保持
    var _addEventListener = document.addEventListener;

    //stub化
    sinon.stub(document, 'addEventListener');

    //stubが呼び出されるので実行されない
    document.addEventListener('DOMContentLoaded', function () {
    }, false);

window.onloadやdocument.addEventListener自体のテストが行いたい場合、clickイベントを強制的に発生させたい (fireEvent/createEventの使い方) - 主に言語とシステム開発に関して等を参考にして直接ブラウザのイベントを呼び出してください。

jQuery経由での設定

初期化にjQueryを使用する場合先にjQuery functionをstub化し、functionが引数の場合に呼び出しを行わないようにすることができます。

この方法は「sinon.js -> jQuery -> 以下の初期化コード -> テスト対象コード」の順番でコードを読み込むことで実現できます。

    //元jQuery objectを保管
    var _jQuery = jQuery;

    //jQuery, $の両方stub化する必要があるので注意
    sinon.stub(window, 'jQuery', init_stub);
    sinon.stub(window, '$', init_stub);

    //jQueryのfunction propertyは自動で継承されるので、このfunctionは呼び出し時の動作のみ想定すればOK
    function init_stub (arg) {
        // functionの場合何もしない
        if (_jQuery.isFunction(arg)) {
            return;
        }
        return _jQuery.apply(this, arguments);
    }

こうすることで$(function () {})経由で設定されたfunctionを自分の好きな段階で$.args[n][0]()から呼び出すことができます。

script要素内に書かれたコードのテスト

htmlに直接書かれたコードもhtmlを$.ajax等で文字列として取得後、正規表現で切り出してevalすることでテストできますが、この方法はあまり安全ではないためhtmlとJSを分離することを推奨します。

非同期実行の同期化

主なUnitTest Frameworkは非同期実行のテストをサポートしていますが、UnitTestを行う場合できる限り非同期コードを同期的に実行することを推奨します。

非同期実行コードがテストに含まれる場合テストが複雑になるだけでなく、テストが増えてきた際に非同期部分の実行時間がテストの実行時間を伸ばすことになります。
(同期テストのみであればテスト実行環境を高速化することでテスト時間を短縮できますが、必ず固定秒数かかるテストが複数あった場合、それが積み重なるとテスト時間が非常に長くなる上に短縮できなくなります)

setTimeout、setIntervalの同期化

ShinonJSのuseFakeTimersを使うことではsetTimeout、setIntervalを同期化することができます。

    //setTimeout、setIntervalのmock化
    this.clock = sinon.useFakeTimers();

    //mock化されたsetTimeoutの呼び出し(この段階では呼び出されない)
    setTimeout(function () {
        window.alert('ok');
    }, 0);

    //100ms経過したものとする(100ms以内に実行されるfunctionのみ実行される)
    this.clock.tick(100);

useFakeTimersはjsunitのjsUnitMockTimeout.jsを元にしているので興味ある方はソースを読むことをおすすめします。
(コメント込みで150行程度なので簡単に読めると思います)

ただ、sinonのuseFakeTimersを使う場合、Date等も上書きされてしまうためUnitTest Frameworkが対応していない場合は注意してください。
Mochaを使用している場合不具合が出ることがあるそうです)

また、useFakeTimersを使用するとDateは常に0(1970-1-1 00:00:00)を返すようになります。

XHR

ShinonJSのuseFakeXMLHttpRequest、fakeServerを使うことでサーバアクセスをstub化することができます。
(ただ、jQuery等を使用している場合は$.ajaxをstub化してしまう方が簡単かもしれません)

ちなみに、同期実行には出来ませんが、JsTestDriver等には擬似的にサーバの代わりをする機能もあるためそちらを使ってテストを行うことも可能です。

jsDeferred

もし内部でjsDeferredを使用している場合、初期化の段階で以下のコードを実行することでjsDeferredがsetTimeoutを使うようになります。

    Deferred.next = Deferred.next_default;

jsDeferredはブラウザによってより高速な非同期化のコードを使用しますが、上記コードを使うことによりuseFakeTimers経由で実行のタイミングを制御することができます。

html, cssのテスト

htmlの記述

テスト対象のコードがDOM上の要素を参照する場合、テスト環境でも同じようにDOM上に要素が必要な場合があります。

これに関してJsTestDriverではコード内に以下の様なコメントでhtml要素を記述することができます。

    /*:DOC += <div>この要素はdocument.body直下に展開される</div> */
    /*:DOC += <div>
        改行も書けます
    </div> */
    /*:DOC hoge = <div>この要素はthis.hogeで参照できる</div> */
    /*:DOC fragment = <div>要素を並列に記述した場合</div><div>documentFragmentになるので注意</div> */

この内容はコード上の任意の場所に記述でき、ブラウザに配信される前に記述された部分が該当要素を生成するJSに置き換えられます。

注意点として、+=で要素をdocument.body以下に展開する場合、「hoge = 」で変数に生成場合に比べてテストの実行時間が長くなる傾向にあります。
(ブラウザの処理コストがかかる)

これに関して、jQuery等のライブラリを使用している場合、以下のようにセレクタを外部から書き換えられるようにすることで、テスト時には変数に生成された要素を使用することができます。

    var selectors = {
        'link' : 'a'
    };
    $.fn.setLinkText = function () {
        $(selectors.link).html('hoge');
    };

    TestCase('test case', {
        'test_link' : function () {
            /*:DOC link = <a></a>*/
            selectors.link = this.link;
            $.fn.setLinkText();
            assertEquals($(this.link).html(), 'hoge');
        }
    });

また、BusterJSにはtestbedという機能があり、テストに対してそれが実行されるhtmlを指定する機能があります。

htmlの参照

htmlのテストとは「要素が正しく生成されるか」というテストになりますが、htmlのテストはクロスブラウザでの安定した要素のシリアライズが難しいため、テスト結果の比較が難しいという問題があります。

これには以下の様な方法でテストを行うことで安定したテストを行うことができます。

  1. htmlを展開するmethodをstub化する
    jQueryを使用している場合、jQuery.fn.htmlをstub化することでDOMに展開される前のhtmlを受け取る事ができます。
    この方法により、htmlをDOMではなく文字列として扱えるため、クロスブラウザで安全にテストを行うことができます。
  2. HTMLElementのmockを使用する
    もしテスト対象のコードがstyle等の属性を設定している場合、以下のようなmockを使用することでテストを行うことができます。
       var elem = { 'style' : {} };
       targetFunction(elem);
       assertEquals(elem.style.display, 'block');
    
  3. selectorで数える
    上記のような方法が取れない場合、最後は普通にDOM経由で参照して要素数を数えたり、属性値を比較することになります。

css

cssのテストも上記「htmlの参照」と同じように「ライブラリをstub化する」、「mockを使用する」、「直接比較する」のいずれかを行うことで安定したテストを行うことができます。

また、jQuery等のライブラリはブラウザ毎に発生するCSS結果の誤差をまとめる機能もあるため、たとえテスト対象のコードでライブラリを使用していなくてもテスト環境のみクロスブラウザ用ライブラリを使用する方法もあります。

イベントのテスト

イベントのテストは大きく「ブラウザ環境のテスト(正しくイベントが呼ばれるか)」と「イベントコードのテスト(イベント内のコードが意図した動作を行うか)」に分けられます。

ブラウザ環境のテスト

ブラウザ環境のテストは「あるイベントを呼び出した時に正しくイベントが呼ばれるか」をテストするものです。

今回はアプリケーションのテストをメインに考えているので、ブラウザ環境自体のテストに関しては省略します。

イベントコードのテスト

HTMLElement等の要素にbindされたコードをテストする場合、bindを行うコードを呼び出す前にテスト用の要素を作成し、コードを呼び出した後にイベントを呼び出します。

通常のイベントに関しては呼び出しが同期でおこるので、テストの際に呼び出しのタイミングを考慮する必要はありません。

jQuery等のライブラリを使用しているのであれば要素を指定してイベントを発火する仕組みを使用するのが簡単です。
(ライブラリを使用していない場合はclickイベントを強制的に発生させたい (fireEvent/createEventの使い方) - 主に言語とシステム開発に関してを参照してください)

    function bindClick () {
        $('a').click(function () {
            console.log('ok');
        });
    }
    /*:DOC += <a></a>*/

    sinon.stub(console, 'log');

    bindClick();
    $('a').click();

    assertCalledOnce(console.log);

もしjQuery等のライブラリを使用していない場合や、mouseover等で座標を取得している場合などは、イベントをエミュレートするよりイベントと処理を切り離してそれぞれテストするのがお勧めです。

    function bindEvent () {
        $('a').mouseover(function (e) {
            setElement(e.pageX, e.pageY);
        });
    }
    function setElement (X, Y) {
        console.log(X, Y);
    }
    /*:DOC += <a></a>*/

    sinon.stub(window, 'setElement');

    bindEvent();
    $('a').mouseover();

    assertCalledOnce(setElement);

    // .restoreでstubを元functionに戻すことができます
    setElement.restore();
    sinon.stub(console, 'log');

    setElement(1, 2);

    assertCalledOnce(console.log);
    //最初に呼ばれた(args[0])時のargumentsの比較
    assertEquals(console.log.args[0], [1, 2]);

その他問題になりうるコード

document.write

JsTestDriverを使う場合、テストはページリロード無しに実行されるためテスト対象のコードは基本的にDOMContentLoaded後に実行されます。

そのためdocument.writeを使うコードをテストする際は、事前にdocument.writeをstub化してテストを実行しましょう。

alert, confirm

コードの実行が止まってしまうためstub化しましょう。

location

location objectは上書き(stub化)できないため、これに直接依存するコードをテストする場合には問題になる可能性があります。

これに関しては安定した回避方法がないため、テスト対象のコードを書き換えることを推奨します。
(別のobject経由で操作するようにし、テスト実行時はそのobjectをstub化しましょう)

Prev