フロントエンドJavaScript
における設計とテスト

Kazuhito Hokamura

2013/02/23

JavaScript 道場

自己紹介

  • 外村 和仁
  • @hokaccha
  • 株式会社 ピクセルグリッド
  • JavaScript, Node.js, Ruby

宣伝 1

宣伝 2


ノンプログラマのためのJavaScriptはじめの一歩

今日話さないこと

  • JavaScriptの基礎知識、jQueryの導入
  • 気持ちいいUIやUXがうんちゃら
  • CanvasやWebGLを使ったリッチでイケてるゲームの作り方

今日の話

フロントエンドJavaScriptにおける

  • 設計の必要性
  • 設計のコツ
  • テストの手法

そもそも設計ってなによ?

大きく分けて二つの設計ある

1. モジュールやクラスの設計(ミクロ)

  • prototypeを用いたOOPにすべきだー
  • 使いやすいAPI設計にすべきだー
  • むやみにグローバル汚染したあかんー
  • 継承がうんぬんカプセル化がうんぬん

※ この話は今日はしない

2. アプリケーション全体の設計(マクロ)

  • MVCに従ってコードを整理すべきだー
  • 役割ごとにモジュールやクラスにまとめるべきだー

※ 今日出てくる「設計」はこっちの意味

アプリケーションの設計で重要なこと

1. ModelとViewを明確に分ける

2. Viewを疎結合にする

他にもあるけど、とりあえず
これだけやるとだいぶ違う

Model? View?

  • Model
  • View
  • Controller

クライアントサイドMVC・・・

お前のMVCはMVCじゃない!

時代はMVVMだ!

いやMVPだ!

いやいやMOVEだ!

MVCはn度死ぬ

Backbone.jsの
ViewはControllerだ!

こんなかんじでみんな疲れてきた

MV*

MVWTF

言葉は重要だけど今日の
話の本質はそこじゃない

今日はこういう意味で使う

  • Model : データを管理する
  • View : DOMを管理する
  • Controller : 説明しない

大事なのでもう一回

JavaScriptによるアプリケーションの設計で重要なのはこの二つ

  1. ModelとViewを明確に分ける
  2. Viewを疎結合にする

ケーススタディ: Todoアプリ

よくあるコード

$(function() {
  // 要素取得してー
  var $form = $('.todoForm');
  var $input = $form.find('input[type="text"]');
  var $list = $('.todoList');

  // フォームがサブミットされたらー
  $form.submit(function(e) {
    e.preventDefault();

    // 要素作って追加ー
    var text = $input.val();
    var html = '<li><input type="checkbox">' + text + '</li>';
    var $li = $(html);

    $li.find('input[type="checkbox"]').change(function() {
      $(this).closest('li').toggleClass('complete');
    });

    $list.append($li);
  });
});

このコードの問題点

  • データがDOMにしかない(データとDOMが密結合)
  • フォームとリストが密結合

密結合だと何が困るのか

  • 仕様変更に弱い
  • 複数人で開発しづらい
  • テストしづらい

機能追加依頼

よく使うTodoを用意してクリック
で入力できるようしてー

できましたー

$(function() {
  var $form = $('.todoForm');
  var $input = $form.find('input[type="text"]');
  var $list = $('.todoList');
  var $usual = $('.usualList li');

  // 共通の処理(リストを追加する部分)を関数に切り出した
  function addList(text) {
    var html = '<li><input type="checkbox">' + text + '</li>';
    var $li = $(html);

    $li.find('input[type="checkbox"]').change(function() {
      $(this).closest('li').toggleClass('complete');
    });

    $list.append($li);
  }

  // よく使う一覧をクリックしたらリストに追加
  $usual.click(function(e) {
    e.preventDefault();

    var text = $(this).text();
    addList(text);
  });

  // フォームをサブミットしたらリストに追加
  $form.submit(function(e) {
    e.preventDefault();

    var text = $input.val();
    addList(text);
  });
});

リストに追加する部分を
関数に切り出した!

これで勝つる!

ちょっと待って

仕様変更があるたびに
このようなことを繰り返すの?

どう見てもスパゲッティに一直線です
本当にありがとうございました

どうする?

よろしい、ならば設計だ。

ModelとViewを分ける

まずModel

model.todo.js

// Todoのデータ管理するModelクラス
function Todo(data) {
  this.text = data.text;
  this.complete = !!data.complete;
}

