建立輔助工具

本教學假設您已熟悉外掛程式的核心概念。如果您尚未閱讀該文章,建議您在繼續之前先閱讀。

提供可鏈接的輔助斷言是 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 類別是不言自明的,但作為範例,這裡我們建構一個人員物件。

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,並且不會到達第二個呼叫。由測試執行器來解釋訊息並處理任何失敗的斷言的顯示。

將方法作為屬性

Chai 包含一個獨特的工具,可讓您建構一個可用作屬性或方法的語言鏈。我們將這些稱為「可鏈接方法」。儘管我們將「是模型」示範為屬性和方法,但這些斷言並非可鏈接方法的好用例。

何時使用

為了了解何時最好使用可鏈接方法,我們將檢查 Chai 核心中的可鏈接方法。

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

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

為了使其運作,需要兩個單獨的函式。一個函式在鏈用作屬性或方法時被呼叫,另一個函式僅在用作方法時被呼叫。

在這些範例中,以及核心中的所有其他可鏈接方法中,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] 的錯誤訊息。因此,請在建構可鏈接方法時遵循此一般準則。

在建構可鏈接方法時,屬性函式應僅用於設定標誌,以便稍後修改現有斷言的行為。

適當的範例

為了配合我們的模型範例使用,我們將建構一個範例,允許我們精確地測試 Arthur 的年齡,或鏈接到 Chai 的數值比較器,例如 abovebelowwithin。您需要學習如何在不破壞核心功能的情況下覆寫方法,但我們稍後會介紹。

我們的目標是允許以下所有內容通過。

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

讓我們首先撰寫可鏈接方法所需的兩個函式。首先是呼叫 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

完成。現在我們可以斷言 Arthur 的確切年齡。當學習如何覆寫方法時,我們將再次從這個範例開始。

覆寫語言鏈

現在我們可以成功地將斷言新增到語言鏈中,我們應該努力能夠安全地覆寫現有的斷言,例如來自 Chai 核心或其他外掛程式的斷言。

Chai 提供了許多工具,可讓您覆寫已存在斷言的現有行為,但如果斷言的主體不符合您的條件,則會還原為已定義的斷言行為。

讓我們從覆寫屬性的簡單範例開始。

覆寫屬性

對於此範例,我們將覆寫 Chai 核心提供的 ok 屬性。預設行為是如果物件為真值,則 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 引數。這是原本就存在的函式,如果你的條件不符合,應該要確定呼叫它。其次,你會注意到我們立即返回一個新的函式,該函式將作為實際的斷言。

有了這個,我們可以編寫正向斷言。

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 不是數字。不幸的是,取反標誌沒有傳遞到我們的數字斷言,所以它仍然期望該值是一個數字。

傳遞標誌

為此,我們將擴展此斷言,將原始斷言的所有標誌傳遞到我們的新斷言。最終的屬性覆寫將如下所示。

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);
    }
  };
});

現在,取反標誌包含在你的新斷言中,我們可以成功地處理 id 類型的正向和負向斷言。我們保持屬性斷言不變,因為我們總是希望在 id 不存在時失敗。

增強錯誤訊息

不過,我們還需要做一個小修改。如果我們的斷言因錯誤的 id 屬性類型而失敗,我們會收到一個錯誤訊息,指出 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。更有資訊性!

覆寫方法

覆寫方法遵循與覆寫屬性相同的結構。在這個例子中,我們將回到我們斷言亞瑟的年齡高於最低門檻的例子。

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

我們已經有了 age 鏈,用 model.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