読者です 読者をやめる 読者になる 読者になる

QUnitでTDDの実践(DOM編)

以前、QUnitを使ってTDD(テスト駆動開発)を実践してみた。
その時に残った課題として、DOMをテストする方法が分からない、というのがあった。

numb86-tech.hatenablog.com

その後DOMやイベントをテストする方法を学んだので、ドラッグアンドドロップを題材に実践してみることにした。

準備

まず、htmlとcssを作る。
これを、tdd-dom.htmlとする。cssも同じファイルに記述した。

<body>
  <div id="belt"></div>
  <img id="person_0" class="person" draggable="true" src="images/neet.png">
  <img id="person_1" class="person" draggable="true" src="images/worker.png">
  <img id="place_0" class="place" draggable="false" src="images/hellowork.png">
  <img id="place_1" class="place" draggable="false" src="images/rouki.png">
  <img id="place_2" class="place" draggable="false" src="images/kaiju.png">
  <img id="circle" draggable="false" src="images/circle.png">
  <img id="cross" draggable="false" src="images/cross.png">

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.2/jquery.min.js"></script>
  <script type="text/javascript" src="script.js"></script>
</body>

f:id:numb_86:20160430134151p:plain

画面上部に表示されているpersonがドラッグ可能になっており、placeにドロップできる。
画像に応じて適切な場所にドロップし、正解ならマルが、不正解ならバツが表示される。
このような機能をTDDで作るのが、今回の目的。

次は、処理用のscriptを用意。テスト用のhtmlとscriptも準備する。

以下が、ファイル構成。

  • tdd-dom.html サンプル用のhtmlファイル
  • script.js サンプルの処理を記述するファイル
  • test.html テストの結果を表示するhtmlファイル
  • test.js テストを書いていくファイル

この4つのファイルで、TDDを行っていく。

DOMの取得

まずは、DOMを取得してそれをテストできるようにする。
これが今回の本題なのだから、出来ないと話にならない。

先ほどのtdd-dom.htmlの中身をテストできるようにする。

まずはテストを書く。

QUnit.test("DOM取得のテスト", function() {
    QUnit.equal($('#person_0').attr('src'), 'images/neet.png', 'DOM取得のテスト');
});

まだ何もしていないから当然、通らない。
これが通るように変えていく。

ヒアドキュメントでhtmlファイルの内容を読み込み、それを#qunit-fixtureに挿入すればいい。

var dom = (function(){/*
  <div id="belt"></div>
  <img id="person_0" class="person" draggable="true" src="images/neet.png">
  <img id="person_1" class="person" draggable="true" src="images/worker.png">
  <img id="place_0" class="place" draggable="false" src="images/hellowork.png">
  <img id="place_1" class="place" draggable="false" src="images/rouki.png">
  <img id="place_2" class="place" draggable="false" src="images/kaiju.png">
  <img id="circle" draggable="false" src="images/circle.png">
  <img id="cross" draggable="false" src="images/cross.png">
*/}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1];

QUnit.test("DOM取得のテスト", function() {
    $('#qunit-fixture').append(dom);
    QUnit.equal($('#person_0').attr('src'), 'images/neet.png', 'DOM取得のテスト');
});

これでテストは通るようになる。
ここからいよいよ、機能を開発していく。

画像の切り替え

まずは、.personの画像をランダムで変える機能を実装する。
画像の種類は3種類あるので、それをランダムに、#person_0#person_1に設定する。

まずは乱数を作る。これは定型文みたいなものだ。

// 0~nまでの乱数を返す
return Math.floor( Math.random() * (n+1) );

今回は画像が3種類あるので、0〜2の乱数を作ればいい。
乱数を作って、それに対応した画像を返す関数を作る。

この場合、テストはどうしたらいいだろうか。