// 説明簡略化のためBackbone.Eventから拝借したEventをmixin
// onとかtriggerメソッドが使えるようになる
$.extend(Todo.prototype, Events);
$.extend(Todo, Events);

// completeプロパティを変更するメソッド
Todo.prototype.setComplete = function(complete) {
  this.complete = !!complete;
  this.trigger('change:complete', this);
};

// 自身のインスタンスを保持する配列
Todo.list = [];

// 新規Todoを追加するためのクラスメソッド
Todo.add = function(text) {
  var todo = new Todo({ text: text });

  Todo.list.push(todo);
  this.trigger('add', todo);
};

ここに注目!

  • DOM操作を一切おこなっていない
  • データの管理だけをおこなっている

次にView

Viewはどのような単位で分けるか

UIコンポーネントごとに
分けるとわかりやすい

この場合フォームとリスト

view.todoForm.js

// Todoを入力するフォームを管理するViewクラス
function TodoFormView($el) {
  this.$el = $el;
  this.$input = this.$el.find('input[type="text"]');
  this.$el.submit(this.onsubmit.bind(this));
}

// サブミット時のイベントハンドラ
TodoFormView.prototype.onsubmit = function(e) {
  e.preventDefault();
  Todo.add(this.$input.val());
};

ここに注目!

  • サブミットされたらModelにデータを追加するだけ
  • 他のViewと全く関連がない(疎結合)

view.todoList.js

// Todo一覧のリストを管理するViewクラス
function TodoListView($el) {
  this.$el = $el;
  Todo.on('add', this.add.bind(this));
}

// Todoの要素を追加するメソッド
TodoListView.prototype.add = function(todo) {
  var item = new TodoListItemView(todo);
  this.$el.append(item.$el);
};

// Todo一覧の要素をを管理するViewクラス
function TodoListItemView(todo) {
  this.todo = todo;
  this.$el = $('<li><input type="checkbox">' + todo.text + '</li>');
  this.$checkbox = this.$el.find('input[type="checkbox"]');

  this.$checkbox.change(this.onchangeCheckbox.bind(this));
  this.todo.on('change:complete', this.onchangeComplete.bind(this));
}

// checkboxの値が変わった時のイベントハンドラ
TodoListItemView.prototype.onchangeCheckbox = function() {
  this.todo.setComplete(this.$checkbox.is(':checked'));
};

// モデルのcompleteプロパティの値が変わった時のイベントハンドラ
TodoListItemView.prototype.onchangeComplete = function() {
  if (this.todo.complete) {
    this.$el.addClass('complete');
  }
  else {
    this.$el.removeClass('complete');
  }

  this.$checkbox.attr('checked', this.todo.complete);
};

ここに注目!

  • ModelのイベントをトリガーにDOM構築をおこなっている
  • 他のViewと全く関連がない(疎結合)

main.js

最後にViewを初期化

$(function() {
  new TodoFormView( $('.todoForm') );
  new TodoListView( $('.todoList') );
});

ViewはModelを通じて
データをやりとりする

Viewが疎結合なので
仕様変更に強い!

機能追加依頼

よく使うTodoを用意してクリック
で入力できるようしてー

はいこれだけー

main.js

$('.usualList li').click(function() {
  Todo.add($(this).text());
});

※ 必要に応じてViewクラスにしてね

機能追加依頼

全部完了にするボタンつけてー

はいこれだけー

model.todo.js

Todo.setCompleteAll = function() {
  Todo.list.forEach(function(todo) { todo.setComplete(true); });
};

main.js

$('.completeAll').click(function() {
  Todo.setCompleteAll();
});

※ 必要に応じてViewクラスにしてね

既存のViewをいじることなく
機能追加に対応できる!

ちなみにBackbone.jsを使うと
同じようなことが簡単にできます

ここまでのまとめ

きちんと設計するとハッピーになれる

次、テストのはなし。

結合テストと単体テスト

結合テスト

  • HTMLやCSSも含めた一連の流れのテスト
  • 「フォームに値を入力してボタンを押したらバリデーションが走ってOKならサブミットされるか」とか
  • ブラウザをエミュレートしたりするツールが必要

結合テストのためのライブラリ

  • Selenium
  • capybara
  • CasperJS

CasperJSの例

var casper = require('casper').create();

casper.start('./index.html', function() {
  this.evaluate(function() {
    var form = document.querySelector('.todoForm');
    form.querySelector('input[type="text"]').value = 'foo';
    form.querySelector('input[type="submit"]').click();
  });
});

