読者です 読者をやめる 読者になる 読者になる

おまえは今まで実行したassertの回数を覚えているのか?あるいは新しいアサーションユーティリティのご提案

JavaScript Advent Calendar 2014 11日目。

いきなり要約: Promiseや非同期テストのアサーションを簡単確実に書けるようになるesplanというライブラリのPoCを作った話。

Promiseや非同期のテストは難しい

詳しくはJavaScript Promiseの本: Chapter.3 Promiseのテストをご覧いただきたいのだが、Promiseのテストを正確に書くのはそんなに簡単ではない。

例えばmochaだと、

// 間違ったテスト1:
// mayBeResolveWithOne() が1以外でresolveしたときタイムアウトエラーになる
it("mayBeResolveWithOne()は1でresolveする", function(done) {
    mayBeResolveWithOne().then(function(value) {
        assert(value === 1);// => throw AssertionError
        done();
    });
});

// 間違ったテスト2:
// mayBeRejected()がresolveを返すとcatchが呼ばれずにテストが成功してしまう
it("mayBeRejected()が'woo'でrejectする", function() {
    return mayBeRejected().catch(function(error) {
        assert(error.message === "woo");
    });
});

上記のテストがなぜ正しくないか分からない人は、JavaScript Promiseの本: Chapter.3 Promiseのテストを読んでください。つか分かった人も読んだほうがいいです。

なぜQUnitに解決できたのか?

ところで最近QUnitが次期バージョンに向けて人知れず進化している。

そんなQUnitでは先ほどの2つ目のテストを単純にこう書けるようになった。

QUnit.test("mayBeRejected()が'woo'でrejectする", function(assert) {
    return mayBeRejected().catch(function(error) {
        assert.ok(error.message === "woo");
    });
});

もしmayBeRejected()がresolveしてしまってcatch()が呼ばれなかったとしても、最初のassertが実行されるまで待ってくれる。

assertを2つ以上書く場合はassert.expect(2)のように事前にアサート回数を宣言することで同様のことができる。

QUnit.test("mayBeRejected()が'woo'でrejectする", function(assert) {
    assert.expect(2);
    return mayBeRejected().catch(function(error) {
        assert.ok(error instanceof Error);
        assert.ok(error.message === "woo");
    });
});

非同期な挙動に対するユニットテストの難しさの原因は、いつまでassertし続ければ(待ち続ければ)テストが成功なのかテストランナーには分からないことだ。QUnitでは最低1回またはassert.expect(n)の指定回数待つことをユーザーが記述し、かつQUnit側から実行回数をカウント可能なアサーションライブラリを提供することでこれを解決している。

アサーションライブラリが統合されているQUnit(やJasmine)に対して、mochaとアサーションライブラリは疎結合だ(例の図を思い出せ!)。というか、mocha側はErrorがthrowされるかどうかしか見てない。

ということで、今朝作りました。

esplan

定番の"es"にplanをくっつけた安易なネーミングでひとまずPoCを作った。

やっていることは単純で、

  1. アサート回数をカウントできるようにassertパッケージをラップ
  2. esprimaでAST化したテストコードを走査して、it('title', function() {})の第二引数のテスト関数を抽出
  3. テスト関数内部を走査して、計画されているassertの回数をカウント
  4. テスト関数の先頭にassert回数をassert.$$plan(n)として挿入(QUnitassert.expect(n)に相当)
  5. テストを実行し、計画されたassert回数に達するかタイムアウトまで待つ

例えばこんな感じのテストコード。直感的には正しそうだけど、mayBeResolve()が変なvalueを渡してきてもAssertion ErrorがPromiseの例外補足により無視されてテストが成功してしまう。

// 元のコード
var esplan = require('esplan');
var assert = esplan.register(require('assert'));

describe('Promise', function() {
    it('can not detect an assertion error in `then` function', function() {
        mayBeResolve().then(function(value) {
            assert.equal(value.length, 2);
            assert.equal(value[0], 'foo');
            assert.equal(value[1], 'bar');
        });
    });
});

example/build.js みたいなコードでこう変換できる。

// 変換後のコード
var esplan = require('esplan');
var assert = esplan.register(require('assert'));

describe('Promise', function () {
    // `$$done`が引数に追加された
    it('can not detect an assertion error in `then` function', function($$done) {
        assert.$$plan(this, 3, $$done); // 3回のassertを計画
        mayBeResolve().then(function (value) {
            assert.equal(value.length, 2);
            assert.equal(value[0], 'foo');
            assert.equal(value[1], 'bar');
        });
    });
});

もしmayBeResolve()`['foo', 'bar']ではなく['foo', 'wrong!']を渡してくると、エラーはこう表示される。

Error: Expected 3 assertions, but actually 2 assertions called

doneを手動で記述してもdone未実行時のエラーが "Timeout" しか表示されず情報に乏しい、という問題も解決している。

アサート回数を手で書きたくない

QUnitのようにアサートの回数を宣言するテストフレームワークJavaScriptだとsubstack/tapeなんかがあるけど、葉桜JSのときにid:t-wadaさんからこれはPerl文化圏のテスト作法だと教えてもらった。

たしかにこれは特に非同期テストなどで分かりやすいし確実なのだけど、jQueryプロジェクトのテストでexpect(50)とかexpect(119)とかをプルリクで微修正する様子を見ていたため、これは人間が書くべきものではないと思っていた。

今回の方式だと自動でカウントしてくれるので、アサート回数を管理はしたくないけどdoneを使うよりも単純に確実にアサートできるっていうことで一石二鳥な気がしている。

TODO

もし気分が乗っていたら次の課題はこのあたり

  • itをfor文の中で回すようなコードには対応できないので、適用対象外にする構文が必要
  • 標準assertとmocha BDDスタイル以外への対応
  • power-assertとの同居(たぶん順番に気をつければできそう)
  • よりeasyに(各種環境やビルドツールへの対応)
  • source map対応

ただ、絶大な恩恵を受けられるpower-assertと違って、たったこれだけのためにテストコードにコンパイルをかませるのは少し気が重い。頑張れば手で書けないこともないし。

もっと手軽にAST変換系ツールチェインを導入できるようになる何かを待つか、あるいはpower-assertに便乗できると良いのかもしれない。