2013年5月6日月曜日

Sinon.JSのテストダブルを使ったユニットテスト

はじめに

JavaScriptでの、テストダブルを使ったユニットテストの書き方について書く。テストランナーにはJsTestDriver, モックライブラリにはSinon.JSを使う。

ベースとなるコードには、Sinon.JS > Getting startedから、Spies, Stubs, Testing Ajax, Fake XMLHttpRequest, Fake Serverの5つを使う。これらのコードはそのままでは実行できない (テストランナーにJasmineやMochaを使う場合のテストメソッドが切り出されている) ので、JsTestDriverで実行できるように書き換えて、サンプルコードとする。

書き換えの際、Sinon.JS > Documentationや『テスト駆動JavaScript』を参考に、次のTIPSを導入する。
  • JsTestDriverとSinon.JSのアサーションの統合
  • サンドボックスの導入
環境は次の通り。「JsTestDriverでUnit Test + Code Coverage」とほぼ同じ。
  • OS: Ubuntu 12.10 (Xubuntu)
  • エディタ: gedit
  • テストランナー: JsTestDriver 1.3.5
  • モックライブラリ: Sinon.JS 1.6.0
  • テストブラウザ: Firefox 20.0

Spies

最初にスパイの書き方。テストコードの書き換えは素直なので、ここでアサーションの統合についても書く。

テスト対象関数は次の通り。この関数は、引数に渡された関数を一度だけ実行し、結果をキャッシュする。
function once(fn) {
  var returnValue, called = false;
  return function () {
      if (!called) {
          called = true;
          returnValue = fn.apply(this, arguments);
      }
      return returnValue;
  };
}
これに対するJsTestDriverのテストコードは次の通り。テストケースの書き換え自体には、特に注意するところはないと思う。

注意すべきは、アサーションの書き方。3つのテストケースで、書き方を変えている。1つ目と2つ目のテストメソッドの書き方は、統合不要。3つ目のテストメソッドの書き方には、統合が必要。それぞれの特徴はコメントを参照。
// JsTestDriverとSinon.JSのアサーションの統合
// 普通は全テストケースで共有するために、グローバルヘルパーで実行する
sinon.assert.expose(this);
 
TestCase('SpyExample', {
  'test calls the original function':function() {
    var callback = sinon.spy();
    var proxy = once(callback);
 
    proxy();

    // JsTestDriverのアサーションを使う場合。メッセージが不親切
    assertTrue(callback.called);
  },
 
  'test calls the original function only once':function() {
    var callback = sinon.spy();
    var proxy = once(callback);
 
    proxy();
    proxy();
 
    // Sinon.JSのアサーションを完全修飾して使う場合。メッセージがフレンドリィ
    sinon.assert.calledOnce(callback);
  },
 
  'test calls original function with right this and args':function() {
    var callback = sinon.spy();
    var proxy = once(callback);
    var obj = {};
 
    proxy.call(obj, 1, 2, 3);
    // Sinon.JSのアサーションを統合して使う場合。
    assertCalledOn(callback, obj);
    assertCalledWith(callback, 1, 2, 3);
  }
});
なお、統合は、sinon.assert.exposeで行っている。メソッド名などオプション引数でカスタマイズできるので、詳しくはリンク先を参照のこと(JsTestDriver以外のテストランナーと統合する場合にも要参照。アサーションを持つオブジェクトによって引数に渡すべき値を変えたり、統合先のテストランナーの失敗の扱い方によってsinon.assert.failのオーバーライドしたりする必要がある)。

Stubs

次に単純なスタブの書き方。テスト対象関数はSpiesと同じ。

テストコードは次の通り。これも特に注意するところはないと思う。
TestCase('StubExample', {
  'test returns the return value from the original function':function() {
    var callback = sinon.stub().returns(42);
    var proxy = once(callback);
 
    assertEquals(42, proxy());
  }
});

Testing Ajax

Ajaxスタブの書き方。ここではサンドボックスを導入する。

