リファクタリングの
ためのテスト

Kazuhito Hokamura

2014/06/21

Remixing 1st Round

自己紹介

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

今日のはなし

  • テストとは何か
  • テストとリファクタリングの関係
  • リファクタリングに有効なテストの書き方

テストって何?

自動でプログラムの動作を
検証するためのプログラム

その中でもさらに

JavaScriptにおける

  • ユニットテスト
  • E2Eテスト

について扱います

ユニットテストとは

メソッドなどの小さい単位
の動作を検証するテスト

平均を求める関数

function average(arr) {
  var result;
  var sum = 0;

  for (var i = 0, len = arr.length; i < len; i++) {
    sum += arr[i];
  }

  result = sum / arr.length;

  return result;
}

動作確認

console.log(average([10, 20])); //=> 15
console.log(average([10, 20, 30])); //=> 20

平均を求める関数

function average(arr) {
  var sum = arr.reduce(function(a, b) { return a + b; });
  return sum / arr.length;
}

動作確認

console.log(average([10, 20])); //=> 15
console.log(average([10, 20, 30])); //=> 20

動作確認をプログラムで
自動化しよう

テストコード

describe('average()', function() {
  it('平均値が返ること', function() {
    expect(average([10, 20])).to.be(15);
    expect(average([10, 20, 30])).to.be(20);
  });
});

DEMO

ユニットテストのためのフレームワーク

  • QUnit
  • Jusmine
  • Mocha

テストとリファクタリングの関係

  • 振る舞いを変えずに内部のコードを変更するのがリファクタリング
  • 振る舞いが変わらないことを検証するのがテスト

テストがあれば
リファクタリングし放題

テストの入門にもオススメ

ちょっと話がそれます

SinonJSの話

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

確認して削除する関数

function confirmAndRemove(el) {
  if (window.confirm('消すよ?')) {
    $(el).remove();
  }
}

window.confirmをダミーに差し替える

// window.confirmを全く別のフェイク関数に置き換える
var stub = sinon.stub(window, 'confirm');

// window.confirmが呼ばれたらtrueを返す
stub.returns(true);

/* ここで要素が消えているかをテストする */

// 元のwindow.confirmに戻す
stub.restore();
describe('confirmAndRemove()', function() {
  beforeEach(function() {
    // 要素を作る
    this.parent = document.createElement('div');
    this.el = document.createElement('div');
    this.parent.appendChild(this.el);

    // window.confirmをstub化
    this.stub = sinon.stub(window, 'confirm');
  });
  afterEach(function() {
    // window.confirmを元に戻す
    this.stub.restore();
  });

  context('window.confirmがtrueを返す場合', function() {
    beforeEach(function() {
      // window.confirmがtrueを返すように設定
      this.stub.returns(true);
      confirmAndRemove(this.el);
    });

    it('要素が削除されること', function() {
      expect(this.parent.childNodes.length).to.be(0);
    });
  });

  context('window.confirmがfalseを返す場合', function() {
    beforeEach(function() {
      // window.confirmがfalseを返すように設定
      this.stub.returns(false);
      confirmAndRemove(this.el);
    });

    it('要素が削除されないこと', function() {
      expect(this.parent.childNodes.length).to.be(1);
    });
  });
});

DEMO

その他のSinonJSの機能

  • 関数が何回呼ばれたとかどんな引数で呼ばれたとかを記録する
  • DateやsetTimeoutなどをダミーに置き換える
  • XHRをダミーに置き換える

便利だけど使いすぎに注意

要素が消えたかをspyでチェック

// jQueryのremove関数をspy化
var spy = sinon.spy(jQuery.fn, 'remove');

// テスト対象メソッド呼び出し
confirmAndRemove(el);

// jQueryのremoveメソッドが呼ばれていることをチェック
expect(spy.calledOnce).to.be(true);

これでいいと思っていた
時期が自分にもありました。

動作確認のためのテスト
ならこれでもいい

リファクタリングのための
テストの場合に困る

内部実装を変更した

function confirmAndRemove(el) {
  if (window.confirm('消すよ?')) {
    // jQueryとか使わない
    el.parentNode.removeChild(el);
  }
}

テスト\(^o^)/オチタ

振る舞いは変わらない
はずなのにテストが落ちる

内部実装のテスト

// jQueryのremoveメソッドが呼ばれたかを検証
expect(removeSpy.calledOnce).to.be(true);

振る舞いのテスト

// 要素が削除されたかを検証
expect(parentElem.childNodes.length).to.be(0);

モックは一番外側のAPIに
留めるのがオススメ

リファクタリングしても
落ちないテストを書こう!

プライベートメソッドテストすべきか議論

http://qa.atmarkit.co.jp/q/2784

E2Eテスト

E2Eテストとは

  • end to endのテスト
  • 最初から最後まで通してアプリケーションの動作をテストする
  • 本来サーバーサイドも含むけど、今回はJavaScriptに限った話

E2Eテストのツール

  • Selenium
  • CaspterJS
  • Nightwatch

Todoアプリのコード

$(function() {
  var $form = $('.todoForm');
  var $list = $('.todoList');

  $form.on('submit', function(e) {
    e.preventDefault();

    var $input = $('input[type="text"]');
    var val = $input.val();

    var $li = $('<li>');
    var $text = $('<span>').addClass('todoText').text(val);
    var $checkbox = $('<input type="checkbox">');
    var $remove = $('<span>').addClass('removeBtn').text('x');

    $checkbox.on('click', function() {
      $li.toggleClass('is-complete');
    });

    $remove.on('click', function() {
      if (!window.confirm('消しますよ')) return;
      $li.remove();
    });

    $li.append($checkbox, $text, $remove);
    $list.append($li);

    $input.val('');
  });
});

このコードをBackbone.js
で書き直したい!

でもユニットテスト書こう
にもメソッドすらないし・・

そこでE2Eテスト

NightwatchによるE2Eテストのコード

module.exports = {
  'Todo App Testing': function (client) {
    client
      // 対象のページをブラウザで開く
      .url('http://localhost:8000/index.html')

      // テキストボックスに文字を入れて送信
      .setValue('.todoText', 'todo test')
      .submitForm('.todoForm')

      // li要素が作成されている
      .assert.elementPresent('.todoList li')

      // リストアイテムのテキストは送信したものと一致している
      .assert.containsText('.todoList li', 'todo test')

      // checkboxをクリックしたら`is-complete`が追加される
      .click('.todoList li input[type="checkbox"]')
      .assert.cssClassPresent('.todoList li', 'is-complete')

      // removeBtnをクリックしてconfirmでキャンセルしてもli要素は消えない
      .click('.todoList li .removeBtn')
      .dismissAlert()
      .assert.elementPresent('.todoList li')

      // removeBtnをクリックしてconfirmでOKしたらli要素が消える
      .click('.todoList li .removeBtn')
      .acceptAlert()
      .assert.elementNotPresent('.todoList li')

      // 検証終了
      .end();
  }
};

DEMO

E2Eテストの注意点

ユニットテストと比べて

  • 運用コストが高い
  • 壊れやすい
  • 時間がかかる

ユニットテストとE2Eテスト

  • どのスコープでリファクタリングしたいか
  • メソッド単位でリファクタリングしたい場合はユニットテスト
  • アプリケーション単位でリファクタリングしたい場合はE2Eテスト

まとめ

  • テストがあればリファクタリングは怖くない
  • 外部から見た振る舞いをテストしよう
  • ユニットテストとE2Eテストをうまく使いわけよう

Thanks.