ヘルパーの構築

このチュートリアルでは、プラグインのコアコンセプトに精通していることを前提としています。まだこの記事を読んでいない場合は、続行する前に読むことをお勧めします。

チェーン可能なヘルパーアサーションを提供することは、Chai が公開するプラグインユーティリティの最も一般的な使用法です。基本に入る前に、Chai のアサーションを理解するために拡張するトピックが必要になります。このために、非常に最小限のデータモデルオブジェクトを使用します。

/**
 * # Model
 *
 * A constructor for a simple data model
 * object. Has a `type` and contains arbitrary
 * attributes.
 *
 * @param {String} type
 */

function Model (type) {
  this._type = type;
  this._attrs = {};
}

/**
 * .set (key, value)
 *
 * Set an attribute to be stored in this model.
 *
 * @param {String} key
 * @param {Mixted} value
 */

Model.prototype.set = function (key, value) {
  this._attrs[key] = value;
};

/**
 * .get (key)
 *
 * Get an attribute that is stored in this model.
 *
 * @param {String} key
 */

Model.prototype.get = function (key) {
  return this._attrs[key];
};

実際には、これはノードの ORM データベースから返されたり、ブラウザで選択した MVC フレームワークから構築されたりする任意のデータモデルオブジェクトである可能性があります。

うまくいけば、Model クラスは説明不要でしょうが、例として、ここで person オブジェクトを構築します。

var arthur = new Model('person');
arthur.set('name', 'Arthur Dent');
arthur.set('occupation', 'traveller');
console.log(arthur.get('name')); // Arthur Dent

これで主題ができたので、プラグインの基本に進むことができます。

言語チェーンの追加

さあ、楽しい部分に入りましょう!プロパティとメソッドの追加は、Chai のプラグイン API が本当に得意とするところです。

プロパティの追加

本質的に、プロパティの定義は Object.defineProperty を使用して行うことができますが、Chai のユーティリティヘルパーを使用して、全体で標準的な実装を確保することをお勧めします。

この例では、次のテストケースがパスすることを望んでいます

var arthur = new Model('person');
expect(arthur).to.be.a.model;

このために、addProperty ユーティリティを使用します。

utils.addProperty(Assertion.prototype, 'model', function () {
  this.assert(
      this._obj instanceof Model
    , 'expected #{this} to be a Model'
    , 'expected #{this} to not be a Model'
  );
});

addProperty API の表示

シンプルで簡潔です。Chai はここから引き継ぐことができます。この拡張パターンは非常に頻繁に使用されるため、Chai は少し簡単になります。次のコードは、上記の最初の行の代わりに使用できます