テスト対象関数は次の通り。Ajaxを簡単に取り扱うために、jQueryを使っている。
function getTodos(listId, callback) {
  $.ajax({
    url: "/todo/" + listId + "/items",
    success: function (data) {
      // Node-style CPS: callback(err, data)
      callback(null, data);
    }
  });
}
これに対するテストコードは次の通り。ポイントは2行目。スタブ化したグローバルオブジェクト$を復元しないと、他のコードに影響するかもしれない。そこで、sinon.testでテストメソッドをラップしてサンドボックス化している。
TestCase('AjaxExample', {
  'test makes a GET request for todo items':sinon.test(function(stub) {
    this.stub($, 'ajax');
    getTodos(42, sinon.spy());
 
    assertTrue($.ajax.calledWithMatch({url: '/todo/42/items'}));
  })
});
スタブへのアクセスは、this.stubで行う。『テスト駆動JavaScript』ではthisがないけれど、それだと動作しなかった。バージョンアップで変更になったのだと思う。

サンドボックス化すべきテストメソッドが複数ある場合は、代わりにsinon.testCaseでテストクラスをラップできる。次の次のFake Serverで使ってみる。

Fake XMLHttpRequest

続いて、XMLHttpRequestのスタブ。あえてサンドボックスを使わないで書くと、復元が面倒になるという例に使う。テスト対象関数は、Testing Ajaxと同じ。

テストコードは次の通り。setUp, tearDown内でテストダブルを自前で管理しなければならない。
TestCase('FakeXMLHttpRequestExample', {
  setUp: function() {
    this.xhr = sinon.useFakeXMLHttpRequest();
    var requests = this.requests = [];
 
    this.xhr.onCreate = function(req) {
      requests.push(req);
    };
  },
 
  tearDown: function() {
    // Like before we must clean up when tampering with globals
    this.xhr.restore();
  },
 
  'test makes a GET request for todo items': function() {
    getTodos(42, sinon.spy());
 
    assertEquals(1, this.requests.length);
    assertEquals('/todo/42/items', this.requests[0].url);
  }
});

Fake server

最後に、サーバのスタブ。テストケースのサンドボックス化を導入する。なお、これもテスト対象関数は、Testing Ajaxと同じ。

テストコードは次の通り。テストケースをサンドボックス化する場合はこうなるはず(ドキュメントに沿えばこうなると解釈したコードで、テストのパスは確認したがテストダブルの復元までは未確認)。
TestCase('FakeServerExample', sinon.testCase({
  'test calls callback with deserialized data': function(server) {
    this.server = sinon.fakeServer.create();
    var callback = sinon.spy();
    getTodos(42, callback);
 
    // This is part of the FakeXMLHttpRequest API
    this.server.requests[0].respond(
      200,
      {'Content-Type': 'application/json'},
      JSON.stringify([{id: 1, text: 'Provide example', done: true}])
    );
 
    assert(callback.calledOnce);
  }
}));

References

JsTestDriverでテストケースを書く

JsTestDriverで実行するためのテストケースの書き方について記載する。使うバージョンは、1.3.5。テストターゲットが同期処理の場合と非同期処理の場合のそれぞれについて、JsTestDriverのプロジェクトWikiをベースに記載する。なお、テストの実行方法については、「JsTestDriverでUnit Test + Code Coverage」に記載した。

テストケースの書き方には、次の2通りがある。Getting Started with JsTestDriverでは、プロトタイプを使っているが、『JavaScript実践入門』に習って、インライン宣言を使う。インライン宣言だと、テスト名に任意の文字列が使える。また、記述量も少なくなる。
  1. プロトタイプを使う
  2. インライン宣言を使う
Getting Started with JsTestDriverのサンプルコードを元に、インライン宣言を使ってテストケースのスケルトンを書くと、次のようになる。
TestCase('GreeterTest', {
  setUp:function() {
    // 必要に応じてセットアップ処理を実装する。
  },

  'test greet returns Hello World!':function() {
    // Set up
    var sut = new myapp.Greeter();

    // Exercise
    var actual = sut.greet('World');

    // Verify
    assertEquals('Hello World!', actual);
  },

  tearDown:function() {
    // 必要に応じてティアダウン処理を実装する。
  }
});

