构建辅助函数

本教程假设您熟悉插件 核心概念。如果您还没有阅读过这篇文章,建议您在继续之前阅读。

提供可链式辅助断言是 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];
};

实际上,这可以是任何从 node 中的 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 调用方法。

添加方法

注意:多个插件使用 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] 的错误消息。因此,在构建可链式方法时,请遵循此一般准则。

在构建可链式方法时,属性函数应该只用于为以后修改现有断言的行为设置标记。

合适的示例

为了与我们的模型示例一起使用,我们将构建一个示例,该示例允许我们准确地测试亚瑟的年龄,或者链接到 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

完成。现在我们可以断言亚瑟的确切年龄。在学习如何覆盖方法时,我们将再次使用此示例。

覆盖语言链

现在我们可以成功地向语言链添加断言,我们应该努力能够安全地覆盖现有断言,例如来自 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