QUnit.test('ランダムで画像を返す関数', function(){
    var image = setPersonImage();
    var result;
    if (image === 'images/neet.png' || image === 'images/worker.png' || image === 'images/robot.png') {
        result = true;
    } else {
        result = false;
    };
    QUnit.ok(result, 'ランダムで画像を返す関数');
});

あまり意味のあるテストには思えないが、取り敢えずこれで。
そして以下のように関数の中身を作れば、パスする。

var setPersonImage = function(){
    var num = Math.floor( Math.random() * (2+1) );
    switch(num){
        case 0:
            return 'images/neet.png';

        case 1:
            return 'images/worker.png';

        case 2:
            return 'images/robot.png';

        default:
            return null;
    };
};

今後も似たようなテストを何回かやりそうなので、テストコードを書き換え、画像の名前を判定する処理をtestImageName()として切り出しておく。

var testImageName = function(image){
    if (image === 'images/neet.png' || image === 'images/worker.png' || image === 'images/robot.png') {
        return true;
    } else {
        return false;
    };
};

QUnit.test('ランダムで画像を返す関数', function(){
    var image = setPersonImage();
    var result = testImageName(image);
    QUnit.ok(result, 'ランダムで画像を返す関数');
});

いよいよ、さっき作ったsetPersonImage()を使ってDOMを操作する機能を実装する。
テストは以下。

QUnit.test('setPersonImageによるDOM操作', function(){
    $('#qunit-fixture').append(dom);
    iniImage();

    var image = $('#person_0').attr('src');
    var result = testImageName(image);
    QUnit.ok(result, 'setPersonImageによるDOM操作');

    image = $('#person_1').attr('src');
    result = testImageName(image);
    QUnit.ok(result, 'setPersonImageによるDOM操作');
});

ここで追加されたiniImage()は、画像の初期化を行う。
画面読み込み時に呼び出すことを想定しており、2つの.personに、ランダムで画像を設定する。

var iniImage = function(){
    var imageName = setPersonImage();
    $('#person_0').attr('src', imageName);
    imageName = setPersonImage();
    $('#person_1').attr('src', imageName);
};

これで、3種類の画像をランダムに#person_0#person_1に設定する、という機能を実装できた。

残っている機能は以下の通り。

  • .placeにドロップした際、どこに何がドロップされたかを取得できるようにする
  • 取得した内容に応じて正誤や位置を判定し、それを基にマル、もしくはバツを表示し、一定時間後に非表示にする
  • ドロップした.personを非表示にして、画像を再設定した上で再び表示する

ドラッグアンドドロップのテストが上手くいかず

まず、ドラッグアンドドロップに関する機能を初期化する。
不要なデフォルトアクションをキャンセルすると同時に、ドロップした画像のsrc属性を受け渡し出来るのようにする。
これを、iniDnD()とする。
今回は先に処理を作ってしまい、そのあとでテストを作ることにする。

var iniDnD = function(){
    $('.person').on('dragstart', function(e){
        e = e.originalEvent;
        e.dataTransfer.setData('text/url-list', $(this).attr('src'));
    });

    $('.place').on('dragenter', function(e){
        e = e.originalEvent;
        e.preventDefault();
    });

    $('.place').on('dragover', function(e){
        e = e.originalEvent;
        e.dataTransfer.dropEffect = 'copy';
        e.preventDefault();
    });

    $('.place').on('drop', function(e){
        e = e.originalEvent;
        var data = e.dataTransfer.getData('text/url-list');
        e.preventDefault();     // Chromeでは不要だが、Firefoxでは必要となるため記述
        checkImage(data, $(this).attr('id'));
    });
};

var checkImage = function(person, place){
    console.log(person, place);
};

ドロップされた際、ドロップした画像のsrc属性と、ドロップされた要素のid属性を引数として渡して、checkImage()を呼び出す。
そしてここで、その画像が正解かどうかを判定する。判定の仕組みは後で作ることとして、取り敢えず、与えられた引数をそのままログに出すようにしておく。