テストターゲットが非同期の場合は、TestCaseクラスではなくて、AsyncTestCaseクラスを使う。AsyncTestCase - js-test-driverのサンプルコードを元に、インライン宣言を使って書き直すと次のようになるはず(JsHintはおおよそ通したが未実行なので、修正が必要かもしれない)。また、高度な内容(コールバック関数の実行タイミングの制御など)はAsyncTestCase - js-test-driverを参照のこと。
AsyncTestCase('XhrTest', {
  'test XHR using callbacks':function(queue) {
    // Set up
    var xhr = new XMLHttpRequest();
    xhr.open('GET', '/some/path');

    var responseStatus;
    var responseBody;
    
    queue.call('Step 1: send a request to the server and save the response status and body', function(callbacks) {
      var onStatusReceived = callbacks.add(function(status) {
        responseStatus = status;
      });
      
      var onBodyReceived = callbacks.add(function(body) {
        responseBody = body;
      });

      xhr.onreadystatechange = function() {
        if (xhr.readyState === 2) { // headers and status received
          onStatusReceived(xhr.status);
        } else if (xhr.readyState === 4) { // full body received
          onBodyReceived(xhr.responseText);
        }
      };

      // Exercise
      xhr.send(null);
    });

    // Verify
    queue.call('Step 2: assert the response status and body matches what we expect', function() {
      assertEquals(200, responseStatus);
      assertEquals('hello', responseBody);
    });
  }
});

References

2013年5月4日土曜日

JsTestDriverでUnit Test + Code Coverage

はじめに

JsTestDriverで、JavaScriptのユニットテストを実行し、htmlレポートでカバレッジを確認する方法。環境は次の通り。
  • OS: Ubuntu 12.10 (Xubuntu)
  • エディタ: gedit
  • テストランナー: JsTestDriver 1.3.5
  • テストブラウザ: Firefox 20.0
  • カバレッジ・レポーター: LCOV 1.9
アウトラインは次の通り。なお、コードの書き方については深入りしない。JsTestDriverのProject Wikiを参照のこと。
  1. 初期設定
  2. 実行準備
  3. 実行
    • パスの場合
    • 失敗してデバッグの場合
  4. カバレッジ確認

初期設定

ディレクトリ構成は下記の通りとする。jarの配置がJsTestDriverおよびカバレッジ・プラグインのインストールに相当する。プロダクトコードgreeter.jsとテストコードgreetertest.jsの内容については、Getting Started with JsTestDriverを参照のこと。
.
├─ jsTestDriver.conf
├─ src
│   └─ greeter.js
├─ test-lib
│   └─ jstestdriver
│       ├─ JsTestDriver-1.3.5.jar
│       └─ plugins
│           └─ coverage-1.3.5.jar
├─ test-output
│   └─ coverage
└─ test-src
     └─ greetertest.js

設定ファイルjsTestDriver.confの内容は次のようになる。jsの読み込み順序をコントールしたい場合など、詳細については、ConfigurationFileを参照のこと。
server: http://localhost:4224