casper.then(function() {
  this.test.assertEvalEquals(function() {
    return document.querySelectorAll('.todoList li').length;
  }, 1, 'Added Todo List');

  this.test.assertEvalEquals(function() {
    return document.querySelector('.todoList li').textContent;
  }, 'foo', 'Added input value');
});

casper.run(function() {
  this.test.done();
  this.test.renderResults(true);
});

実行結果

Seleniumの例(Ruby)

実行するとFirefoxが起動してテストする

require 'test/unit'
require 'selenium-webdriver'

class TodoAppTest < Test::Unit::TestCase
  def setup
    @driver = Selenium::WebDriver.for :firefox
  end

  def teardown
    @driver.quit
  end

  def test_submit_todo
    url = "file://#{File.expand_path('..', __FILE__)}/todo/index.html"
    @driver.navigate.to url

    input = @driver.find_element :name => 'text'
    input.send_keys 'foo'
    input.submit

    list = @driver.find_elements :css => '.todoList li'
    assert_equal(list.size, 1)
    assert_equal(list[0].text, 'foo')
  end
end

単体テスト

  • 個々のモジュールやメソッドを対象にテストを行う
  • 「このメソッドを実行したらこの値が返ってくるか」とか
  • JavaScriptだけで完結できる

単体テストのためのライブラリ

  • Mocha
  • Jasmine
  • QUnit
  • JsTestDriver
  • Buster.JS

全部は紹介しきれないので
今日はMochaについて解説します

Mochaの特徴

  • アサーション機能がない(自由に選べる)
  • 機能が少ないので覚えることが少ない
  • 非同期のテストが簡単

Mochaの書き方

基本はこれだけ

describe('テストの対象', function() {
  it('テストの内容', function() {
    // ここで例外が投げられたらテストが落ちる
  });

  it('テストの内容', function() {
    // ここで例外が投げられたらテストが落ちる
  });
});

describeのネスト

describe('テストの対象', function() {
  // describeはネストできる
  describe('テストの対象', function() {
    it('テストの内容', function() {
      // ...
    });
  });

  describe('テストの対象', function() {
    it('テストの内容', function() {
      // ...
    });
  });
});

事前処理と事後処理

describe('テストの対象', function() {
  // テストの事前処理
  before(function() {
  });

  // テストの事後処理
  after(function() {
  });

  // itの前に毎回行う処理
  beforeEach(function() {
  });

  // itの後に毎回行う処理
  afterEach(function() {
  });
});

非同期のテスト

describe('テストの対象', function() {
  // doneを引数に取ると非同期
  it('非同期のテスト', function(done) {
    setTimeout(function() {
      // ここにテストを書く
      done();
    }, 100);
  });
});

アサーションライブラリの選定

現状ほぼこの二つのどちらかしかない

  • Chai
  • expect.js

Chaiは動かないブラウザも
あるのでexpect.jsがおすすめ

expect.jsの例

条件に合わないと例外を投げる

expect(foo).to.be('bar');
expect(foo).to.eql({ foo: 'bar' });
expect(foo).to.have.property('bar', 'baz');
expect(foo).to.be.a(Date);
expect(function() {
  foo();
}).to.throwError();

Mocha + expect.jsの例

結果

describe('Todo', function() {
  describe('.add', function() {
    it('Todo.listにインスタンスが追加されること', function() {
      Todo.add({ text: 'foo' });
      expect(Todo.list).to.have.length(1);
      expect(Todo.list[0]).to.be.a(Todo);
    });
  });

  describe('#setComplete', function() {
    var todo;
    beforeEach(function() {
      todo = new Todo({});
    });

    it('completeが設定されること', function() {
      todo.setComplete(true);
      expect(todo.complete).to.be(true);
    });

    it('change:completeイベントが発火すること', function(done) {
      todo.on('change:complete', function() {
        expect(todo.complete).to.be(true);
        done();
      });
      todo.setComplete(true);
    });
  });
});

Sinon.js

テストダブルのライブラリ

テストダブル?

テストダブル (Test Double) とは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品のこと。ダブルは代役、影武者を意味する。

テストダブル - Wikipedia

Sinon.jsの主な機能

  • spy
  • stub
  • mock
  • Fake Timer
  • Fake XHR

spyの例

対象のメソッドが呼ばれたか調べる

// Todo.addにspyを忍ばせる
var spy = sinon.spy(Todo, 'add');

// Todo.addメソッドを実行
Todo.add('foo');

