没有API就是最好的API-断言库的优雅力量

原文链接:

Facebook React 库背后的核心思想之一是,不需要为已经知道如何在原生 JavaScript 中实现的东西学习新的 API 。当你可以使用很老的 Array.map() 时,为什么还要记住 Angular ng-repeat 语法呢? 这是一个好主意,而这正是使该项目首先吸引开发人员的重要原因。 所以为什么 Jest Jest 是这家公司的 JavaScript 测试框架,也是一个 React 开发者的流行选择)鼓励你学习一个新的断言 API ,并当你已经知道如何在原生 JavaScript 中断言同一件事时,编写像如下的代码呢?

expect(result).toEqual(expect.not.stringContaining(unexpectedSubstring));


assert(!result.includes(unexpectedSubstring));

Jest API 所鼓励的那种断言模式似乎与 React 背后的哲学恰恰相反。

这肯定不是针对于 Jest 的抱怨(而且 Jest 也不仅仅是一个断言库)。它适用于 JavaScript 中大多数流行的测试断言库,例如 chai must.js should.js JavaScript 世界中的测试往往主要面向具有详细和复杂断言 API BDD 风格断言库。如果你喜欢这种风格,这没问题,但是当你已经知道以更简单和更简洁的方式执行相同断言的其他方法时,你需要记忆很多额外的语法。

不出所料,为什么这些断言库倾向于按照它们的方式工作有一些很好的理由。如果您只是使用 Node 内置断言模块 来进行断言,那么您的错误消息会为您提供有关正在测试内容的相当有限的上下文。无论字符串包含什么,我们之前的 “unexpected substring” 测试的原生 JavaScript 版本都将失败并显示相同的错误消息。

AssertionError [ERR_ASSERTION]: false == true

这告诉您测试失败了,但是查看 result unexpectedSubstring 变量的值是什么是有用的,这样您就可以确定测试失败的原因。

作为对比,以下是 Jest 测试失败时的输出。

expect(received).toEqual(expected)
Expected value to equal:
  StringNotContaining "World"
Received:
  "Hello World"

这立马让我们知道字符串 “Hello World” 意外地包含了子字符串 “World” 。我们当然可以使用原生方法编写您自己的更多信息性断言错误消息,但这需要为每个测试进行额外的工作。 这个子串的例子并不算太糟糕,以下内容就足够了。

assert(
  !result.includes(unexpectedSubstring),
  `"${result}" unexpectedly included "${unexpectedSubstring}".`,
);

但是,这种方法无法很好地扩展到更复杂的测试,尤其是在处理数组和嵌套对象时。使用 Jest ,您可以获得开箱即用的有用且信息丰富的错误消息。

很长一段时间,我认为你必须在使用详细的断言 API 或编写自己的断言消息之间做出选择。嗯,事实证明并非如此!有一个名为 Power Assert 的库,它可以使用标准的 assert 模块,同时仍然可以获得信息量大的错误消息。这是两全其美的。

Power Assert 背后的基本前提是,不需要使用复杂的 API 来提供关于正在测试内容的断言库上下文;可以从代码本身推断出该信息。这当然意味着 Power Assert 需要能够访问代码本身,但是无论好坏,无论如何, JavaScript 往往会在代码运行之前通过一两次转换。使用像 Babel WebPack Gulp 这样的工具是常态而不是例外, Power Assert 为每个工具提供了插件,以提供更多信息性的断言消息。

回到之前的 “unexpected substring” Power Assert 将转换断言,以便产生如下错误消息:

assert(!result.includes(unexpectedSubstring))
        ||      |        |
        ||      true     "World"
        |"Hello World"
        false
     + expected - actual
     -false
     +true

此消息允许我们在评估期间立即查看 result unexpectedSubstring 的值以及每个中间值。这里运行的代码与我们之前运行的代码完全相同。唯一改变的是我在第二次运行测试之前启用了 Babel Power Assert preset 。如果您已经在使用 Node 断言模块,那么这就是开始获取更多有用的错误消息所需的全部内容。您不需要学习任何新的断言方法,只需使用现有的 assert API 即可。