Assertion.addProperty('model', function () { // ...

すべてのチェーン拡張ユーティリティは、utils オブジェクトの一部として、および Assertion コンストラクターで直接提供されます。ただし、このドキュメントの残りの部分では、Assertion から直接メソッドを呼び出します。

メソッドの追加

注: addMethod を使用して同じメソッド名を定義する複数のプラグインは競合し、最後に登録されたプラグインが勝ちます。プラグイン API は、Chai の将来のバージョンで大幅な見直しが予定されており、とりわけ、この競合に対処する予定です。当面は、overwriteMethod を使用することをお勧めします。

プロパティは優れたソリューションですが、構築しているヘルパーには十分な具体性がない可能性があります。モデルには型があるため、モデルが特定の型であるとアサートすると有益です。このために、メソッドが必要です。

// goal
expect(arthur).to.be.a.model('person');

// language chain method
Assertion.addMethod('model', function (type) {
  var obj = this._obj;

  // first, our instanceof check, shortcut
  new Assertion(this._obj).to.be.instanceof(Model);

  // second, our type check
  this.assert(
      obj._type === type
    , "expected #{this} to be of type #{exp} but got #{act}"
    , "expected #{this} to not be of type #{act}"
    , type        // expected
    , obj._type   // actual
  );
});

addMethod API の表示

assert のすべての呼び出しは同期であるため、最初の呼び出しが失敗すると、AssertionError がスローされ、2 番目の呼び出しには到達しません。メッセージを解釈し、失敗したアサーションの表示を処理するのは、テストランナー次第です。

プロパティとしてのメソッド

Chai には、プロパティまたはメソッドとして機能できる言語チェーンを構築できる独自のユーティリティが含まれています。これらを「チェーン可能なメソッド」と呼びます。「モデルのモデルである」をプロパティとメソッドの両方として実演しましたが、これらのアサーションはチェーン可能なメソッドの良いユースケースではありません。

いつ使用するか

チェーン可能なメソッドをいつ最適に使用するかを理解するために、Chai のコアからのチェーン可能なメソッドを調べてみましょう。

var arr = [ 1, 2, 3 ]
  , obj = { a: 1, b: 2 };

expect(arr).to.contain(2);
expect(obj).to.contain.key('a');

これを機能させるには、2 つの個別の関数が必要です。チェーンがプロパティまたはメソッドとして使用されるときに呼び出される関数と、メソッドとしてのみ使用されるときに呼び出される関数です。

これらの例では、コアの他のすべてのチェーン可能なメソッドと同様に、プロパティとしての contain の唯一の機能は、contains フラグを true に設定することです。これは、keys が異なる動作をすることを示します。この場合、keycontain と組み合わせて使用される場合、すべてのキーとの完全一致をチェックするのではなく、キーの包含をチェックします。

いつ使用しないか

model のチェーン可能なメソッドを上記のように動作するように設定したとしましょう。プロパティとして使用する場合は instanceof チェックを行い、メソッドとして使用する場合は _type チェックを行います。次の競合が発生します…

次のコードは動作します…

expect(arthur).to.be.a.model;
expect(arthur).to.be.a.model('person');
expect(arr).to.not.be.a.model;

しかし、次のコードは動作しません…

expect(arthur).to.not.be.a.model('person');

プロパティアサーションとして使用される関数は、メソッドとしても使用されるときに呼び出され、否定は設定後にすべてのアサーションに影響を与えるため、expected [object Model] not to be instance of [object Model] のようなエラーメッセージが表示されることに注意してください。そのため、チェーン可能なメソッドを構築するときは、この一般的なガイドラインに従ってください。

チェーン可能なメソッドを構築する場合、プロパティ関数は、後で既存のアサーションの動作を変更するためのフラグを設定するだけにする必要があります。

適切な例

モデルの例で使用するために、アーサーの年齢を正確にテストしたり、abovebelowwithin などの Chai の数値比較器にチェーンできる例を構築します。コア機能を破壊せずにメソッドを上書きする方法を学ぶ必要がありますが、それは少し後で説明します。

私たちの目標は、次のすべてがパスできるようにすることです。

expect(arthur).to.have.age(27);
expect(arthur).to.have.age.above(17);
expect(arthur).to.not.have.age.below(18);

まず、チェーン可能なメソッドに必要な 2 つの関数を作成することから始めましょう。最初に age メソッドを呼び出すときに使用する関数です。

function assertModelAge (n) {
  // make sure we are working with a model
  new Assertion(this._obj).to.be.instanceof(Model);

  // make sure we have an age and its a number
  var age = this._obj.get('age');
  new Assertion(age).to.be.a('number');

  // do our comparison
  this.assert(
      age === n
    , "expected #{this} to have age #{exp} but got #{act}"
    , "expected #{this} to not have age #{act}"
    , n
    , age
  );
}

これで、説明不要のはずです。次にプロパティ関数です。

function chainModelAge () {
  utils.flag(this, 'model.age', true);
}

後で、数値比較器にそのフラグを探して動作を変更するように教えます。コアメソッドを壊したくないので、そのメソッドを安全にオーバーライドする必要がありますが、それについては後で説明します。まずここで終えましょう…

Assertion.addChainableMethod('age', assertModelAge, chainModelAge);

addChainableMethod API の表示

完了。これで、アーサーの正確な年齢をアサートできます。メソッドを上書きする方法を学習するときに、この例に戻ってきます。

言語チェーンの上書き

言語チェーンにアサーションを正常に追加できるようになったので、Chai のコアやその他のプラグインからのアサーションなど、既存のアサーションを安全に上書きできるようにする必要があります。

Chai には、既存のアサーションの既存の動作を上書きできるが、アサーションの対象が基準を満たさない場合は、すでに定義されているアサーションの動作に戻すことができるユーティリティが多数用意されています。

まずは、プロパティの上書きの簡単な例から始めましょう。

プロパティの上書き

この例では、Chai のコアによって提供される ok プロパティを上書きします。デフォルトの動作では、オブジェクトが truthy の場合、ok がパスします。その動作を変更して、ok がモデルのインスタンスで使用される場合、モデルが整形式であることを検証するようにします。例では、モデルに id 属性がある場合、モデルが ok であると見なします。

基本的な上書きユーティリティと基本的なアサーションから始めましょう。

chai.overwriteProperty('ok', function (_super) {
  return function checkModel () {
    var obj = this._obj;
    if (obj && obj instanceof Model) {
      new Assertion(obj).to.have.deep.property('_attrs.id').a('number');
    } else {
      _super.call(this);
    }
  };
});

overwriteProperty API の表示

上書きの構造

ご覧のとおり、上書きの主な違いは、最初の関数が _super の 1 つの引数のみを渡すことです。これは元々存在していた関数であり、基準が一致しない場合は必ずその関数を呼び出す必要があります。次に、実際のアサーションとして機能する新しい関数をすぐに返していることに気づくでしょう。

これで、肯定的なアサーションを記述できます。

var arthur = new Model('person');
arthur.set('id', 42);
expect(arthur).to.be.ok;
expect(true).to.be.ok;

上記のエクスペクテーションはパスします。モデルを使用する場合はカスタムアサーションが実行され、非モデルを使用する場合は元の動作に戻ります。ただし、モデルに対して ok アサーションを否定しようとすると、少し問題が発生します。

var arthur = new Model('person');
arthur.set('id', 'dont panic');
expect(arthur).to.not.be.ok;

私たちのステートメントは否定され、ID は数値ではないため、このエクスペクテーションもパスすることを期待します。残念ながら、否定フラグは数値アサーションに渡されなかったため、依然として値が数値であることを期待しています。

フラグの転送

このために、元のassertionからすべてのフラグを新しいassertionに転送することで、この主張を拡張します。最終的なプロパティの上書きは次のようになります。

chai.overwriteProperty('ok', function (_super) {
  return function checkModel () {
    var obj = this._obj;
    if (obj && obj instanceof Model) {
      new Assertion(obj).to.have.deep.property('_attrs.id'); // we always want this
      var assertId = new Assertion(obj._attrs.id);
      utils.transferFlags(this, assertId, false); // false means don't transfer `object` flag
      assertId.is.a('number');
    } else {
      _super.call(this);
    }
  };
});

これで、否定フラグが新しいassertionに含まれ、idの型に関する肯定と否定の両方のassertionを正常に処理できます。プロパティassertionは、idが存在しない場合に常に失敗するように、そのままにしました。

エラーメッセージの強化

ただし、もう1つ小さな修正を加える必要があります。id属性の型が間違っているためにassertionが失敗した場合、expected 'dont panic' to [not] be a numberというエラーメッセージが表示されます。大規模なテストスイートを実行する際には、あまり役に立ちません。そのため、もう少し情報を提供します。

var assertId = new Assertion(obj._attrs.id, 'model assert ok id type');

これにより、エラーメッセージはより情報量の多いmodel assert ok id type: expected 'dont panic' to [not] be a numberに変わります。はるかに有益です!

メソッドの上書き

メソッドの上書きは、プロパティの上書きと同じ構造に従います。この例では、アーサーの年齢が最小しきい値以上であることをassertionする例に戻ります。

var arthur = new Model('person');
arthur.set('age', 27);
expect(arthur).to.have.age.above(17);

すでにmodel.ageでassertionにフラグを立てるためのageチェーンがあるので、存在するかどうかを確認するだけで済みます。

Assertion.overwriteMethod('above', function (_super) {
  return function assertAge (n) {
    if (utils.flag(this, 'model.age')) {
      var obj = this._obj;

      // first we assert we are actually working with a model
      new Assertion(obj).instanceof(Model);

      // next, make sure we have an age
      new Assertion(obj).to.have.deep.property('_attrs.age').a('number');

      // now we compare
      var age = obj.get('age');
      this.assert(
          age > n
        , "expected #{this} to have an age above #{exp} but got #{act}"
        , "expected #{this} to not have an age above #{exp} but got #{act}"
        , n
        , age
      );
    } else {
      _super.apply(this, arguments);
    }
  };
});

overwriteMethod APIを表示

これで肯定と否定の両方のシナリオに対応できます。この場合、this.assertが自動的に処理するため、フラグを転送する必要はありません。belowwithinにも同じパターンを使用できます。