以前、QUnitを使ってTDD(テスト駆動開発)を実践してみた。
その時に残った課題として、DOMをテストする方法が分からない、というのがあった。
その後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>
画面上部に表示されている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ミリ秒後に非表示にして、完了。
これをテストするには、stop
やstart
が有効な気がする。
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); });
このテストが通れば、問題ないはず。
dragstart
とdrop
に、処理を追加する。ドラッグしている要素の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); };
これで通る。
出来上がったサンプルがこれ。
自動テストは全て通っており、手動テストの結果も問題なかった。
感想
DnDのイベントを出来なかったので、本末転倒な感じではある。
だがそれでも、TDDに慣れてきたのは実感している。
まだまだ初歩の初歩だが、先にテストを作って、それから実装、という考え方は身についてきた。
今回な簡略化のためにアニメーション(jQueryのanimateメソッド)は敢えて避けたが、これのテストもしたい。
最終的には実際にアニメーションを見て確認するしかないと思うが、デグレの防止、早期発見という観点から、ある程度は自動テストを作っておいたほうがいい気がする。