事实上, Power Assert 的口号是 “没有API就是最好的API。” 。这是一个良好的情绪,真正引起我作为开发人员的共鸣。与早期的 Web 框架相比,这是关于 React 我最喜欢的一部分,它也是我们的浏览器自动化框架 Remote Browser 背后的主要思想之一。我们开发了一个尽可能少的 API 的库,同时使开发人员可以非常轻松地使用原生 JavaScript HTML浏览器上下文 Web Extensions API 来完成复杂的测试和 Web 抓取任务(您可以查看 Remote Browser互动之旅 ,看看我所说的)。我们没有提出一个时髦的口号,但我觉得它几乎同样适用于 Remote Browser Power Assert

这种共鸣激发了我与 Power Assert 库的主要作者 Takuto Wada 的联系。长话短说,我们最后对 Power Assert 这个项目进行了简短的采访。我们将在下一节中看看他将会说些什么,然后我们将有一个如何将 Power Assert 集成到 JavaScript 项目中的快速教程。一旦将它添加到示例项目中,我们将构建一些测试作为示例,以查看可以使用 Power Assert 生成断言的错误消息类型。

Takuto Wada 的迷你访谈

这些问题和回答来自 Takuto Wada 和我交换过的一系列电子邮件。 Takuto Wada - 除了作为 Power Assert 的主要作者 - 在编程方面拥有超过20年的经验,在日本被称为 “TDD传播者” ,并将 Kent Beck 测试驱动开发 翻译成日语。让我们看看他对其他测试框架, Power Assert 的起源以及项目背后的理念有何看法。

  1. 其他语言中也有一些类似的框架也使用 “Power Assert” 名称,但根据提交历史记录看起来,您的版本最先出现。是真的吗?如果是这样,你是如何决定用 “Power Assert” 这个词来表达,你最初是如何得到 Power Assert 的这个想法的?
不是我最先使用的。 “Power Assert” 的起源是用groovy编写的 Spock框架 。当我第一次看到groovy的 Power Assert 时,我非常惊讶深深被吸引。
2013年1月8日,我决定使用 Mozilla SpiderMonkey Parser API (作为 ESTree Spec 的前身)将 power-assert 实现为 POC


2. 自从你第一次开始研究 Power Assert 以来,主要断言库的流行度已经发生了一些变化。一个值得注意的转变是, Jest 已经从最初的不起眼变得像 Chai 一样受欢迎。你认为 Jest 是否提供了比 Chai 更好的断言接口,还是它们基本是相同的?

基本是相同的。 Jest Jasmine 的一个分支,是一种测试框架的 “BDD风格” 方言。 Jest chai 更谦虚,但它们基本相同,大量的匹配器,大量的 API 需要学习。
Kent Beck 曾经说过,“约束之间存在冲突。易于编写与易于学习编写。“ Chai Jest 及其祖先 RSpec 旨在”易于编写“。 Power-assert 的目标是“易于学习编写”=简单。


3. 您认为是否有任何实用程序库与 Power Assert 结合得特别好?信息性错误消息是等式的一半,但是一些断言可能需要从头开始构造一些样板。像 Lodash 这样的库提供了广泛的方法,可以促进与对象和数组的复杂交互,并且可以用来以更简洁的方式编写一些测试。你会认为 Lodash Power Assert 的一个很好的补充,还是只是将一个 API 的复杂性换成另一个?

简单地返回布尔值的函数或方法(例如 lodash )能够很好的与 power-assert 一起使用。由于测试失败时信息量较少,因此往往可以避免使用布尔函数,但是通过 power-assert ,您可以获得信息性的失败消息。简单胜利。


4. Power Assert 背后的理念似乎非常简约,与 Keep It Simple Stupid(KISS) 一致。 我很好奇这是否是一种延续到你的其他开发工具的偏好。 您的开发硬件/软件配置是什么样的,您是否认为它面向极简主义工具?

我20年来一直是 Unix 公民,所以我的开发堆栈包含小巧漂亮的 FLOSS 工具。
  • Macbook Pro
  • 主要是Emacs,有时是WebStorm,Atom,VSC。
  • zsh / nodebrew / ghq / peco
正如您所猜测的,我的产品背后的哲学是 KISS 原则。 简单至关重要。 我的编程风格受到 Unix 哲学以及创作 Clojure 语言的 Rich Hickey 的哲学的强烈影响。

5. 您是业余时间贡献 Power Assert ,还是工作时间?

业余时间。