上記の機能をテストするためのテストコードを考えてみる。
まずは、スタート、エンター、オーバー、ドロップといった、DnDの一連の動作を再現してみる。

ストファイルに、以下の様な記述をした。これでDnDを再現できるような気がするが、どうか。

var dragstart = $.Event('dragstart');
var dragenter = $.Event('dragenter');
var dragover = $.Event('dragover');
var drop = $.Event('drop');
var executeDnD = function(dragSelector, dropSelector){
    $(dragSelector).trigger(dragstart);
    $(dropSelector).trigger(dragenter);
    $(dropSelector).trigger(dragover);
    $(dropSelector).trigger(drop);
};

テストしてみる。

QUnit.test("ドロップした画像のsrcを取得", function() {
    $('#qunit-fixture').append(dom);
    iniDnD();

    executeDnD('#person_0', '#place_0');

    // 以降にアサートを記述
});

エラーが出た。
Cannot read property 'dataTransfer' of undefinedと出た。

いろいろ試してみたが、どうやら、イベントオブジェクトに上手くアクセス出来ないようだ。
方法はあるかもしれないが、今回はここに時間をかけても仕方ない気がするので、先に進む。TDDに慣れるのが目的だから。

手動でテストした限り上手く動いているようなので、次はcheckImage()の作り込みを行う。

まずはテスト。
checkImage()にデータを投げると、正解ならtrueを、不正解ならfalseを返す。

QUnit.test("正誤のテスト", function() {
    var result = checkImage('images/neet.png', 'place_0');
    QUnit.ok(result, '正解のパターン');
    result = checkImage('images/worker.png', 'place_1');
    QUnit.ok(result, '正解のパターン');
    result = checkImage('images/robot.png', 'place_2');
    QUnit.ok(result, '正解のパターン');

    result = checkImage('images/neet.png', 'place_2');
    QUnit.ok(!result, '不正解のパターン');
    result = checkImage('images/worker.png', 'place_0');
    QUnit.ok(!result, '不正解のパターン');
    result = checkImage('images/robot.png', 'place_1');
    QUnit.ok(!result, '不正解のパターン');
});

これが通るように作っていく。

var checkImage = function(person, place){
    if (person === 'images/neet.png') {
        if (place === 'place_0') { return true; }
        else { return false; };
    } else if (person === 'images/worker.png'){
        if (place === 'place_1') { return true; }
        else { return false; };
    } else if (person === 'images/robot.png'){
        if (place === 'place_2') { return true; }
        else { return false; };
    };
};

これで通った。

startやstopの活用

正誤の判定は出来るようになったので、次は、それに基いてマルやバツを表示させる機能を作る。

これのテストは少し難しそうだったから、先にshowResult()を作った。
これを、dropイベントに紐付ける。

$('.place').on('drop', function(e){
    e = e.originalEvent;
    var data = e.dataTransfer.getData('text/url-list');
    e.preventDefault();     // Chromeでは不要だが、Firefoxでは必要となるため記述
    var result = checkImage(data, $(this).attr('id'));
    showResult(result, $(this).attr('id').slice(-1));
});
var showResult = function(result, id){
    var target = null;
    if(result){ target = $('#circle'); }
    else{ target = $('#cross'); };
    target.removeClass().addClass('position-'+id).show();
    setTimeout(function(){
        target.hide();
    }, 700);
};

まずは正誤によって表示する要素を振り分け、次に、クラスによって表示位置を設定してから表示。700ミリ秒後に非表示にして、完了。

これをテストするには、stopstartが有効な気がする。

QUnit.test('showResultのテスト(正解)', function(){
    $('#qunit-fixture').append(dom);

    $('#circle').hide();
    $('#cross').hide();

    showResult(true, 0);

    QUnit.equal($('#circle').css('display'), 'inline', '正解が表示になっているか');
    QUnit.equal($('#cross').css('display'), 'none', '不正解が非表示になっているか');
    QUnit.ok($('#circle').hasClass('position-0'), '正解の表示位置が正しいかどうか');

    QUnit.stop();
    setTimeout(function(){
        QUnit.start();
        QUnit.equal($('#circle').css('display'), 'none', '時間経過で、正解が非表示に切り替わっているか');
    }, 1000);
});