load:
  - src/*.js

test:
  - test-src/*.js

plugin:
 - name: "coverage"
   jar: "test-lib/jstestdriver/plugins/coverage-1.3.5.jar"
   module: "com.google.jstestdriver.coverage.CoverageModule"

実行準備

ユニットテストを実行する前に、テストサーバを起動しブラウザをキャプチャする必要がある。まずテストサーバを起動するために、シェルで次のコマンド実行する。
# オプション--portをjsTestDriver.confで指定したポート番号に一致させる
$ java -jar test-lib/jstestdriver/JsTestDriver-1.3.5.jar --port 4224
続いて、ブラウザからhttp://localhost:4224/captureにアクセスする。

コマンドライン・フラグで、シェルからテストサーバを起動すると同時に、ブラウザをキャプチャすることもできる。詳細については、CommandLineFlagsを参照のこと。

実行

シェルで次のコマンドを実行する。
$ java -jar test-lib/jstestdriver/JsTestDriver-1.3.5.jar --tests all --testOutput test-output

パスの場合

テストをパスするなら、次のような結果が返ってくる。カバレッジはファイルにしか出力されない。簡易にコンソールで確認したいなら、オプション--testOutputを外しておく。
setting runnermode QUIET
Firefox: Reset
Firefox: Reset
.
Total 1 tests (Passed: 1; Fails: 0; Errors: 0) (0.00 ms)
  Firefox 20.0 Linux: Run 1 tests (Passed: 1; Fails: 0; Errors 0) (0.00 ms)
加えて、test-outputにテスト結果(JUnit XML形式)とカバレッジ(LCOV互換)が出力される。
  • TEST-Firefox_200_Linux.GreeterTest.xml
  • jsTestDriver.conf-coverage.dat

失敗してデバッグの場合

失敗すると、次のような結果が返ってくる。カバレッジ用と思われるLCOV.jsの出力がうるさいけれど、カバレッジ有無を簡単に切り替えられるかどうか未確認。
setting runnermode QUIET
Firefox: Reset
Firefox: Reset
F
Total 1 tests (Passed: 0; Fails: 1; Errors: 0) (1.00 ms)
  Firefox 20.0 Linux: Run 1 tests (Passed: 0; Fails: 1; Errors 0) (1.00 ms)
    GreeterTest.testGreet failed (1.00 ms): AssertError: expected "Hello World!" but was "Hell World!"
      GreeterTest.prototype.testGreet@http://localhost:4224/test/src-test/greeter_test.js:7
      runTest@http://localhost:4224/test/com/google/jstestdriver/coverage/javascript/LCOV.js:203
      TestResultIterator.prototype.runNext@http://localhost:4224/test/com/google/jstestdriver/coverage/javascript/LCOV.js:292
      InstrumentedTestCaseRunner.prototype.run@http://localhost:4224/test/com/google/jstestdriver/coverage/javascript/LCOV.js:250
      InstrumentedTestCaseRunnerPlugin.prototype.runTestConfiguration@http://localhost:4224/test/com/google/jstestdriver/coverage/javascript/LCOV.js:221

失敗したメソッドをデバッグするため、そのメソッドのみを実行するために、シェルで次のコマンドを実行する。
$ java -jar test-lib/jstestdriver/JsTestDriver-1.3.5.jar --tests GreeterTest.testGreet
デバッグするのにconsole.log()で十分なら、コマンドラインフラグに --captureConsoleを付けてテストを実行すればよい。

ブレークポイントが必要なときは、ブラウザのデバッガを利用する。そのためには、キャプチャしたブラウザのデバッガ (Firefoxなら[ツール] > [Web開発] > [デバッガ]、あるいはFireBug) を開き、ブレークポイントを設定すればよい。再度、上記コマンドを再実行すると、ブレークポイントで停止するので、デバッグできる。

console.log()にせよブラウザのデバッガにせよ、ここでもカバレッジ用のコードが挿入されて煩わしいが、カバレッジ有無を簡単に切り替えられるかどうか未確認。

カバレッジ確認

カバレッジデータjsTestDriver.conf-coverage.datの/./を/に置換しておく。本来的には不要な作業だが、置換しておかないとカバレッジのhtmlレポートがリンク切れを起こす。恐らくIssue 367と同じ問題が発生していると思われる。

htmlレポートを生成するには、シェルで下記コマンドを実行する。test-output/coverage以下に多数のファイルが生成されるが、index.htmlがエントリーポイント。genhtmlの詳細については、Linux Test Project - Coverage » lcovを参照のこと。
$ genhtml -o test-output/coverage -f test-output/jsTestDriver.conf-coverage.dat

雑感

カバレッジを測ろうとすると、出力が汚くなる。特にデバッグコードに測定用コードが挿入されてしまうのが煩わしい。簡単に切り替えられると良いのだけれど、カバレッジ有無がコマンドライン・フラグではなくて設定ファイルのようなので面倒。設定ファイルの切り替えにしようとすると、ほぼ複製になってしまう。

Issue 367のgenhtmlで生成するHTMLレポートのリンクが切れる問題は、置換してから渡すようなワンライナーで当座はしのげそう。jsTestDriverというよりLCOVの問題だけれど、変更履歴を見ると1.10では解決されていそう。
- Fixed directory prefix calculation
Linux Test Project - Coverage » lcov

2013年5月2日木曜日

JavaScriptのUnit Test Tool

まとめた先から情報が古くなりそうで躊躇していたけれど、JavaScriptのUnit Testツールについて、最近見かけた情報をまとめてみる。


『JavaScript Unit Test Why? What? How?』はUnit Testツールを次の4レイヤで分離している。特定レイヤのみの機能を提供するツールもあれば、複数レイヤの機能を提供するツールもある。図でテスティングフレームワークがさらに分かれているのは、Mochaが好みのAssertionライブラリ (Chaiなど)を使える設計になっているため。
  • モックライブラリ
  • テスティングフレームワーク
  • リモートテストランナー
  • 実行環境

これらのツールは組み合わせて使うことができる。フレームワークで実践! JavaScriptテスト入門では、次の4パターンを紹介している。また、『JavaScriptの開発効率を高める7つのライブラリ』では、JasmineとSino.JSを組み合わせている。

上記連載の著者のスライド『JavaScriptテストフレームワークを諸々眺めてみる』では、上記の他に次のテストフレームワークを紹介している。

また、モックライブラリSinon.JSについては、Sinon.JSが詳しい。『JavaScriptの開発効率を高める7つのライブラリ』では、モック機能を持つJasmineと組み合わせているし、Sinon.JSが定番という理解でよさそう。

この他のツールとして、上記リンク先の複数がBuster.JSを挙げている。Buster.jsはJsTestDriverと同じレイヤをカバーしていて、Sinon.JSをバンドルしているので、カバー範囲が広い。Vowというものもあるらしい。それから、Yahoo!のYUI TestがIBM developerWorksで紹介されている。

書籍に目をやると、定番とおぼしき2011年発売の『テスト駆動JavaScript』で主に取り扱っているのは、JsTestDriver。それから、最近日本語訳が発売された『メンテナブルJavaScript』では「19章 自動テスト」で次の4つのツールを紹介している。どちらも未読なので、どれくらい詳しく紹介されているかは未確認。
  • YUI.Test.Selenium.Driver
  • Yeti
  • PhantomJS
  • JsTestDriver

上記をざっと見ると、jsTestDriverの支持率が高そう。それから、比較の際に着目されているのは、次の4点。個人的には、加えて出力形式も気になるところ。
  • スタイル: TDD? BDD?
  • 実行環境: 実ブラウザ? ヘッドレスブラウザ?(=Phantomjs) シミュレータ?
  • 非同期対応
  • CI対応

以下、余談。別の観点では、そもそもテスタビリティの低いコードは、悪いコードだというわけで、Lintや静的解析ツールも有用そう。Lintだと、JSLint, JSHint, Closure Linterが有名そう。静的解析ツールはあまり見当たらない。jsmeter, complexityReport.jsあたりか?

2013/05/05追記:
静的解析ツールにplatoというツールも見つかった。

2013/05/09追記:
カバレッジ計測に限れば、JSCoverというJSCoverageの後継のツールもある。

2013年3月1日金曜日

インスタンスを生成するメソッドの名前

インスタンスを生成するメソッドの名前について、よく迷うのでまとめておく。

まずは、スタティックファクトリメソッド。"Effective Java (2nd Edition)"の"Item 2: Consider Static Factory Methods instead of Constructors"に倣って使うようにしているけれど、感覚で名前をつけてしまうことがあるので、気をつけないと。
  • valueOf: パラメータと同じ意味合いの値を持つインスタンスを返す。Integer#valueOf(int)はじめ、型変換メソッドが典型例。
  • of: valueOfと同じ。EnumSetで使われている。
  • getInstance: 指定したパラメータから組み立てたインスタンスを返す。Singletonパターンでよく使われる。
  • newInstance: getInstanceと同様だけれど、必ず新しいインスタンスを返す。だからSingletonパターンでは使えない。
  • getType: getInstanceと同様だけれど、ファクトリメソッドが他のクラスにある場合に使う。Typeは返値の型名で置き換えて使う。
  • newType: newInstanceと同様だけれど、ファクトリメソッドが他のクラスにある場合に使う。Typeは返値の型名で置き換えて使う。

続いて、インスタンスの型変換メソッド。つまりインスタンスを元に別のクラスのインスタンスを作っているメソッドの名前についてもまとめておく。いずれもTypeは変換後の型名で置き換えて使う。こちらは"Item 56: Adhere to Generally Accepted Naming Conventions"の一部。
  • toType: 異なった型の独立したオブジェクトを返す。例えば、Object#toString() 。
  • asType: そのオブジェクトの異なる型での表現を返す。adapter パターン (Effective Java ではView) で使う。例えば、Arrays#asList(T... a)。
  • typeValue: プリミティブ型を返す。例えば、Integer#intValue()。

References

2013年2月6日水曜日

Selenium 2.xで条件が満たされるまで待つ

Selenium 2.X (確認したのは2.28) で何か条件が満たされる(例えば、JavaScriptが実行されてロード時はクリックできない要素がクリックできるようになる)まで待つには、WebDriverWait#until(Predicate)を使う。
WebDriverWait wait = new WebDriverWait(driver, 10);
WebElement element = wait.until(ExpectedConditions.elementToBeClickable(By.id("id")));
引数に渡しているインスタンスが条件を表している。よくある条件はExpectedConditionsクラスに用意されている。not(ExpectedCondition)もあるのでたいていの場合はこれで事足りる。

単に待つだけじゃなく、条件を満たした時のインスタンスを返してくれるのが便利。

独自の条件が必要な場合は、ExpectedCondition<T>インタフェースを実装して、applyメソッドをオーバーライドする。その返値の型は、インタフェースの型パラメータで指定する。nullでもfalseでもない値が返されると、条件を満たしたと判定される。

車輪の再発明だけれど、上記の例を無名クラスを使って実装すると、次のようになる。
WebDriverWait wait = new WebDriverWait(driver, 10);
WebElement element = wait.until(new ExpectedCondition(){
      @Override
      public WebElement apply(WebDriver d) {
      return d.findElement(By.id("id"));
}});

References

Selenium 2.xでHTML要素の非存在をチェックする

Selenium 2.X (確認したのは2.28) で指定したHTML要素が存在しないことをチェックするには、WebDriver#findElements(By)を使う。返値 (List) の長さが0なら、存在しない。例えば、hogeクラスが指定されたdiv要素が存在しないことをチェックするなら、下記のようになる。

if (driver.findElements(By.cssSelector("div.hoge")).size() == 0) {
  // hogeクラスが指定されたdiv要素は存在しない
}
このことは、WebDriver#findElements(By)じゃなくてWebDriver#findElement(By)のJavadocに書いてある。ちょっと回りくどい上に、使うメソッドのJavadocには特に何も書いてなくて、よく見失う。
findElement should not be used to look for non-present elements, use findElements(By) and assert zero length response instead.
WebDriver#findElement(By)
1.XのAPIだと、Selenium#isElementPresent(String)というそのものズバリのメソッドがある。後方互換性があるから今でも動作するけれど、それを使うのにはパッと思いつくだけでデメリットが3つある。というわけで、なるべく2.XのAPIで統一していきたい。
  • 1.XのAPIは要素の指定で、バグを作りやすい ("locatorType=argument"フォーマットのStringで、"locatorType="の省略時動作に暗黙のルールがある)
  • APIのスタイルが混在すると、リーダビリティが下がる
  • 古いAPIはいつまでも使い続けられるとは限らない

References