Sinon.JS

Kazuhito Hokamura

2013.04.26

第38回HTML5とか勉強会

自己紹介

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

Sinon.JSってなんぞ

テストダブルとは?

テストしづらい部品をダミーに
置き換えてテストしやすくする

Sinon.jsの主な機能

  1. spy
  2. stub
  3. mock
  4. Fake Timer
  5. Fake XHR

1. spy

jQuery#triggerのテスト

describe('jQuery#trigger', function() {
  it('イベントハンドラに値を渡せること', function(done) {
    var $el = $('<div>');
    
    $el.bind('foo', function(event, val) {
      expect(val).to.be('bar');
      done();
    });

    $el.trigger('foo', 'bar');
  });
});

コールバックが2回呼ばれるかをテスト

describe('jQuery#trigger', function() {
  it('イベントハンドラに値を渡せること', function(done) {
    var $el = $('<div>');

    var count = 0;
    $el.bind('foo', function(event, val) {
      count++;

      if (count === 1) {
        expect(val).to.be('bar');
      }
      else if (count === 2) {
        expect(val).to.be('baz');
        done();
      }
    });

    $el.trigger('foo', 'bar');
    $el.trigger('foo', 'baz');
  });
});

めんどいですね

sinon.spyを使う

sinon.spy

var spy = sinon.spy();

spy('foo', 'bar');
spy('hoge');

spy.callCount; //=> 2
spy.args;      //=> [ ['foo', 'bar'], ['hoge'] ]

sinon.spyを使ったテスト

describe('jQuery#trigger', function() {
  it('イベントハンドラに値を渡せること', function() {
    var $el = $('<div>');
    var spy = sinon.spy();

    $el.bind('foo', spy);
    $el.trigger('foo', 'bar');
    $el.trigger('foo', 'baz');

    // 呼ばれた回数を検証
    expect(spy.callCount).to.be(2);

    // 引数を検証
    expect(spy.args[0][1]).to.be('bar');
    expect(spy.args[1][1]).to.be('baz');
  });
});

既存メソッドのspy化

何か処理をして要素を削除する関数

function removeElement($el) {
  // 何か処理

  $el.remove();
}

jQuery.fn.removeが呼ばれるかをテスト

describe('removeElement', function() {
  it('jQuery.fn.removeが呼ばれること', function() {
    var $el = $('<div>');
    var spy = sinon.spy(jQuery.fn, 'remove');

    removeElement($el);

    // jQuery.fn.removeが呼ばれていることを確認
    expect(spy.callCount).to.be(1);

    spy.restore();
  });
});

2. stub

stubができること

  • spyの機能は全てもっている
  • 元の関数を上書きする
  • 関数がどのように振る舞うかを決めることができる

confirmで確認して要素を削除する関数

function removeElement($el) {
  if (window.confirm('削除しますか?')) {
    $el.remove();
  }
}

stubを使ったテスト

describe('removeElement', function() {
  it('confirmがtrueだった場合removeが呼ばれること', function() {
    var $el = $('<div>');
    var spy = sinon.spy(jQuery.fn, 'remove');

    // window.confirmをstub化
    var stub = sinon.stub(window, 'confirm');

    // window.confirmはtrueを返す
    stub.returns(true);

    removeElement($el);
    expect(spy.callCount).to.be(1);

    spy.restore();
    stub.restore();
  });
});

window.confirmがfalseを返す場合

describe('removeElement', function() {
  it('confirmがfalseだった場合removeが呼ばれないこと', function() {
    var $el = $('<div>');
    var spy = sinon.spy(jQuery.fn, 'remove');

    // window.confirmをstub化
    var stub = sinon.stub(window, 'confirm');

    // window.confirmはfalseを返す
    stub.returns(false);

    removeElement($el);
    expect(spy.callCount).to.be(0);

    spy.restore();
    stub.restore();
  });
});

3. mock

mockができること

  • spyとstubの機能を全て持っている
  • 予めメソッドがどう呼ばれてどう振る舞うかを決めてからテストする

とあるModelクラス

Model.prototype.set = function(key, val) {
  var error = this.validate(key, val);

  if (!error) {
    this.attr[key] = val;
  }
};
 
Model.prototype.validate = function() { ... };

mockを使ったテスト