QUnit.test('showResultのテスト(不正解)', function(){
    $('#qunit-fixture').append(dom);

    $('#circle').hide();
    $('#cross').hide();

    showResult(false, 1);

    QUnit.equal($('#circle').css('display'), 'none', '正解が非表示になっているか');
    QUnit.equal($('#cross').css('display'), 'inline', '不正解が表示になっているか');
    QUnit.ok($('#cross').hasClass('position-1'), '不正解の表示位置が正しいかどうか');

    QUnit.stop();
    setTimeout(function(){
        QUnit.start();
        QUnit.equal($('#cross').css('display'), 'none', '時間経過で、不正解が非表示に切り替わっているか');
    }, 1000);
});

これで通った。

完成

残りは、

ドロップした.personを非表示にして、画像を再設定した上で再び表示する

だけである。

repositionPerson()を作り、それをdropイベントに紐付ける。

この関数で、ドラッグしていた画像を非表示にし、srcを再設定し、再び表示させる。

まずはテストを書いてみる。

repositionPerson()は引数を受け取り、その引数をもとに要素を判断。
そしてその要素に対して、処理を加える。

QUnit.test('repositionPersonのテスト', function(){
    $('#qunit-fixture').append(dom);

    QUnit.equal($('#person_0').css('display'), 'inline', '表示されているか');
    repositionPerson(0);
    QUnit.equal($('#person_0').css('display'), 'none', '非表示に切り替わったかどうか');

    var image = $('#person_0').attr('src');
    var result = testImageName(image);
    QUnit.ok(result, '画像の再設定が上手くいったかどうか');

    QUnit.stop();
    setTimeout(function(){
        QUnit.start();
        QUnit.equal($('#person_0').css('display'), 'inline', '時間経過で、表示に切り替わっているか');
    }, 1000);
});

このテストが通れば、問題ないはず。

dragstartdropに、処理を追加する。ドラッグしている要素のsrc属性だけでなく、id属性も受け渡すようにする。
そしてその、id属性の末尾の数字を引数にして、repositionPerson()を呼び出す。

$('.person').on('dragstart', function(e){
    e = e.originalEvent;
    e.dataTransfer.setData('text/url-list', $(this).attr('src'));
    e.dataTransfer.setData('text/plain', $(this).attr('id'));
});
$('.place').on('drop', function(e){
    e = e.originalEvent;
    var data = e.dataTransfer.getData('text/url-list');
    e.preventDefault();     // Chromeでは不要だが、Firefoxでは必要となるため記述
    var result = checkImage(data, $(this).attr('id'));
    showResult(result, $(this).attr('id').slice(-1));
    data = e.dataTransfer.getData('text/plain');
    repositionPerson(data.slice(-1));
});
var repositionPerson = function(id){
    var elem = '#person_'+id;
    $(elem).hide();
    $(elem).attr( 'src', setPersonImage() );
    setTimeout(function(){
        $(elem).show();
    }, 700);
};

これで通る。

出来上がったサンプルがこれ

自動テストは全て通っており、手動テストの結果も問題なかった。

f:id:numb_86:20160430134219p:plain

感想

DnDのイベントを出来なかったので、本末転倒な感じではある。
だがそれでも、TDDに慣れてきたのは実感している。
まだまだ初歩の初歩だが、先にテストを作って、それから実装、という考え方は身についてきた。

今回な簡略化のためにアニメーション(jQueryのanimateメソッド)は敢えて避けたが、これのテストもしたい。
最終的には実際にアニメーションを見て確認するしかないと思うが、デグレの防止、早期発見という観点から、ある程度は自動テストを作っておいたほうがいい気がする。