// メソッドが呼ばれたかどうかや引数を調べられる
expect(spy.calledOnce).to.ok();
expect(spy.args[0][0]).to.be('foo');

// spyを解除
spy.restore();

stubの例

window.confirmで分岐するコード

ListView.prototype.remove = function() {
  if (window.confirm('削除しますか?')) {
    this.$el.remove();
    this.$el = null;
  }
};

テスト書いてみる

結果

テストのたびにconfirmが出る
(しかも選択によってテストの成否が変わる)

describe('ListView', function() {
  var listView;
  beforeEach(function() {
    listView = new ListView($('<ul>'));
  });

  describe('#remove', function() {
    it('要素が削除されること', function() {
      listView.remove();
      expect(listView.$el).to.be(null);
    });
  });
});

解決策

stubを使う

ダイアログがでない!結果

describe('ListView', function() {
  describe('#remove', function() {
    var stub;
    var listView;
    beforeEach(function() {
      stub = sinon.stub(window, 'confirm');
      listView = new ListView($('<ul>'));
    });
    afterEach(function() {
      stub.restore();
    });

    it('window.confirmが呼ばれること', function() {
      listView.remove();
      expect(stub.calledOnce).to.ok();
      expect(stub.args[0][0]).to.be('削除しますか?');
    });

    context('window.confirmでOKを押したとき', function() {
      beforeEach(function() {
        stub.returns(true);
      });

      it('要素が削除されること', function() {
        listView.remove();
        expect(listView.$el).to.be(null);
      });
    });

    context('window.confirmでキャンセルを押した時', function() {
      beforeEach(function() {
        stub.returns(false);
      });

      it('要素が削除されないこと', function() {
        listView.remove();
        expect(listView.$el).to.not.be(null);
      });
    });
  });
});

Sinon.jsは他にも色々できて
超便利なんだけど省略

よくある質問のコーナー

Q. Modelのテストはいいんだけど
Viewのテストが難しいです

A. 疎結合なクラスにしていれば
比較的やりやすくなる

TodoFormViewのテスト

describe('TodoFormView', function() {
  var todoForm;
  var html = '<form><input type="text"></form>';

  beforeEach(function() {
    todoForm = new TodoFormView($(html));
  });

  it('$elに要素がセットされていること', function() {
    expect(todoForm.$el.is('form')).to.ok();
  });

  context('submitしたとき', function() {
    var spy;
    beforeEach(function() {
      spy = sinon.spy(Todo, 'add');
      todoForm.$input.val('foo');
      todoForm.$el.submit();
    });
    afterEach(function() {
      spy.restore();
    });

    it('textの値がTodo.addに渡されること', function() {
      expect(spy.calledOnce).to.ok();
      expect(spy.args[0][0]).to.be('foo');
    });
  });
});

他のViewと関連がないため
テストが(比較的)楽

Q. でもUIの動きとかはテスト
できないでしょ?

A. そもそもプログラムでテスト
するものかどうかを検討する

テスト可能なものと不可能なもの

  • 「気持ちいい動きをしているか」はテスト不可能
  • 「アニメーション終了後にこの位置にあるか」はテスト可能

例えば

flipsnap.jsというフリック系のライブラリを作ってるんだけど

↓こういうやつ

1
2
3

こういうテストを書いてる

dispatchEventでイベントを発火させて動くか確認する

// jQueryのtriggerのようなもの
function trigger(element, eventType, params) {
  var ev = document.createEvent('Event');
  ev.initEvent(eventType, true, false);
  $.extend(ev, params || {});
  element.dispatchEvent(ev);
}

// タッチイベントを発火させて現在地が動くかをテストする
it('should move to next', function() {
  trigger(f.element, touchStartEvent, { pageX: 50, pageY: 0 });
  expect(f.currentPoint).to.be(0);

  trigger(f.element, touchMoveEvent, { pageX: 40, pageY: 0 });
  trigger(f.element, touchMoveEvent, { pageX: 30, pageY: 0 });
  expect(f.currentPoint).to.be(0);

  trigger(document, touchEndEvent);
  expect(f.currentPoint).to.be(1);
});

できなくはないけど
どこまでやるかは難しいところ

Q. テストを自動化(CI)できんの?

A. 最近は環境がそろって
きてるのでできる

DEMO
PhantomJS + Mocha

DEMO
testem

まとめ

設計は大事

テストも大事

しかしそれらは手段である

真に大事なのは目的を達成すること

There's More Than
One Way To Do It.

やり方はひとつじゃない!

Enjoy Programming !!

ありがとうございました