describe('Model#set', function() {
  it('validateが呼ばれること', function() {
    var model = new Model();
 
    // 期待するvalidateの振る舞いを先に記述する
    var mock = sinon.mock(model);
    mock.expects('validate')
        .once()
        .withArgs('foo', 'bar')
        .returns(null);
 
    model.set('foo', 'bar');

    // fooに値がセットされている
    expect(model.attr.foo).to.be('bar');
 
    // 期待通り呼ばれたかをチェック
    mock.verify();
  });
});

stubとmock

  • mockでできることはstubでもできる
  • 好みの問題
  • 個人的にはstubだけあればいい派

4. Fake Timer

今日が日曜日だったらtrueを返す関数

function isSunday() {
  var now = new Date();
 
  return now.getDay() === 0; // 0は日曜日
}

sinon.useFakeTimersを使ったテスト

describe('isSunday()', function() {
  it('日曜日の場合にtrueを返すこと', function() {
    var sunday = new Date('2013-03-24'); // この日は日曜日
    var clock = sinon.useFakeTimers(sunday.getTime());
 
    expect(isSunday()).to.be(true);
 
    clock.restore();
  });
 
  it('日曜日以外の場合にfalseを返すこと', function() {
    var monday = new Date('2013-03-25'); // この日は月曜日
    var clock = sinon.useFakeTimers(monday.getTime());
 
    expect(isSunday()).to.be(false);
 
    clock.restore();
  });
});

10分後にコールバックを実行する関数

function wait10min(fn) {
  var time = 10 * 60 * 1000; // 10分
 
  setTimeout(function() {
    fn();
  }, time);
}

タイマーを操作することができる

describe('wait10min()', function() {
  it('10分後にコールバックが呼ばれること', function() {
    var clock = sinon.useFakeTimers();
    var spy = sinon.spy();
 
    wait10min(spy);
 
    // 10分の1ミリ秒手前まで時間を進める
    clock.tick(10 * 60 * 1000 - 1);
 
    // まだコールバックは呼ばれていない
    expect(spy.called).to.be(false);
 
    // 1ミリ秒進める
    clock.tick(1);
 
    // コールバックが呼ばれた
    expect(spy.called).to.be(true);
 
    clock.restore();
  });
});

5. Fake XHR

ユーザーAPIを呼び出す関数

function fetchUser(fn) {
  $.ajax({
    type: 'GET',
    url: '/api/user',
    dataType: 'json',
  })
  .done(function(userData) {
    fn({ result: 'ok', data: userData });
  });
}

XHRを置き換えてテストする

describe('fetchUser', function() {
  it('userAPIにアクセスすること', function() {
    // XHRを置き換える
    var xhr = sinon.useFakeXMLHttpRequest();

    // HTTPリクエストがあったらリクエストオブジェクトを保存する
    var requests = [];
    xhr.onCreate = function(request) {
      requests.push(request);
    };

    // fetchUserを呼ぶ
    var spy = sinon.spy();
    fetchUser(spy);

    // 期待通りリクエストされているか
    var request = requests[0];
    expect(request.method).to.be('GET');
    expect(request.url).to.be('/api/user');

    // 任意のレスポンスを返す
    request.respond('200', {}, '{"foo":"bar"}');

    // レスポンスがあったときに期待通り処理が行われているか
    expect(spy.callCount).to.be(1);
    expect(spy.args[0][0]).to.eql({
      result: 'ok',
      data: { foo: 'bar' }
    });

    // XHRをもとに戻す
    xhr.restore();
  });
});

fakeServerを使う

describe('fetchUser', function() {
  it('userAPIにアクセスすること', function() {
    // XHRを置き換えてダミーサーバーを作る
    var server = sinon.fakeServer.create();

    // ダミーサーバーのリクエストを処理する
    var response = [ 200, {}, '{"foo":"bar"}' ];
    server.respondWith('GET', '/api/user', response);

    // fetchUserを呼ぶ
    var spy = sinon.spy();
    fetchUser(spy);

    // レスポンスを返す
    server.respond();

    // レスポンスがあったときに期待通り処理が行われているか
    expect(spy.callCount).to.be(1);
    expect(spy.args[0][0]).to.eql({
      result: 'ok',
      data: { foo: 'bar' }
    });

    // XHRをもとに戻す
    server.restore();
  });
});

まとめ

Sinon.jsマジ便利

でも使いすぎには注意

ご利用は計画的に

宣伝

Thanks.