6. 接下来我要解决我的困惑,我猜测你认为 BDD 断言风格中的可链接语义 API 没必要那么复杂。

是的。

如果是,那么你对一般的可链接语义 API 你有同感吗?还是有些情况下它是一个不错的选择?

我看到 BDD 断言样式中的许多可链接语义 API 逐渐变得复杂。它们“易于编写”但不“易于学习”。可链接的语义 API (a.k.a Fluent interface )有时效果很好,尤其是在编译器和 IDE 帮助下的静态类型语言(例如用 Java 编写的 SQL Query Builder )。

是否有一个您知道的库使用了一个可链接的 API ,您认为它可以很好地完成?

Rails 中的 “Scopes” 设计得很好并且运行良好。

7. 我写这篇文章是因为我是 Power Assert 的忠实粉丝,我认为可能有很多人会喜欢使用它,但还没有听说过它。 有哪些开源项目与您没有关联,但您认为更多人应该了解这些项目?

我想介绍 ghq peco 。 他们让我的 OSS 生活非常愉快。

8. 如果我们引用 “没有API是最好的API” 并且在谈论 Remote Browser 时使用它是否可以:-)?

当然,可以:)

使用 Mocha Babel 设置 Power Assert

首先我要说的是,有很多插件, preset ,任务以及诸如此类将 Power Assert 与各种 JavaScript 开发工具集集成的东西。我将专注于将 Babel Mocha 结合使用,因为这是我喜欢的用于自己测试的设置。如果您喜欢不同的测试设置,可以在 Power Assert主仓库 中找到与许多其他方案相关的说明。然而,我们将看到的实际错误消息和测试策略应该独立于工具,因此无论如何都可以看到 Power Assert 的运行情况。

我还要提到最终项目配置和我们将要编写的测试可以在 intoli-article-materials 仓库中找到。如果您尝试将此代码用作模板以将 Power Assert 添加到您自己的项目中,那么可以在那里查看最终的产品。记得给仓库 star 哦!我们在那里为我们的大多数文章提供了补充材料,并且 star 仓库是了解新文章和即将发表的文章的好方法。

现在,一切准备就绪,让我们开始项目。与 JavaScript 项目一样,您需要在一个干净的工作目录中创建一个 package.json 文件。将以下内容添加到 package.json 文件将指定在启用 Power Assert 的情况下运行测试所需的所有开发依赖项。

