Chaiをやめてpower-assertを使うことにした

はじめに

前回のJavaScript速習エントリの「おわりに」も書いたんですが、ここ最近はずっとNode.jsを触っていて、最近はテストを書くのにも慣れてきました。
その過程でChaiをやめてpower-assertを使うようになったので、その理由を忘れないように書き留めてみました。

Chaiを使っていて感じたこと

RubyではRSpecを使ってテストを書いていたので、特に何も考えずにMochaとChaiの組み合わせでテストを書き始めたんですが、使っているうちに徐々に違和感を覚えるようになってきました。

例えば、次のような場合です。

const chai = require("chai");
const expect = chai.expect;

describe("Array.from()", () => {
  let subject;

  context('when "foo" is passed', () => {
    beforeEach(() => {
      subject = Array.from("foo");
    });

    it('should include "foo"', () => {
      expect(subject).to.include("foo");
    });

    it('should include "f"', () => {
      expect(subject).to.include("f");
    });
  });
});

このコードを実行すると、次のような結果が得られます。

Array.from()
  when "foo" is passed
    1) should include "foo"
    ✓ should include "f"


1 passing (16ms)
1 failing

1) Array.from() when "foo" is passed should include "foo":
   AssertionError: expected [ 'f', 'o', 'o' ] to include 'foo'

このように、itの中で一つの項目のみを検証する場合、expectから始まるメソッドチェーンとテストケースの説明(テスト成功時のメッセージとしても使用される)がほとんど一致するという現象が起きます。
こんなとき、「似たようなことを繰り返し書かなきゃいけないし、RSpecの時と何か違うなあ」と感じていました。

一方、RSpecでは

ここで、RSpecではどのように書けていたかを振り返ってみると

  • subjectis_expectedを使う
  • itの第1引数にテストケースの説明となる文字列を渡さず、{}を使って一行で書く
  • 適切なマッチャーを選択する

といったことを守ることで、次のようなメリットを得ることができました。

  • テスト失敗時に適切なメッセージが表示される
  • 成功時のメッセージも自動でいい感じに生成される
  • コードそのものが自然言語(英語)のように読める

例えば、こんな感じに書くと

RSpec.describe "Kernel#Array" do
  subject { Array(arg) }

  context 'when "foo" is passed' do
    let(:arg) { "foo" }

    it { is_expected.to include "foo" }
    it { is_expected.to include "f" }
  end
end

次のような結果が得られます。

Kernel#Array
  when "foo" is passed
    should include "foo"
    should include "f" (FAILED - 1)

Failures:

  1) Kernel#Array when "foo" is passed should include "f"
     Failure/Error: it { is_expected.to include "f" }
       expected ["foo"] to include "f"
     # ./kernel_spec.rb:8:in `block (3 levels) in <top (required)>'

Finished in 0.02618 seconds (files took 0.1921 seconds to load)
2 examples, 1 failure

テスト失敗時にexpected ["foo"] to include "f"というメッセージが得られるのはもちろんのこと、成功時にもshould include "foo"というメッセージが自動で生成されていることがわかります。
また、it { is_expected.to include "foo" }という一行はもはや英語として解釈することができますね\(^o^)/

というわけで、RSpecは覚えることが多くて辛いという声もありますが*1、学習コストが高い分それなりに見返りがあるのでは?というのが個人的な見解です。

どうしてこうなったか

皆さんもご存知の通り、MochaはJavaScriptのテストフレームワークのひとつですが、RSpecを始めとする他のフレームワークとは異なり、アサーションの機構を包含していません。
もう少し技術的な言い方をすると、itの第2引数に渡した関数を実行した際にAssertionErrorthrowされたらテスト失敗とみなす、という枠組みだけを提供しています。

こうすることで、開発者が好きなアサーションライブラリを使えるようにしていますが、その裏返し(?)として、テストケースの説明となる文字列をitの第1引数に取るAPIになっています。
そのため、Chaiのマッチャーを頑張って覚えても「テスト失敗時に適切なメッセージが表示される」以外のメリットがなく、これが冒頭で挙げた違和感につながっていたというわけです。

ここまで考えて、Mochaを捨てるという選択をしない限り*2、Chaiを使う必要はないなあという結論に至りました。

結局どうしたか

以上を踏まえた結果、次のような方針でテストを書くことにしました。

  • アサーションライブラリにChaiではなくpower-assertを使う
  • itに渡すテストケースの説明を「何を検証しているのかわかるように」書く*3

つまり、どうせテストケースの説明を書かないといけないのなら、マッチャーを自然言語に寄せる意味があまりないので覚えるのをやめようという手抜き方針です。

この方針のもとでは、前述のテストコードは次のようになります。

const assert = require("power-assert");

describe("Array.from()", () => {
  let subject;

  context('when "foo" is passed', () => {
    beforeEach(() => {
      subject = Array.from("foo");
    });

    it('should include "foo"', () => {
      assert(subject.indexOf("foo") !== -1);
    });

    it('should include "f"', () => {
      assert(subject.indexOf("f") !== -1);
    });
  });
});

このコードを実行すると、次のような結果が得られます。

Array.from()
  when "foo" is passed
    1) should include "foo"
    ✓ should include "f"


1 passing (73ms)
1 failing

1) Array.from() when "foo" is passed should include "foo":

    AssertionError:   # test/array_test.js:14

assert(subject.indexOf("foo") !== -1)
       |       |              |   |
       |       |              |   -1
       |       -1             false
       ["f","o","o"]

    + expected - actual

    -false
    +true

標準のassertモジュールではなくpower-assertを使うことで、テスト失敗時の情報量を損なうことなく、当初の問題を解決することができました。

おわりに

RSpecの、Rubyの力を使ってギリギリ成り立ってる感が好きです。

*1:RSpec についての議論 - Togetterまとめ

*2:Mocha以上にバランスの取れたライブラリの形を想像できませんでした/(^o^)\

*3:マッチャーを使わない分ちゃんと書く必要があると思っています