{
  "scripts": {
    "test": "NODE_ENV=testing mocha --exit --require babel-register"
  "devDependencies": {
    "babel": "^6.23.0",
    "babel-core": "^6.26.3",
    "babel-preset-env": "^1.7.0",
    "babel-preset-power-assert": "^2.0.0",
    "babel-register": "^6.26.0",
    "mocha": "^5.2.0",
    "power-assert": "^1.5.0"
}

然后运行 npm install yarn install 将依赖项安装到本地 node_modules / 子目录中。以上指定的依赖项中只有两个关于 Power Assert ......就是名字含有 “power-assert” 的依赖项。一个是 power-assert 包,另一个是 babel-preset-power-assert ,这是 Babel preset ,它可以让 Power Assert Babel 一起工作。其他依赖项涵盖了 Mocha 测试框架和一组最小的 Babel 包。

package.json 文件中要注意的另一件事是我们定义了一个测试脚本命令。这不是针对 Power Assert 的,但我们在这里做了两件很重要的事情:将 NODE_ENV 环境变量设置为 test ,并在运行 Mocha 时使用 --require babel-register 命令行参数。 --require 标志只是告诉 Mocha 在运行测试之前它应该需要一个特定的包。

在这种情况下, --require 标志告诉 Mocha 在测试之前评估 require('babel-require') 。这似乎相当无害,但它对我们的测试代码的运行方式产生了深远的影响。 babel-register 包是 Babel 的一个特殊部分,在需要时,它将自己绑定到 Node require() 方法,并导致 Babel 动态地编译任何进一步需要的包。

可以通过创建 .babelrc 文件来配置编译的行为。包含 Power Assert 支持的基本 .babelrc 配置可能如下所示

{
  "env": {
    "testing": {
      "presets": [
        "power-assert"
  "presets": [["env", {
    "targets": {
      "node": "6.10"
}

实际上针对 Power Assert 的唯一部分是我们告诉 Babel NODE_ENV testing 时添加 Power Assert preset (即当我们运行我们的 test 脚本时)。 此测试配置将与默认配置 合并 。 在这种情况下,默认配置仅指定应使用 Babel env preset 来动态确定哪些插件是 Node 6.10 版本所必需的。

添加我们的 .babelrc 文件后,所有内容都应该可以使用 Babel Power Assert 工作。 现在只需创建一个名为 test 的子目录,即 Mocha 的默认搜索目录,并创建一个名为 test / test-assertion-errors.js JavaScript 文件,其中包含以下内容

import assert from 'assert';
// Note that all of these tests are designed to fail, so that we can see the error messages!
describe('Power Assert Testing Examples', () => {
  it('check that an unexpected substring is not found', () => {
    const result = 'Hello World';
    const unexpectedSubstring = 'World';
    // Jest Equivalent: expect(result).toEqual(expect.not.stringContaining(unexpectedSubstring));
    assert(!result.includes(unexpectedSubstring));

我们可以在这里使用 ECMAScript 2015 import 语法 ,因为 Babel 正在编译我们的测试。 您添加到 .babelrc 文件的任何其他配置也将适用于您的测试,因此如果你喜欢,您可以使用有趣的东西,如 transform-object-rest-spread transform-optional-changing 。 然而,唯一对 Power Assert 真正重要的部分是我们将 Power Assert preset 指定为配置的一部分。

您现在可以使用 yarn test npm run test 运行测试,您将看到我们在简介中查看的相同信息性错误消息。

assert(!result.includes(unexpectedSubstring))
        ||      |        |
        ||      true     "World"
        |"Hello World"
        false
     + expected - actual
     -false
     +true

使用 assert 模块编写的任何其他测试也将包含类似有用的错误消息。 我们将在下一节中看一些实际的例子!

运行中的 Power Assert

现在我们已经完成了所有配置,现在让我们快速浏览几个常见的实际测试模式以及 Power Assert 在其中生成的断言消息。 我们将使用原生 JavaScript Node assert 模块编写每个断言,但我们还将包括已注释掉的 Jest 断言以进行比较。 请注意,为简洁起见,仅显示各个测试定义。 每个测试都要放在 test / test-assertion-errors.js 中的 describe() 块中,因此如果你想自己运行测试(使用 yarn test ),你可以将它们复制并粘贴到那里。

例1:检查另一个数组中没有包含当前数组成员

在第一个测试中,我们将有一个名为 result 的数组,我们需要检查它是否包含第二个名为 unexpectedMembers 的数组中的任何元素。有几种不同的逻辑等效方法可以为此测试编写断言。你可以循环遍历 result 并断言每个成员都不是 unexpectedMembers 的一部分,你可以循环遍历 unexpectedMembers 并断言每个成员不是 result 的一部分,你可以计算被视为集合的两个数组之间的交集并断言它是空集,等等。我个人认为这些方法中的每一种都意味着略有不同的意图,并且在任何给定情况下最合适的选择取决于测试的基本背景和目的。

当我写这个测试时,我认为 result 是一个有意义的单词序列,而 unexpectedMembers 则是一组无关的单词集合。鉴于上下文,将整个断言分解为一系列单独的断言是有意义的,其中按顺序检查 result 中每个 unexpected member 。我们可以使用 Array.forEach() 循环遍历 unexpectedMembers ,然后使用 Array.include() 来检查 result 是否包含其中一个 unexpected member

it('check that no members of an array are included in another array', () => {
  const result = ['Hello', 'World'];
  const unexpectedMembers = ['Evan', 'World'];
  // Jest Equivalent: expect(result).toEqual(expect.not.arrayContaining(unexpectedMembers));
  unexpectedMembers.forEach(member =>
    assert(!result.includes(member))

运行此测试将失败并生成以下错误消息

assert(!result.includes(member))
       ||      |        |
       ||      true     "World"
       |["Hello","World"]
       false
    + expected - actual
    -false
    +true

此消息向我们显示 result 的完整值,即导致测试失败的 unexpectedMembers 的某个成员, result.includes(member) 求值为 true 的事实,以及我们明确地将该值否定为 false

检查正则表达式是否与字符串匹配

这个很简单:我们有一个名为 result 的字符串,我们想检查一个名为 regex 的正则表达式是否匹配它。 JavaScript 对正则表达式有很大的支持,我们可以使用 RegExp.test() 来确定正则表达式是否与 result 字符串匹配。

it('check that a regular expression matches a string', () => {
  const regex = /^Hello World!/;
  const result = 'Hello World';
  // Jest Equivalent: expect(result).toEqual(expect.stringMatching(regex));
  assert(regex.test(result));

由于正则表达式中存在感叹号,此测试将失败,并且生成的错误消息将如下所示

assert(regex.test(result))
       |     |    |
       |     |    "Hello World"
       |     false
       /^Hello World!/
    + expected - actual
    -false
    +true

我们可以很容易地从中看到正则表达式的值是什么, result 字符串的值,以及正则表达式检查失败。

检查数组是否包含至少一个数字

在此示例中,我们将要检查名为 result 的数组是否包含至少一个数字。 Array.some() 方法允许我们检查函数是否对数组中的任何元素求值为 true 。 我们可以将它与箭头函数结合使用,该函数检查每个成员的类型以构建此测试。

it('check that an array contains at least one number', () => {
  const result = ['Hello', 'World'];
  // Jest Equivalent: expect(result).toContainEqual(expect.any(Number));
  assert(result.some(member => typeof member === 'number'));

当然,这个测试也会失败,它的输出看起来像这样

assert(result.some(member => typeof member === 'number'))
       |      |
       |      false
       ["Hello","World"]
    + expected - actual
    -false
    +true

这让我们清楚地看到我们如何定义检查每个成员的箭头函数, result 数组的值,以及箭头函数对所有成员求值为 false 的事实。

检查两个对象之间的深度相等

到目前为止,我们一直在进行空 assert() 调用,但我应该指出, assert 模块确实有一个超出此范围的 API 。这包括用于检查深度相等性的 assert.deepStrictEqual() / assert.notDeepStrictEqual() 方法,用于检查是否抛出错误的 assert.throws() ,以及用于检查 promise 被拒绝的 assert.rejects() 。由于适用于第三方断言库,当使用 Power Assert 时,大部分 API 都变得多余。例如,使用 Power Assert assert(a === b) 进行 assert.strictEqual(a, b) 调用几乎没有什么好处,类似的语句适用于其余的大部分 API

我认为深度相等比较是一个值得注意的例外。虽然很有可能使用递归手动检查,但这是我认为这样编码没有意义的一种情况。到目前为止我们看过的其他示例实际上并没有必要牺牲代码的简洁性或意图的清晰度来使用空 assert() 调用,但是我们需要牺牲这两个来手动检查深度相等。所以我们将使用 assert.deepStrictEqual() 代替,并且断言的错误消息仍然会被 Power Assert 转换。

it('check for deep equality between two objects', () => {
    const expectedResult = { 'a': [1, 2], 'b': [1, 2] }
    const result = { 'a': [1, 2], 'b': [1, 2, 3] }
    // Jest Equivalent: expect(result).toEqual(expectedResult);
    assert.deepStrictEqual(result, expectedResult);

运行此测试将输出以下内容

assert.deepStrictEqual(result, expectedResult)
                       |       |
                       |       Object{a:#Array#,b:#Array#}
                       Object{a:#Array#,b:#Array#}
    + expected - actual
       "b": [
    -    3
     }

这告诉我们 result expectedResult 都是包含名为 a b 属性的数组对象。 我们还可以知道 result.b 数组包含第三个元素,其值为3,不应该出现。

这里的错误消息非常有用,但值得注意的是,您可以自定义 Power Assert 的行为以显示每个数组的实际值,而不是 #Array# 。 有一个 maxDepth 选项,用于确定在用通用标识符替换对象之前可以有多少级别的嵌套。 自定义行为需要在我们的测试中添加一些特定于 Power-Assert 的代码,但是当 Power Assert 没有像这样初始化时,我们可以优雅地回退到标准的 assert 模块。

import uncustomizedAssert from 'assert';
const assert = !uncustomizedAssert.customize ? uncustomizedAssert : (
  uncustomizedAssert.customize({
    output: {
        maxDepth: 5,

这种自定义允许我们查看每个对象内部数组的实际值。

assert.deepEqual(result, expectedResult)
                 |       |
                 |       Object{a:[1,2],b:[1,2]}
                 Object{a:[1,2],b:[1,2,3]}
    + expected - actual