Backbone-js-测试指南-全-

Backbone.js 测试指南(全)

原文:zh.annas-archive.org/md5/425c1105a3d3b7671db0e1d91de4ced0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

JavaScript 网络应用程序的受欢迎程度正在飙升,并在互联网上推动着令人兴奋的新应用程序可能性。引领这一潮流的最普遍的框架之一是 Backbone.js,它为组织 JavaScript 应用程序提供了一种现代和理性的方法。

同时,测试客户端 JavaScript 和 Backbone.js 应用程序仍然是一项困难且繁琐的任务。即使是经验丰富的开发者,在编写前端测试时也可能遇到与浏览器特性、复杂的 DOM 交互和异步应用程序行为相关的问题。

Backbone.js 测试 将合理的实践和当前技术带到了 Backbone.js 测试开发的挑战中。你将了解基本的测试概念、当代前端测试基础设施以及 Backbone.js 应用程序开发各个方面的实际练习。本书涵盖了从创建基本测试套件到使用测试双库来解决甚至最困难/最不易测试的 Backbone.js 应用程序组件等主题。

在本书的一些建议指导下,你可以轻松、快速、有信心地测试你的 Backbone.js 网络应用程序。

本书涵盖的内容

第一章, 设置测试基础设施,从如何设置你的测试应用程序代码以及获取我们将在这本书中使用的测试库的基本知识开始。我们创建了一个基本的测试基础设施,编写了第一个测试,并回顾了测试报告结果。

第二章, 创建 Backbone.js 应用程序测试计划,从 Backbone.js 基础知识的复习开始,介绍了一个本书的示例网络应用程序,并讨论了广泛的与测试和规划相关的概念。我们通过编写和运行我们的第一个 Backbone.js 应用程序测试来结束。

第三章, 测试断言、规范和套件,涵盖了使用 Mocha 编写 Backbone.js 测试套件和规范以及使用 Chai 进行测试断言的基本知识。

第四章, 测试间谍,介绍了 Sinon.JS 测试双库以及如何在 Backbone.js 测试中监视应用方法行为。

第五章, 测试存根和模拟,深入探讨了 Sinon.JS,其中包括可以替换应用程序方法行为的存根和模拟。我们检查了存根和模拟如何减少测试中的应用程序依赖,并简化了 Backbone.js 应用程序测试。

第六章, 自动化网络测试,增强了前几章中构建的测试基础设施,使其能够自动运行,例如,从命令行或持续集成服务器。

本书面向的对象

本书面向希望为 Backbone.js 网络应用创建和实现测试支持的 JavaScript 开发者。你应该熟悉 JavaScript 编程语言,并了解 Backbone.js 应用开发,包括模型、视图和路由等核心组件,尽管你可能在学习本书的测试主题时刚开始学习这个框架。对测试方法和技术的了解(任何语言)将有所帮助,但不是必需的。

习惯用法

在本书中,你会找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将按照以下方式显示:“我们使用原生的 JavaScript 函数setTimeout()来模拟慢速测试。”

代码块按照以下方式设置:

describe("Test failures", function () {
  it("should fail on assertion", function () {
    expect("hi").to.equal("goodbye");
  });
});

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

describe("Test failures", function () {
  it("should fail on assertion", function () {
    expect("hi").to.equal("goodbye");
  });
});

任何命令行输入或输出都按照以下方式编写:

$ mocha-phantomjs chapters/05/test/test.html

新术语重要词汇将以粗体显示。屏幕上显示的词,例如在菜单或对话框中,在文本中将像这样显示:“点击下一步按钮将您带到下一屏幕”。

注意

警告或重要注意事项将显示在这个框中。

小贴士

小技巧和技巧将像这样显示。

读者反馈

我们欢迎读者的反馈。请告诉我们您对本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大价值的标题非常重要。

要向我们发送一般反馈,只需发送电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些可以帮助您充分利用购买的东西。

下载示例代码

本书所有示例和文件的源代码都可在 GitHub 仓库(github.com/ryan-roemer/backbone-testing/)中找到,并在backbone-testing.com网站上详细介绍。该backbone-testing.com网站将始终包含获取和使用本书代码示例的最新和更新说明。

注意

代码示例库内部使用符号链接来引用某些库和文件。因此,Windows 用户可能需要从 Packt 下载示例存档(请参阅以下说明),而不是从 GitHub 下载。

由于这是一个开源项目,示例可能会定期更新以修复错误或澄清代码或概念。因此,书中的代码片段可能不会与在线代码样本完全匹配,但在实践中不应有太大差异。最终,您可以依赖 GitHub 仓库作为本书中代码的最正确版本。

由于 Chai 断言库的限制,运行示例的最小浏览器要求如下:

  • Chrome: 7+

  • Safari: 5+

  • Firefox: 4+

  • Internet Explorer: 9+

本书使用的供应商库版本包括以下内容:

  • Backbone.js: 1.0.0

  • Underscore.js: 1.4.4

  • jQuery: 2.0.2

  • Mocha: 1.9.0

  • Chai: 1.7.1

  • Sinon.JS: 1.7.3

GitHub 仓库将努力跟上这些库随时间演变的步伐。同时,书中大多数的应用和测试样本在可预见的未来应该仍然可以很好地与更新的库一起工作,除非本书或网站上特别指出。

每章的文件和代码都通过 chapters/NUMBER 目录结构提供,其中 NUMBER 是章节编号。示例 Backbone.js 网络应用——Notes——在 notes 目录中以 localStorage 版本提供,并在 notes-rest 中作为完整的 MongoDB 支持的 Node.js 服务器提供。

要检索示例代码,您可以从以下位置下载整个压缩存档:github.com/ryan-roemer/backbone-testing/archive/master.zip。另一个选项是使用 git 直接检出源代码:

$ git clone https://github.com/ryan-roemer/backbone-testing.git

最后,您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站,或添加到该标题的错误清单中。任何现有的错误都可以通过从www.packtpub.com/support选择您的标题来查看。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者以及为我们提供有价值内容的能力方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。

第一章. 设置测试基础设施

现代网络开发正在见证 JavaScript 的复兴,前端驱动、单页和实时网络应用程序的普及不断扩大。引领和推动这一潮流的是一些 JavaScript 网络框架,这些框架使开发者能够合理地将前端网络应用程序组织成模块化和约定驱动的组件。随着越来越多的逻辑和功能从服务器推送到浏览器,这些框架在维护单页应用程序状态、避免无结构和临时“意大利面”代码以及为常见开发情况提供抽象和功能方面变得越来越关键。

本书将重点关注这样一个框架——Backbone.js (backbonejs.org/)——它以其平衡的功能集脱颖而出,包括小巧的体积、坚实的核心抽象和显著的社区支持。Backbone.js 为应用程序开发提供了一组最小化的有用接口(例如,模型、集合、路由器和视图),同时通过可插拔的模板引擎、可扩展的事件用于跨组件通信以及通常对代码交互和模式采取无偏见的方法,保持了巨大的灵活性。该框架在 USA Today、LinkedIn、Hulu、Foursquare、Disqus 和许多其他组织的应用程序中得到了大规模的应用。本质上,Backbone.js 为数据驱动、客户端密集型网络应用程序开发提供了实用的工具,而不会过多地阻碍开发。

然而,这个不断发展的前端开发世界充满了许多潜在的障碍。更具体地说,虽然使用现代 JavaScript 框架(如 Backbone.js)的理论应用可能性是无限的,但在这个领域快速应用程序开发中悬而未决的最关键问题之一是软件质量和可靠性。

JavaScript 网络应用程序已经臭名昭著地难以验证和测试:异步 DOM 事件和数据请求容易受到时间问题和不真实失败的影响,显示行为难以从应用程序逻辑中隔离,测试套件依赖于/与特定浏览器交互。像 Backbone.js 这样的前端框架通过需要隔离和测试的额外接口、大量并发交互的小组件以及在整个应用程序层中传播的事件逻辑,增加了另一个复杂层次。此外,Backbone.js 的无实现范式产生了广泛不同的应用程序代码库,使得测试指南和启发式方法成为一种移动的目标。

在本书中,我们将通过识别应用程序要测试的部分、断言各种组件的正确行为以及验证程序作为一个整体按预期工作来应对测试 Backbone.js 应用程序的挑战。在本章中,我们将介绍以下基本测试基础设施:

  • 设计用于开发 Backbone.js 应用程序和测试的仓库结构

  • 获取 Mocha、Chai 和 Sinon.JS 测试库

  • 设置并编写我们的第一个测试

  • 使用 Mocha 测试报告运行和评估测试结果

我们假设读者已经熟悉 JavaScript Web 应用程序开发,并且熟悉 Backbone.js 及其常用补充——Underscore.js (underscorejs.org/) 和 jQuery (jquery.com/)。所有其他库和技术将在本书中使用时适当介绍。

注意

虽然这本书主要关注 Backbone.js 应用程序,但我们介绍的测试技术和工具应该很容易迁移到其他前端 JavaScript 框架和 Web 应用程序。前端生态系统中有许多优秀的框架——尝试其中之一吧!

设计应用程序和测试仓库结构

首先设置测试基础设施需要有一个计划,确定所有部分和组件将放在哪里。我们将从一个简单的代码仓库目录结构开始,如下所示:

app/
  index.html
  css/
  js/
    app/
    lib/

test/
  test.html
  js/
    lib/
    spec/

app/index.html 文件包含 Web 应用程序,而 test/test.html 提供测试驱动页面。应用程序和测试库分别包含在 app/js/test/js/ 目录中。

注意

这只是组织 Backbone.js 应用程序和测试的一种方式。其他目录布局可能更合适,你应该根据手头的具体开发项目自由地遵循自己的约定和偏好。

Backbone.js 应用程序和组件文件(模型、视图、路由器等)放在 app/js/app/ 中,可能看起来如下所示:

app/js/app/
  app.js
  models/
    model-a.js
    ...
  views/
    view-a.js
    ...
  ...

核心应用程序库存储在 app/js/lib/ 中,应包括驱动实际应用程序所需的库:

app/js/lib/
  backbone.js
  jquery.js
  underscore.js
  ...

测试库和套件有一个单独的目录 test/js/,这可以将测试代码与应用程序隔离开来,以避免意外地将应用程序依赖引入测试函数或库:

test/js/
  lib/
    mocha.js
    mocha.css
    chai.js
    sinon.js
  spec/
    first.spec.js
    second.spec.js
    ...

现在我们已经有一个抽象的应用程序和测试布局,我们需要填充所有这些部分,并在目录中填充库、网页和测试文件。

获取测试库

前端 JavaScript 测试框架生态系统相当丰富,其中包含支持不同范式、功能和特性的库。从这些工具中选择是一个困难的任务,没有明确的“正确”答案。在本书中,我们选择了三个互补的库,MochaChaiSinon.JS,它们提供了一组特别适合测试 Backbone.js 应用程序的功能。除了这些库之外,我们还将使用 PhantomJS 无头 Web 浏览器来自动化我们的测试基础设施,并从命令行运行测试。

注意

使用 Mocha、Chai 和 Sinon.JS 进行服务器端 JavaScript 测试

除了浏览器之外,JavaScript 通过流行的 Node.js 框架在服务器技术领域实现了飞速发展,取代了传统的服务器端语言,并为开发者提供了一个单一语言的 Web 应用程序堆栈。尽管我们将在本书中仅讨论前端测试,但我们使用的三个核心测试库都可作为 Node.js 服务器端测试模块使用。在集成和使用方面存在一些非平凡的差异(例如,Mocha 报告是从命令行而不是浏览器中运行的),但本书中将要涵盖的许多通用测试和应用设计概念同样适用于 Node.js 服务器应用程序,并且你可以方便地在前后端开发中使用完全相同的测试库。

按照之前讨论的仓库结构,我们将下载每个测试库文件到 test/js/lib/ 目录。之后,我们将准备好针对这些库编写和运行测试网页。请注意,尽管我们在本书中选择了特定的库版本以与可下载的示例代码相对应,但我们通常推荐使用这些库的最新版本。

Mocha

Mocha (visionmedia.github.io/mocha/) 框架支持测试套件、规范和多种测试范式。Mocha 提供的一些实用功能包括前端和后端集成、灵活的超时设置、慢速测试识别以及多种不同的测试报告器。

要在浏览器中运行 Mocha 测试,我们只需要两个文件——mocha.jsmocha.css。对于 1.9.0 版本,这两个文件都可以从 GitHub 的以下位置获取:

注意

当本书付印时,Mocha 的最新版本(1.10.0 及以上)引入了与本书后面将要使用的 Mocha-PhantomJS 自动化工具的不兼容性。您可以关注 Mocha(github.com/visionmedia/mocha/issues/770)和 Mocha-PhantomJS(github.com/metaskills/mocha-phantomjs/issues/58)的问题跟踪,以获取状态更新和可能的未来修复。

JavaScript (mocha.js) 文件包含库代码,CSS (mocha.css) 文件为 HTML 报告页面提供样式。有了这些文件,我们可以将测试组织成套件和规范,运行测试,并获得可用的测试结果报告。

注意

为什么选择 Mocha?

Mocha 只是众多优秀测试库集合中的一个框架。Mocha 框架的一些优势包括强大的异步测试支持、服务器端兼容性、可选的测试接口和灵活的可配置性。但是,我们同样可以轻松选择其他测试库。

作为另一个替代框架的例子,来自 Pivotal Labs 的Jasmine(pivotal.github.io/jasmine/)是一个极为流行的 JavaScript 测试框架。它提供了测试套件和规范支持、内置的断言库以及许多其他功能(包括测试间谍)——它本质上是一个一站式框架。相比之下,Mocha 非常灵活,但您需要添加额外的组件。例如,我们在本书的测试基础设施中利用 Chai 进行断言,以及 Sinon.JS 进行模拟和存根。

Chai

Chai (chaijs.com/) 是一个提供广泛 API、支持行为驱动开发BDD)和测试驱动开发TDD)测试风格的断言库,以及不断增长的插件生态系统。BDD 和 TDD 将在第二章创建 Backbone.js 应用程序测试计划中详细介绍。特别是,我们将使用 Chai 的可链式测试函数编写与自然语言非常接近的断言,使测试在最大程度上易于理解,同时最小化对解释性代码注释的需求。

对于集成,我们需要下载单个库文件——chai.js。我们想要的版本(1.7.1)可在raw.github.com/chaijs/chai/1.7.1/chai.js找到。

或者,当前稳定的 Chai 版本可在chaijs.com/chai.js找到。

Sinon.JS

Sinon.JS 库 (sinonjs.org/) 提供了一套强大的测试间谍、存根和模拟工具。间谍是分析并存储关于底层函数信息的函数,可用于验证被测试函数的历史行为。存根是可以用更适合测试的不同行为替换函数的间谍。模拟监视和存根函数,并在测试执行期间验证是否发生了某些行为。我们将在本书的其余部分更详细地解释这些工具。

在实践中,Backbone.js 应用程序由许多不同且不断交互的部分组成,这使得我们测试隔离程序组件的目标变得困难。像 Sinon.JS 这样的模拟库将允许我们分离可测试的应用程序行为,并一次专注于一件事情(例如,一个单独的视图或模型)。

与 Chai 一样,我们只需要一个 JavaScript 文件就可以在我们的测试中使用 Sinon.JS。版本化的发布版本——我们将使用 1.7.3 版本——可以在以下任一位置找到:

安装 Sinon.JS,以及 Mocha 和 Chai,完成了我们测试基础设施创建的获取阶段。

设置和编写我们的第一个测试

现在我们有了基础测试库,我们可以创建一个包含应用程序和测试库的测试驱动网页,设置并执行测试,并显示测试报告。

小贴士

下载示例代码

本书中所有代码片段和代码示例的源代码均可在网上找到。每个章节的文件和测试可以在 chapters 目录中按编号找到。请参阅 前言 了解下载位置和安装说明。

最好在完成一个章节并已将课程和练习应用到自己的代码和应用程序之后,将示例用作对自己进度的一个有益检查。作为温和的告诫,我们鼓励您抵制复制和粘贴示例中的代码或文件。自己编写和修改代码的经验将使您更好地内化和理解成为熟练的 Backbone.js 测试员所需的测试概念。

测试驱动页面

通常使用单个网页来包含测试和应用程序代码,并驱动所有前端测试。因此,我们可以在我们的存储库的 chapters/01/test 目录中创建一个名为 test.html 的网页,从一点 HTML 模板——标题和 meta 属性——开始:

<html>
  <head>
    <title>Backbone.js Tests</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">

然后,我们包括 Mocha 样式表以用于测试报告,以及 Mocha、Chai 和 Sinon.JS JavaScript 库:

    <link rel="stylesheet" href="js/lib/mocha.css" />
    <script src="img/mocha.js"></script>
    <script src="img/chai.js"></script >
    <script src="img/sinon.js"></script>

接下来,我们准备 Mocha 和 Chai。Chai 被配置为全局导出expect断言函数。Mocha 被设置为使用bdd测试接口,并在window.onload事件上启动测试:

    <script>
      // Setup.
      var expect = chai.expect;
      mocha.setup("bdd");

      // Run tests on window load event.
      window.onload = function () {
        mocha.run();
      };
    </script>

在配置完库之后,我们添加测试规范。在这里,我们包括一个单独的测试文件(我们将在稍后创建),用于初始测试运行:

    <script src="img/hello.spec.js"></script>
  </head>

最后,我们添加一个 Mocha 使用的div元素来生成完整的 HTML 测试报告。请注意,一个常见的替代做法是将所有script包含语句放在关闭body标签之前,而不是在head标签内:

  <body>
    <div id="mocha"></div>
  </body>
</html>

有了这些,我们就准备好创建一些测试了。现在,你甚至可以在浏览器中打开chapters/01/test/test.html来查看空测试套件下的测试报告看起来是什么样子。

添加一些测试

虽然测试设计和实现将在后续章节中详细讨论,但可以说测试开发通常涉及编写 JavaScript 测试文件,每个文件都包含一些有组织的测试函数集合。让我们从一个单独的测试文件开始,以预览测试技术栈并运行一些测试。

测试文件chapters/01/test/js/spec/hello.spec.js创建了一个简单的函数(hello())来测试并实现一个嵌套的套件集,引入了一些 Chai 和 Sinon.JS 功能。被测试的函数尽可能简单:

window.hello = function () {
  return "Hello World";
};

hello函数应该包含在其自己的库文件(可能是hello.js)中,以便在应用程序和测试中使用。代码示例只是为了方便将其包含在规范文件中。

测试代码使用嵌套的 Mocha describe语句来创建测试套件层次结构。Chai套件中的测试使用expect来展示一个简单的断言。Sinon.JS套件的单个测试展示了测试间谍的作用:

describe("Trying out the test libraries", function () {
  describe("Chai", function () {
    it("should be equal using 'expect'", function () {
      expect(hello()).to.equal("Hello World");
    });
  });

  describe("Sinon.JS", function () {
    it("should report spy called", function () {
      var helloSpy = sinon.spy(window, 'hello');

      expect(helloSpy.called).to.be.false;
      hello();
      expect(helloSpy.called).to.be.true;
      hello.restore();
    });
  });
});

如果你现在不完全理解这些测试和断言的细节,不要担心,我们很快会详细覆盖所有内容。重要的是,我们现在有一组准备运行的测试套件和规范。

运行和评估测试结果

现在所有必要的组件都已就绪,是时候运行测试并审查测试报告了。

第一份测试报告

在任何网络浏览器中打开chapters/01/test/test.html文件将导致 Mocha 运行所有包含的测试并生成测试报告:

第一份测试报告

测试报告

该报告提供了测试运行的 useful 摘要。右上角列显示有两个测试通过,没有失败,测试总共运行了 0.01 秒。在describe语句中声明的测试套件作为嵌套标题出现。每个测试规范旁边都有一个绿色的勾号,表示测试已通过。

测试报告操作

报告页面还提供了分析整个测试集合子集的工具。点击套件标题,例如 尝试测试库Chai,将仅重新运行该标题下的规范。

点击规范文本(例如,使用 'expect' 应该相等)将显示测试的 JavaScript 代码。一个由右三角形指定的筛选按钮位于规范文本的右侧(它有些难以看到)。点击按钮将重新运行单个测试规范。

测试报告操作

测试规范代码和筛选器

上一图展示了点击了筛选按钮的报告。图中的测试规范文本也已点击,显示了 JavaScript 规范代码。

小贴士

高级测试套件和规范筛选

报告套件和规范筛选器依赖于 Mocha 的 grep 功能,该功能在测试网页中作为 URL 参数公开。假设报告网页 URL 以 chapters/01/test/test.html 结尾,我们可以手动添加一个 grep 筛选参数,并附带文本以匹配套件或规范名称。

例如,如果我们想根据术语 spy 进行筛选,我们会在浏览器中导航到一个包含 chapters/01/test/test.html?grep=spy 的类似 URL,这将导致 Mocha 只运行 Sinon.JS 套件中的 should report spy called 规范。尝试使用各种 grep 值以熟悉仅匹配所需的套件或规范是有益的。

测试时间和慢速测试

到目前为止,我们所有的测试都成功了,并且运行得很快,但现实世界的开发必然涉及在创建健壮的 Web 应用程序过程中的一定程度的失败和低效。为此,Mocha 报告器有助于识别慢速测试和分析失败。

小贴士

为什么测试速度很重要?

慢速测试可能表明应用程序代码低效甚至错误,这应该被修复以加快整个 Web 应用程序的速度。此外,如果大量测试运行得太慢,开发者将有隐性的激励在开发中跳过测试,导致在部署管道后期发现缺陷的成本高昂。

因此,定期诊断并加快整个测试集合的执行时间是良好的测试实践。慢速应用程序代码可能留给开发者修复,但大多数慢速测试可以通过结合使用诸如存根和模拟以及更好的测试规划和隔离等工具来迅速修复。

让我们通过创建 chapters/01/test/js/spec/timing.spec.js 并使用以下代码来探索一些实际操作中的时间变化:

describe("Test timing", function () {
  it("should be a fast test", function (done) {
    expect("hi").to.equal("hi");
    done();
  });

  it("should be a medium test", function (done) {
    setTimeout(function () {
      expect("hi").to.equal("hi");
      done();
    }, 40);
  });

  it("should be a slow test", function (done) {
    setTimeout(function () {
      expect("hi").to.equal("hi");
      done();
    }, 100);
  });

  it("should be a timeout failure", function (done) {
    setTimeout(function () {
      expect("hi").to.equal("hi");
      done();
    }, 2001);
  });
});

我们使用原生的 JavaScript setTimeout()函数来模拟慢速测试。为了使测试异步运行,我们使用done测试函数参数,它将测试完成延迟到done()被调用。异步测试将在第三章测试断言、规范和套件中更详细地探讨。

第一项测试在测试断言和done()回调之前没有延迟,第二项增加了 40 毫秒的延迟,第三项增加了 100 毫秒,最后测试增加了 2001 毫秒。这些延迟将在 Mocha 默认配置下暴露不同的定时结果,该配置报告慢速测试为 75 毫秒,中等测试为慢速阈值的二分之一,以及超过 2 秒的测试失败。

接下来,将文件包含到您的测试驱动程序页面中(例如示例代码中的chapters/01/test/test-timing.html):

    <script src="img/timing.spec.js"></script>

现在,在运行驱动程序页面后,我们得到以下报告:

测试定时和慢速测试

测试报告的定时和失败

此图展示了我们中等速度(橙色)和慢速(红色)测试的定时注释框以及 2001 毫秒测试的测试失败/堆栈跟踪。有了这些报告功能,我们可以轻松地识别测试基础设施中的慢速部分,并使用更高级的测试技术和应用程序重构来高效且正确地执行测试集合。

测试失败

测试超时是我们可能在 Mocha 中遇到的测试失败类型之一。另外两种值得快速演示的失败类型是断言失败和异常失败。让我们在一个名为chapters/01/test/js/spec/failure.spec.js的新文件中尝试这两种情况:

// Configure Mocha to continue after first error to show
// both failure examples.
mocha.bail(false);

describe("Test failures", function () {
  it("should fail on assertion", function () {
    expect("hi").to.equal("goodbye");
  });

  it("should fail on unexpected exception", function () {
    throw new Error();
  });
});

第一项测试should fail on assertion是一个 Chai 断言失败,Mocha 通过消息expected 'hi' to equal 'goodbye'整洁地封装了它。第二项测试should fail on unexpected exception抛出一个未经检查的异常,Mocha 通过完整的堆栈跟踪显示它。

注意

Chai 断言失败时的堆栈跟踪根据浏览器而异。例如,在 Chrome 中,第一次断言不显示堆栈跟踪,而在 Safari 中则显示。请参阅 Chai 文档,了解提供更多堆栈跟踪控制选项的配置选项。

测试失败

测试失败

Mocha 的失败报告清晰地说明了出了什么问题以及在哪里。最重要的是,Chai 和 Mocha 以非常易读的自然语言格式报告最常见的案例——测试断言失败。

摘要

在本章中,我们介绍了一种适合开发的程序和测试结构,收集了 Mocha、Chai 和 Sinon.JS 库,并创建了一些基本测试以开始。然后,我们回顾了 Mocha 测试报告的一些方面,并观察了各种测试的实际运行情况——通过、慢速、超时和失败。

在下一章中,我们将把 Backbone.js 应用程序集成为我们测试框架的目标,并学习如何在应用程序开发过程中测试、隔离和验证程序行为。

第二章:创建 Backbone.js 应用程序测试计划

现在我们已经建立了一个基本的测试基础设施,我们将把注意力转向集成 Backbone.js 应用程序并制定测试开发策略。在本章中,我们将通过以下主题创建测试计划:

  • 回顾 Backbone.js 开发的一些基本概念

  • 选择要测试的 Backbone.js 应用程序

  • 检查相关的测试概念和方法,以指导测试计划的创建和执行

  • 评估在完整或部分隔离状态下要测试的 Backbone.js 应用程序部分

  • 识别测试多个相互作用的 Backbone.js 应用程序部分

  • 将 Backbone.js 应用程序集成到我们的测试基础设施中,并编写和运行一些入门级应用程序测试

Backbone.js 复习

虽然本书假设读者对 Backbone.js、Underscore.js 和 jQuery 有相当熟悉的程度,但我们仍将简要介绍 Backbone.js 应用程序开发的基础知识。

Backbone.js 提供了构建和开发 JavaScript 网络应用程序的抽象和有用功能。Backbone.js 通过一种可以粗略认为是模型-视图-控制器MVC)的范式,将应用程序代码分为以下主题:

  • 数据建模和检索

  • 显示渲染和用户交互

  • 将数据和处理逻辑代理到适当绑定和操作数据模型和用户界面的方式

注意

Backbone.js 并不完全遵循传统的 MVC 方法,导致一些观察者称其为MV框架。一个 MV应用程序有一个模型和一个视图,但连接模型和视图的不是控制器。有关 MVC 和不同 MV方法的更详细讨论,请参阅 Addy Osmani 的《Developing Backbone.js Applications》以及文章《Journey Through The JavaScript MVC Jungle》(coding.smashingmagazine.com/2012/07/27/journey-through-the-javascript-mvc-jungle/)。

为了实现这一点,Backbone.js 提供了一套核心库组件:

  • 事件Backbone.Events模块使 JavaScript 对象能够发出和响应事件,包括内置的 Backbone.js 类事件以及自定义应用程序事件。

  • 模型Backbone.Model类提供了一个可以与后端同步、验证数据更改并向 Backbone.js 应用程序的其他部分发出事件的包装器。模型是 Backbone.js 应用程序中的基本数据单元。

  • 集合Backbone.Collection类将一组模型封装在一个有序列表中。集合提供事件、后端同步以及许多辅助方法,用于操作和修改底层模型集合。

  • 模板:Backbone.js 将模板库的选择权留给开发者(本书我们将使用 Underscore.js 模板)。其他流行的模板替代方案包括 Handlebars (handlebarsjs.com/)、Mustache (github.com/janl/mustache.js/) 和 EJS (embeddedjs.com/)。

  • 视图Backbone.View 对象是绑定模型、集合、模板与浏览器环境和 DOM 之间的粘合剂。Backbone.js 故意保持对视图必须做什么的不可知性,但一个典型的视图会引用一个集合或模型,通过模板将数据与用户界面耦合,并调解用户交互和后端服务器事件。为了阐明可能令人困惑的术语,Backbone.View 更类似于传统的 MVC 控制器,而 Backbone.js 模板则像 MVC 视图。

  • 路由器:Backbone.js 程序通常作为单页应用程序开发,其中整个 HTML 页面源和 JavaScript 库在单个页面加载中下载。Backbone.Router 维护应用程序的内部状态并管理浏览器历史记录。路由器通过 URL 哈希片段(#app-page)提供客户端路由,允许将不同的视图链接到、书签和导航,就像传统的网页一样。

在接下来的章节中,我们将分别和一起测试这些组件,因此确保对基础知识有扎实的掌握非常重要。核心文档位于 backbonejs.org,它是了解概念、API 以及应用程序开发提示和技巧的良好起点。对于更深入地了解 Backbone.js 主题,有许多优秀的在线和印刷资源,包括:

选择一个 Backbone.js 应用程序进行测试

设计和实施测试计划本质上是一项实践练习,通过将测试经验和技巧应用于实际应用——无论是刚刚开始的项目还是需要更好测试覆盖的现有应用——我们可以更好地实现整体应用可靠性的目标。

如果您已经在开发 Backbone.js 应用程序,您可能可以跳到本章的下一节。我们想要识别的一个潜在问题是现有应用程序的复杂性,特别是那些具有最少或没有现有测试的应用程序。复杂的依赖关系、非模块化设计和高度耦合的应用程序组件可能需要大量的模拟和存根来允许基本的测试框架集成。最终,围绕遗留应用程序编写的测试基础设施可能与围绕模块化、解耦应用程序(如 Notes)编写的测试基础设施大不相同。因此,您可能希望将我们的参考应用程序作为学习工具使用。

我们提供了一个小型参考 Backbone.js 应用程序,用于与本书一起使用,简单命名为 Notes。Notes 是一个在线笔记管理器,允许用户使用 Markdown 语言([daringfireball.net/projects/markdown/](http://daringfireball.net/projects/markdown/))创建、查看和编辑笔记。您可以在 http://backbone-testing.com/notes/app/ 尝试该应用程序的在线演示。

Notes 的完整源代码作为示例仓库的一部分提供(有关下载说明,请参阅前言)。我们实际上提供了 Notes 应用程序的两种版本,它们共享大部分底层代码。具体如下:

  • 本地应用程序:示例仓库中的 notes/ 目录包含一个由 HTML5 localStorage(developer.mozilla.org/en-US/docs/DOM/Storage#localStorage)支持的应用程序,用于在浏览器中进行持久、客户端存储。该应用程序可以从基于 file:// 的 URL 运行,而无需网络连接,并将用于本书中的大多数示例。

  • 服务器应用程序:示例仓库中的 notes-rest/ 目录包含一个由 MongoDB 数据库支持并由 Node.js 网络服务器(位于 notes-rest/server.js)提供服务的应用程序。示例仓库包含有关安装和运行后端服务器的进一步说明。

熟悉 Notes 应用程序

Notes 应用程序最初向用户展示现有笔记标题列表,并提供编辑/删除单个笔记的按钮。页面还提供了一个写新笔记输入表单用于创建笔记,以及一个简单的搜索框,用户可以通过标题过滤显示的笔记。

熟悉 Notes 应用程序

注意事项列表

点击笔记标题,例如 华盛顿特区要做的事情,将激活单个笔记视图并显示带有渲染 HTML(标题、项目符号列表等)的笔记:

熟悉笔记应用

单个笔记视图

单个笔记编辑器提供标题和 Markdown 文本数据的表单输入。对标题或文本的任何更改都将立即保存到后端数据存储中,并可供查看:

熟悉笔记应用

单个笔记编辑表单

笔记应用程序的结构

notes/app/index.html 网页中的 JavaScript script 标签说明了程序文件和应用程序的整体结构:

<!-- JavaScript Core Libraries -->
<script src="img/underscore.js"></script>
<script src="img/jquery.js"></script>
<script src="img/backbone.js"></script>
<script src="img/backbone.localStorage.js"></script>
<script src="img/bootstrap.js"></script>
<script src="img/showdown.js"></script>

<!-- JavaScript Application Libraries -->
<script src="img/namespace.js"></script>
<script src="img/config.js"></script>
<script src="img/note.js"></script>
<script src="img/notes.js"></script>
<script src="img/templates.js"></script>
<script src="img/note-nav.js"></script>
<script src="img/note-view.js"></script>
<script src="img/note.js"></script>
<script src="img/notes-item.js"></script>
<script src="img/notes-filter.js"></script>
<script src="img/notes.js"></script>
<script src="img/router.js"></script>

<!-- Bootstrap and start application. -->
<script src="img/app.js"></script>

如果长长的 JavaScript 库和应用文件列表看起来令人畏惧,请不要担心。随着我们对每个应用组件的测试,我们将逐一介绍它们。本书中的示例可以独立存在,无需查看笔记应用程序的完整源代码。同时,下载、运行和测试笔记应用程序是一项有用的练习,尤其是在你进入本书的后续章节并希望看到整个测试集合作为一个单一包组合在一起时。

在 JavaScript 库中,我们从熟悉的 Backbone.js(v1.0.0)、Underscore.js(v1.4.4)和 jQuery(v2.0.2)的核心开始。notes/app/js/lib/ 中的附加供应商库包括:

在应用结构和事件流方面,笔记的关键组件可以大致按层次结构如下呈现:

app
  App.Routers.Router
    App.Views.Notes
      App.Views.NotesFilter
      App.Views.NotesItem
      App.Collections.Notes
      App.Templates

    App.Views.Note
      App.Views.NoteNav
      App.Views.NoteView
      App.Models.Note
      App.Templates

应用程序 app 引导各种应用部分并启动路由器 App.Routers.Router。路由器将哈希片段路由到列表视图 App.Views.Notes 或单个笔记视图 App.Views.Note。这两个视图都使用来自 App.Templates 的 Underscore.js 模板字符串。列表视图 App.Views.Notes 包含两个额外的视图对象用于过滤和显示列表项,以及笔记集合。单个笔记视图 App.Views.Note 包含两个视图对象用于菜单栏导航操作和渲染标记,以及一个笔记模型。

深入应用文件,notes/app/js/app/ 目录分解为模块组,从一些辅助工具开始:

  • namespace.js: 这为我们的应用程序类(App)和实例(app)设置了一个全局命名空间。

  • config.js: 这为应用程序实例设置配置变量,我们将在一些测试中覆盖这些变量。

应用程序有一个单一的模式和集合来抽象笔记数据:

  • App.Models.Note (models/note.js): 这是一个表示笔记的模型类。

  • App.Collections.Notes (collections/notes.js): 这是一个封装App.Models.Note模型实例的集合,表示笔记列表。

所有视图模板都维护在一个文件中:

  • App.Templates (templates/templates.js): 这是一个对象字面量,包含用于渲染各种视图的 HTML 部分的 Underscore.js 模板字符串。

单个笔记页面有三个视图对象——一个包含子视图(App.Views.NoteView)的父视图(App.Views.Note),用于渲染笔记,以及一个用于与导航栏交互的辅助视图(App.Views.NoteNav)。

  • App.Views.NoteNav (views/note-nav.js): 这是一个辅助视图,用于控制笔记导航栏选项查看编辑删除,并监听/触发与其他视图交互的事件。

  • App.Views.NoteView (views/note-view.js): 这是一个子视图,用于将笔记 Markdown 数据渲染为 HTML。

  • App.Views.Note (views/note.js): 这是一个表示单个App.Models.Note模型用于查看、编辑和删除的父视图。它包含子视图App.Views.NoteView和辅助视图App.Views.NoteNav

主页上的笔记列表具有类似的视图组合。

  • App.Views.NotesFilter (views/notes-filter.js): 这是一个用于管理筛选表单输入和根据筛选查询隐藏/显示笔记的辅助视图。

  • App.Views.NotesItem (views/notes-item.js): 这是一个子视图,用于在主页上渲染单个笔记列表条目。

  • App.Views.Notes (views/notes.js): 这是一个包含App.Collections.Notes集合、App.Views.NotesFilter视图和多个App.Views.NotesItem视图实例的父视图,允许用户通过标题浏览可用的笔记,并通过点击操作按钮调用特定的笔记操作(例如编辑或删除)。

最后,我们有路由器和应用程序实例:

  • App.Routers.Router (routers/router.js): 这是应用程序路由器,它处理主页和单个笔记页面的路由。

  • app.js: 这是 Backbone.js 应用程序实例,它实例化了App.Views.NotesApp.Routers.Router实例并启动路由历史。应用程序实例在功能上类似于 C 和 Java 等语言中的main函数入口点。

这些组件构成了本书中测试示例的基础。同时,Notes 应用程序的具体代码、类和对象作为测试目标并不特殊或独特——任何具有标准模型、视图、模板和路由器的 Backbone.js 应用程序都应足够。

小贴士

许多其他 Backbone.js 应用程序作为测试和开发实践的学习工具可用,其中许多已在 Backbone.js 示例网站维基页面上进行了记录。一个特别受欢迎的项目是 TodoMVC (todomvc.com/),它使用 Backbone.js 提供了一个简单的任务管理器。TodoMVC 还提供了使用其他 JavaScript 框架(包括 AngularJS、Knockout.js、Meteor 和 Derby)的相同应用程序的示例,这使得在 Backbone.js 世界之外尝试流行的前端框架成为可能。

测试范式和方法

关于软件测试和开发方法,存在众多相互竞争和补充的理论。了解测试方法的世界为任何希望改进构建、实施和管理测试方式的开发者提供了一个极好的背景。为了简洁起见,本书中我们将仅介绍两种特别适用于 Backbone.js 测试的范式——测试驱动开发(TDD)和行为驱动开发(BDD)。

测试驱动开发是一个先编写测试然后才编写实际代码的过程。这种方法的优点包括:

  • 将测试作为开发过程中的首要任务

  • 鼓励将代码编写成小型模块单元

  • 防止对代码实现细节的了解过度影响测试

测试驱动开发(TDD)和一般的软件测试原则在许多资源中都有涉及;关于这个主题的一个推荐参考是 Steve Freeman 和 Nat Pryce 所著的 Growing Object-Oriented Software, Guided by Tests (www.amazon.com/Growing-Object-Oriented-Software-Guided-Tests/dp/0321503627)。

注意

为了提高可读性,我们通常先展示代码然后是测试。然而,这并不一定是代码和测试的开发顺序。尽管我们的示例如此,但我们强烈建议您将 TDD 实践融入您的软件开发过程中。

行为驱动开发(Behavior-Driven Development,简称 BDD)是对测试驱动开发(Test-Driven Development,简称 TDD)的一种改进。它是由Dan North(dannorth.net/introducing-bdd/)开发的,其核心是使用应用期望的行为来指定和描述测试。换句话说,BDD 测试关注的是应用应该做什么,而不是测试代码在测试什么,这导致开发者理想上更多地思考整个应用,而不是内部测试细节。要了解更多关于 BDD 的原则和应用,behaviour-driven.org/网站是一个很好的起点。

小贴士

BDD 和 TDD 作为库配置选项

BDD 和 TDD 范式在测试术语中非常普遍,以至于许多测试库都采用了 BDD 或 TDD 术语来指定 API 和配置。例如,在第三章测试断言、规范和套件中,我们将探讨 Mocha 的bddtddAPI 接口。为了避免任何混淆,最好将测试库模式视为只是配置选项,这些选项可能与我们已经讨论过的范式有或没有严格的关系。

测试概念、方法和规划

在跳入测试的深水区之前,有一个关于我们应该测试什么以及为什么的测试计划是有意义的。术语测试计划被大量使用,有众多可能的解释,因为过程、文献和实践已经普遍存在并且持续演变了几十年。现代测试计划的范围从随意的、主要是非正式的实践到正式的、100 页的文档,需要在不同阶段获得管理层批准,这并不令人惊讶。

注意

关于应用于现代 JavaScript 应用程序的测试计划实践的更详细讨论,请参阅由Yuxian Eugene Liang编写的《JavaScript 测试入门指南》(www.packtpub.com/javascript-testing-beginners-guide/book)。

由于 Backbone.js 应用程序通常是在迭代开发周期中创建的,通常没有太多的额外正式性,我们将采取相当实际的方法,创建一个测试计划,该计划仅识别测试类别并将它们应用于正在测试的应用程序。虽然实际的规划文档或维基是最佳实践,但并非绝对必要。主要观点是能够在开发过程中识别适合给定代码或功能的测试。

我们将专注于测试计划范围内的几个许多重叠的概念:

  • 单元测试:单元测试将应用程序的某些部分(单个函数、类和模块)隔离出来进行测试。一些前端单元测试的解释还进一步要求测试执行快速且没有任何 I/O(网络、磁盘等)。

  • 部分集成测试:集成测试通常涉及测试整个应用程序堆栈——前端、Web 服务器、后端数据存储以及其中的一切。在这本书中,我们不会走那么远,而是将编写结合多个应用程序部分(例如,集合和视图)的前端测试,并验证它们是否正确交互。

单元测试采取狭窄的视角,通常由开发人员用来为当前正在工作的代码片段定义一组所需的行为。然后,代码被开发以匹配单元测试断言。部分集成测试采取更高层次的视角,将应用程序拼凑在一起,并检查各个组件是否构成一个功能整体。在一个典型的 Backbone.js 应用程序中,单元测试可能会创建一个单独的模型并测试仅模型方法。相比之下,部分集成测试可能会创建一个包含多个子视图和集合的视图,并验证集合数据的变化是否修改了子视图的显示。

其他我们在这本书中不会明确介绍,但值得熟悉并整合到整体开发和测试过程中的概念包括:

  • 完全集成测试:完全集成测试包含整个应用程序(通常使用已知测试数据初始化),模拟用户通过前端的行为,并验证应用程序响应是否从后端传播到用户界面。对于一个 Backbone.js 应用程序,这包括将浏览器窗口指向应用程序的 HTML 页面,并像真实用户一样运行应用程序。

  • 回归测试:回归测试隔离并暴露应用程序中报告的缺陷。测试首先编写来验证缺陷是否重现,之后修复源代码。测试作为整体测试套件的一部分继续运行,以确保缺陷不会再次出现。

  • 可用性测试:可用性测试包括许多不同的形式,重点是获取反馈,揭示需要用户界面或用户体验改进的应用程序部分。

  • 性能/负载测试:性能测试验证应用程序在给定用例中是否保持最小响应时间。负载测试检查当程序的不同部分受到压力时,应用程序是否仍然能够满足性能目标。

  • 验收测试:验收测试构成了客户验证应用程序是否满足其要求的标准。一组验收测试可以包括之前提到的任何测试类别。

现在我们对这些各种测试概念有了简要的介绍,我们将为我们的 Backbone.js 应用程序制定一个非正式的测试计划。我们将检查我们 Backbone.js 应用的各个部分,确定需要测试的内容,我们应该应用哪种类型的测试,以及我们需要在应用中验证的行为。对于仍处于开发阶段或早期设计阶段的程序部分,我们将进行相同的练习,但重点是我们期望应用程序在开发完成后表现出的行为。

测试单个 Backbone.js 组件

Backbone.js 应用程序非常适合进行测试分离。Backbone.js 提供了一些核心组件,它们大多避免了相互依赖。本节的目标是确定可以单独进行单元测试的 Backbone.js 应用程序的不同部分,并开始思考我们应该测试每个组件的哪些特性。许多组件可以单独实例化,而其他组件在测试中可能需要一些额外的模拟或修补帮助。

模型

Backbone.js 模型通常是独立实体,可以通过简单的 new MyModel({foo: 123}) 调用进行实例化。因此,我们可以在测试中创建独立的模型对象,而不需要引用任何其他对象。我们的模型测试应包括以下断言:

  • 对象可以使用提供的和/或默认值进行实例化

  • 数据可以与后端数据存储(例如,localStorage 或 REST 服务器)同步

  • 自定义和内置事件在适当的状态变化时触发和/或被消费

  • 验证逻辑能够准确区分属性数据的正确性

集合

集合通常对模型有一个单一依赖,在类定义中声明为 model: MyModel。我们可以在测试中直接实例化集合,或者模拟 model 属性以实现进一步的测试隔离。典型的集合规范应验证以下内容:

  • 可以使用或不需要模型对象数组来创建集合对象

  • 模型对象可以被添加和移除出集合

  • 在容器和模型变化时触发事件

  • 数据与后端适当同步

模板

虽然模板不是实际的 Backbone.js 组件,但有一些传统的模板开发技术用于 Backbone.js 集成,我们将观察这些技术。模板通常没有依赖关系,可以在测试代码中单独使用。

模板测试的具体内容很大程度上取决于所使用的引擎(例如,Underscore.js 或 Handlebars)。一个合理的测试起点将确认以下内容:

  • 模板对象使用提供的数据渲染适当的 HTML 输出

  • 复杂数据结构,如数组和对象,在模板输出中被正确插值

视图

视图通常是 Backbone.js 组件中依赖性最多的。视图可以包含模型、集合、模板、路由和子/辅助视图引用的组合。因此,我们将在测试中模拟或修补依赖项以隔离视图和/或提供部分依赖项。

对于所有应用程序视图,我们希望验证:

  • 视图可以渲染目标 HTML,将模型数据绑定到模板字符串

  • 带有el属性的视图对象在创建时被添加到 DOM 中

  • 视图方法正确绑定到 DOM 和 Backbone.js 事件,并做出适当的响应

  • 视图包含的对象(例如,子视图和模型)在视图移除时被正确处理

路由器

路由器通常包含几个顶级视图,并且可能有集合或模型引用。为了单元测试的目的,我们通常会模拟依赖项以轻松测试路由行为,而无需考虑应用程序的其他部分。我们的路由器测试需要断言:

  • URL 路由与适当的视图或其他操作准确匹配

  • 路由器在导航事件后正确维护浏览器历史记录

工具

工具包括任何实际上不是核心 Backbone.js 类或对象的辅助代码。由于工具是临时创建的,并且没有实际约束,因此它们通常可以很容易地进行单元测试,前提是它们与支持测试一起开发,并考虑到支持测试。

测试应用程序交互和事件

Backbone.js 应用程序被最终用户作为一个统一的整体使用,并且尽可能的,我们应该有测试基础设施验证整体应用程序的功能和行为,这些功能和行为跨越单个 Backbone.js 组件。

部分集成

虽然单元测试是现代软件开发的基础,但我们必须从单元测试树隐喻地转移到部分集成测试的森林,以确保至少某些应用程序的部分能够和谐且可靠地协同工作。在实践中,这仅仅意味着我们在之前讨论的测试中改变模拟或移除组件依赖的程度。

集成测试可以通过许多方式与应用程序部分交互,包括:

  • 通过创建一个包含集合和子视图的父视图,调用 DOM 事件,并检查集合数据和子视图显示的适当更改

  • 通过在 Backbone.js 视图中填写和提交表单输入

  • 通过直接将模型添加到集合中并在监听视图中触发事件

事件

所有 Backbone.js 类都扩展了Backbone.Events基类,并且通常将事件作为第一级通信手段发出和消费。我们希望测试我们的应用程序组件在应用程序执行期间正确触发并对各种预期事件做出反应。我们通常会利用工具,如间谍、存根和模拟,来测试我们想要测试的事件逻辑,同时不影响其他应用程序状态。

我们还需要仔细编写测试代码,以正确设置和拆除测试环境,以便我们可以在每个测试中对起始事件监听器状态做出合理的假设。例如,如果多个测试在未清理的情况下向共享对象添加自定义监听器,其他测试可能会由于监听器回调交互而意外失败。

我们将在所有 Backbone.js 组件中测试的事件行为包括是否:

  • 对象响应自定义/内置事件

  • 对象正确地发出事件

  • 在销毁事件(如对象或视图移除)中,事件监听器被正确清理

测试应用程序的初步尝试

现在我们已经可以识别出我们想要测试的 Backbone.js 组件的各个方面,让我们开始规划并编写测试命名空间实用工具和 Backbone.js 模型的测试。对于每个组件,我们将检查应用程序用例和预期行为,然后编写测试以验证我们的期望。

命名空间

Notes 应用程序的起点是一个命名空间实用工具,它提供了两个全局变量来组织我们的应用程序类(App)和实例(app)。在 notes/app/js/app/namespace.js 示例应用程序文件中,我们将创建两个命名空间对象字面量,并带有类/应用程序属性:

// Class names.
var App = App   || {};
App.Config      || (App.Config = {});
App.Models      || (App.Models = {});
App.Collections || (App.Collections = {});
App.Routers     || (App.Routers = {});
App.Views       || (App.Views = {});
App.Templates   || (App.Templates = {});

// Application instance.
var app = app || {};

我们想要测试这些辅助对象的预期行为是它们包含其他应用程序组件所依赖的正确属性。因此,chapters/02/test/js/spec/namespace.spec.js 测试文件只需要几个规范来覆盖这些对象。第一个规范 提供 'App' 对象 断言 App 是一个具有所有不同分组名称(ModelsViews 等)属性的 JavaScript 对象:

describe('Namespace', function () {
  it("provides the 'App' object", function () {
    // Expect exists and is an object.
    expect(App).to.be.an("object");

    // Expect all namespace properties are present.
    expect(App).to.include.keys(
      "Config", "Collections", "Models",
      "Routers", "Templates", "Views"
    );
  });

第二个规范 提供 'app' 对象 仅检查全局 app 变量是否存在为一个对象:

  it("provides the 'app' object", function () {
    expect(app).to.be.an("object");
  });
});

笔记模型

接下来,我们将实际进入 Backbone.js 类,从提供单个笔记数据的 notes/app/js/app/models/note.js 文件开始,该文件位于 Notes 应用程序中:

App.Models.Note = Backbone.Model.extend({
  defaults: function () {
    return {
      title: "",
      text: "*Edit your note!*",
      createdAt: new Date()
    };
  }
});

该模型有三个字段:titletextcreatedAt。由于我们的示例 Notes 应用程序使用在集合类中配置的 localStorage,我们不需要提供后端同步声明(例如,urlRoot 属性或 url 函数)以持久化模型数据。因为我们的模型本质上只包含一个 defaults 声明,所以我们需要测试的行为仅仅是默认和修改后的属性按预期工作。

我们为模型编写的测试文件 chapters/02/test/js/spec/models/note.spec.js 包含两个规范。第一个规范使用默认值创建一个 App.Models.Note 对象,并使用 get() 验证每个属性:

describe("App.Models.Note", function () {
  it("has default values", function () {
    // Create empty note model.
    var model = new App.Models.Note();

    expect(model).to.be.ok;
    expect(model.get("title")).to.equal("");
    expect(model.get("text")).to.equal("*Edit your note!*");
    expect(model.get("createdAt")).to.be.a("Date");
  });

第二个规范 sets passed attributes 测试了一个使用提供的 titletext 值创建的模型:

  it("sets passed attributes", function () {
    var model = new App.Models.Note({
      title: "Grocery List",
      text: "* Milk\n* Eggs\n*Coffee"
    });

    expect(model.get("title")).to.equal("Grocery List");
    expect(model.get("text")).to.equal("* Milk\n* Eggs\n*Coffee");
  });
});

运行应用程序测试

在有了 Backbone.js 应用程序文件和初步的应用程序测试后,我们需要将所有内容集成到我们在第一章中创建的测试驱动程序中,设置测试基础设施。我们将通过向chapters/02/test/js/spec添加规范并将应用程序库从notes/app/js/app复制到chapters/02/app/js/app来继续使用现有的应用程序目录结构。

注意

Notes 应用程序位于notes/app目录中,这是我们讨论应用程序组件的基础位置。同时,章节代码示例旨在保持独立。因此,我们保持我们的布局规则,即应用程序代码放在chapters/NUMBER/app中,测试放在chapters/NUMBER/test中。

因此,可下载的示例链接文件,如chapters/02/app/js/app/namespace.jsnotes/app/js/app/namespace.js。因此,在本书中,我们将交替使用任一完整路径作为前缀来讨论文件,如namespace.js

chapters/02/test/test.html测试驱动程序页面中,我们将添加引用我们的库、应用程序文件和测试的script标签:

<!-- JavaScript Test Libraries. -->
<script src="img/mocha.js"></script>
<script src="img/chai.js"></script>
<script src="img/sinon.js"></script>

<!-- JavaScript Core Libraries -->
<script src="img/underscore.js"></script>
<script src="img/jquery.js"></script>
<script src="img/backbone.js"></script>
<script src="img/backbone.localStorage.js"></script>
<script src="img/bootstrap.js"></script>
<script src="img/showdown.js"></script>

<!-- JavaScript Application Libraries -->
<script src="img/namespace.js"></script>
<script src="img/note.js"></script>

<!-- Set up Mocha and Chai -->
<script>
  var expect = chai.expect;
  mocha.setup("bdd");

  window.onload = function () {
    mocha.run();
  };
</script>

<!-- Include our specs. -->
<script src="img/namespace.spec.js"></script>
<script src="img/note.spec.js"></script>

在上一文件中突出显示的标签行说明我们现在已经添加了核心供应商库(Underscore.js、Backbone.js 等)、应用程序库以及我们的两个规范文件。打开chapters/02/test/test.html将给我们测试报告:

运行应用程序测试

测试报告

在我们的测试报告完成后,我们现在已经锻炼和测试了特定的 Backbone.js 组件,并将所有内容连接到整体测试基础设施中。

摘要

在本章中,我们回顾了 Backbone.js 应用程序的基础知识,并介绍了 Notes 应用程序作为本书测试示例的有用但可选的伴侣。然后,我们回顾了一些相关的高级测试概念,并深入探讨了在 Backbone.js 应用程序中我们想要测试的具体内容——作为独立的应用程序部分以及作为整体的一部分。最后,我们编写了我们的第一个应用程序单元测试,并将测试基础设施从第一章,设置测试基础设施扩展到执行我们的测试报告器。

现在,你应该能够对现有的或正在开发中的 Backbone.js 应用程序进行分析,分析其组件,并创建一个抽象的测试基础设施轮廓,稍后用实际的测试和套件填充。

在下一章中,我们将学习 Chai 断言、基本的 Mocha 测试结构(规范和套件)、测试设置/清理/配置,以及如何在异步应用程序环境中确定性地验证行为。我们还将通过在学习测试技术的过程中为我们的 Backbone.js 应用程序编写更多的测试来广泛增加我们的测试覆盖率。

第三章。测试断言、规范和套件

在将 Backbone.js 应用程序集成到我们正在发展的测试基础设施中,并且有一个粗略的测试计划进行中,我们现在将开始扩展我们应用程序的测试覆盖率。在本章中,我们将介绍一些基本的测试工具,并在以下主题中测试 Backbone.js 应用程序的更多部分:

  • 概述 Mocha 和 Chai 测试库的接口和风格

  • 介绍 Chai 断言库 API

  • 配置 Mocha 运行器和 Backbone.js 应用程序以进行测试

  • 将 Mocha 规范聚合到测试套件中并准备测试状态

  • 从 Backbone.js 集合规范开始编写 Mocha 测试规范

  • 测试异步应用程序代码

  • 为 Backbone.js 视图编写规范和 HTML 测试固定装置

  • 应对软件/测试开发中的陷阱,并学习如何编写可测试的代码

选择适合的测试风格

Mocha 和 Chai 都为编写测试提供了不同的库接口。这方便开发者为不同的项目选择合适的测试范式或风格,同时仍然利用相同的底层功能。

Mocha 测试接口

Mocha 目前为测试套件和规范提供四个接口:

  • 行为驱动开发BDD)接口:此接口使用与 Ruby RSpec 框架推广的类似测试构造(rspec.info/)。

  • 测试驱动开发TDD)接口:此接口使用更传统的单元测试关键字,如 suitetest

  • exports 接口:此接口利用 Node.js/CommonJS 开发者熟悉的模块格式,将测试功能实现为 module.exports 对象的属性。

  • QUnit 风格的接口:此接口使用来自流行的 QUnit 测试框架的平面声明范式(qunitjs.com/)。在此风格中,套件是在测试之前和与测试相同的级别上声明的,而不是像其他接口那样包含测试。

Chai 断言风格

Chai 提供两种断言风格:

  • BDD 风格:此风格允许使用 expect 函数或 should 对象原型扩展启用断言的点符号链,例如 expect("foo").to.be.a("string").and.equal("foo")

  • assert 风格:此风格使用附加到 assert 对象的单个函数断言,例如:

    assert.isString("foo");
    assert.strictEqual("foo", "foo");
    

虽然 expectassert 在功能上等效,但 BDD 构造 expectshould 之间有一些重要的区别。本质上,因为 should 会修补对象原型,所以它不适用于某些类型的实际值(例如 nullundefined),并且与 Internet Explorer 9 不兼容。因此,我们的 Chai BDD 示例将使用 expect 而不是 should

尝试一些不同的风格

让我们看看 Mocha 和 Chai 两个最常见接口的示例——BDD 和 TDD。

Mocha 和 Chai BDD 接口

Mocha BDD 接口提供了四个主要功能单元:

  • before(): 这是一个在套件中的所有测试运行之前只发生一次的设置。Mocha 还提供了一个beforeEach()函数,它在套件中的每个测试之前运行。

  • after(): 这是一个在套件中所有测试运行后只发生一次的设置,与在每次测试之前运行的afterEach()替代方案。

  • describe(): 这指定了一个测试套件,可以嵌套在其他describe()函数中。

  • it(): 这定义了一个包含一个或多个断言的单个测试函数。

Chai 的 BDD 风格使用expectshould来创建点符号断言链。

我们可以创建一个基本的测试文件chapters/03/test/js/spec/bdd.spec.js,它使用所有这些组件。我们使用describe()命名套件,使用before()/after()添加/删除函数,并通过it()规范声明进行测试。我们使用and辅助函数将两个 Chai 断言链式连接起来,产生一个复合断言,其读起来自然为“期望hello()的结果是一个字符串,并且等于文本'hello world'”:

describe("BDD example", function () {
  // Runs once before all tests start.
  before(function () {
    this.hello = function () {
      return "Hello world!";
    };
  });

  // Runs once when all tests finish.
  after(function () {
    this.hello = null;
  });

  it("should return expected string result", function () {
    expect(this.hello()).to
      .be.a("string").and
      .equal("Hello world!");
  });
});

我们的测试驱动网页(示例中的chapters/03/test/test-bdd.html)将 Chai 的expect函数添加到全局命名空间中以便使用,并配置 Mocha 使用 BDD 风格。相关的配置片段是:

<script>
  var expect = chai.expect;
  mocha.setup("bdd");

  window.onload = function () {
    mocha.run();
  };
</script>

<script src="img/bdd.spec.js"></script>

Mocha TDD 和 Chai 断言风格

Mocha TDD 接口对相同的基本单元使用不同的名称:

  • suiteSetup()setup(): 这些是before()beforeEach()的别名

  • suiteTeardown()teardown(): 这些是after()afterEach()的别名

  • suite(): 这指定了一个测试套件(在 BDD 中为describe()

  • test(): 这定义了一个单个测试函数(在 BDD 中为test()

Chai 断言风格通常与 TDD 风格的测试相关联,并提供了一个具有单个函数断言的断言对象。

我们的 TDD 测试文件chapters/03/test/js/test/tdd.js提供了与 BDD 版本相同的测试设置和断言系列:

suite("TDD example", function () {
  // Runs once before all tests start.
  suiteSetup(function () {
    this.hello = function () {
      return "Hello world!";
    };
  });

  // Runs once when all tests finish.
  suiteTeardown(function () {
    this.hello = null;
  });

  test("expected string result", function () {
    assert.isString(this.hello());
    assert.strictEqual(this.hello(), "Hello world!");
  });
});

驱动网页只在两行配置上有所不同:

<script>
  var assert = chai.assert;
  mocha.setup("tdd");

  window.onload = function () {
    mocha.run();
  };
</script>

<script src="img/tdd.js"></script>

在浏览器中打开chapters/03/test/test-tdd.html应显示与上一个 BDD 示例完全相同的测试结果。

决定项目风格

Mocha 和 Chai 中的风格选项为开发者提供了在利用相同底层测试基础设施的同时选择测试范例的很大灵活性。在这本书中,我们出于几个原因更喜欢 Mocha 和 Chai 的 BDD 风格:

  • Chai expect断言链以自然语言格式读取,通常可以免除单独的测试注释的需要。

  • Mocha BDD 接口组件简化了诸如“这是一个数字计算器”和“它应该求和两个数字”等行为的叙述性描述。

  • BDD 接口在现代 JavaScript 测试生态系统中非常流行,并鼓励开发者描述被测试代码的行为,而不仅仅是识别内部细节。

尽管如此,如果你更喜欢其他风格,请使用最自然的感觉。本书中的所有测试代码示例都可以在 Mocha 和 Chai 接口之间进行翻译。

Chai 断言库之旅

Chai 测试库提供了一套强大的断言和辅助工具,以帮助测试的可读性和组织。

Chai 对 expect 的自身单元测试(在 github.com/chaijs/chai/blob/master/test/expect.js)提供了一个很好的起点,从该起点可以探索 API。Chai 方便地使用 Mocha 作为其测试框架(配置了 TDD 接口)。因此,整个测试套件应该非常熟悉。

在本节中,我们将使用一系列断言来介绍 Chai BDD API 的大部分内容。断言示例被累积到本章的单个驱动文件中——chapters/03/test/test-chai.html

注意

Chai 为本节中将要讨论的许多断言提供了别名。例如,深度相等断言 eql 也可用作 eqlsdeep.equal。有关别名完整列表,请参阅 Chai API 文档。

链接对象和断言

Chai 的 BDD 接口公开了可以链式组合以使测试断言更易于理解的对象。我们将通过文件 chapters/03/test/js/spec/chai-chains.spec.js 中的基本示例进行说明。

作为入门示例,断言 expect("foo").to.be.a("string") 使用了链式对象 tobe,它们简单地代理到最后一个断言。通过这种方式,Chai 允许我们添加任何链式对象 tobebeenisthatandhavewithatof,以形成更易于阅读的断言语句。aan 既可以作为断言链,也可以作为比较函数。

我们可以使用这些语句创建语言链断言,例如:

expect("foo").a("string");
expect("foo").to.be.a("string");
expect("foo").to.have.been.a("string");
expect("foo").that.is.a("string");

// Chains can be repeated (or be nonsensical).
expect("foo").to.to.to.to.a("string");
expect("foo").and.with.at.of.a("string");

由于所有语句都是等效的,前一个代码中突出显示的语言链展示了表达相同断言的许多不同方式。

Chai 还提供了修改最终断言的语言链对象:

  • not:这否定任何后续的断言。例如:

    expect("foo").to.not.equal("bar");
    
    // Let's get literary.
    expect("Hamlet").to.be.not.to.be.an("object");
    
  • deep:这设置了深度检查的深度标志。原始的相等检查执行一个身份测试,它断言两个变量在进程内存中是相同的对象。使用深度标志,Chai 相反地断言两个变量具有相同的属性值,即使它们是不同的底层对象。例如,expect({foo: "bar"}).to.equal({foo: "bar"}) 在对象身份测试中失败,而 expect({foo: "bar"}).to.deep.equal({foo: "bar"}) 则成功。

最后,大多数其他 Chai BDD 断言语句都是可链式的。以下示例使用 and 辅助函数将几个断言链组合在一起:

expect("foo")
  .to.be.a("string").and
  .to.equal("foo").and
  .to.have.length(3).and
  .to.match(/f[o]{2}/);

使用这些基本语言链和辅助工具,我们为编写可读的断言语句打下了基础。

小贴士

当为了开发者的理解以及编写简洁的测试代码时,将断言链在一起是一种良好的实践。同时,单独的 expect() 语句通常更为合适。

基本值断言

Chai 提供了各种断言属性来检查输入值(请参阅 chapters/03/test/js/spec/chai-values.spec.js):

  • ok: 值是真值(有关 JavaScript 的条件真值和相等性的宽容概念快速介绍,请参阅 www.sitepoint.com/javascript-truthy-falsy/

    expect("foo").to.be.ok;
    expect(true).to.be.ok;
    expect(false).to.not.be.ok;
    
  • exist: 值既不是 null 也不是 undefined

    expect(false).to.exist;
    expect(null).to.not.exist;
    expect(undefined).to.not.exist;
    
  • true: 值正好是 true

    expect("foo").to.not.be.true;
    expect(true).to.be.true;
    
  • false: 值正好是 false

    expect("").to.not.be.false;
    expect(false).to.be.false;
    
  • null: 值正好是 null

    expect(null).to.be.null;
    
  • undefined: 值正好是 undefined

    expect(undefined).to.be.undefined;
    expect(null).to.not.be.undefined;
    
  • arguments: 值是特殊的 JavaScript arguments 对象,它包含当前函数的参数列表

    expect(arguments).to.be.arguments;
    expect([]).to.not.be.arguments;
    

比较值

Chai 提供了各种比较函数来评估输入值(请参阅 chapters/03/test/js/spec/chai-comparisons.spec.js):

  • equal: 严格(===)相等

    expect("foo").to.equal("foo");
    expect({foo: "bar"}).to.not.equal({foo: "bar"});
    
  • eql: 深度相等——等同于 deep.equal

    expect("foo").to.eql("foo");
    expect({foo: "bar"}).to.eql({foo: "bar"});
    
  • above: 实际值大于预期值

    expect(1).to.not.be.above(1);
    expect(5).to.be.above(2);
    
  • least: 实际值大于或等于预期值

    expect(1).to.be.at.least(1);
    expect(5).to.be.at.least(2);
    
  • below: 实际值小于预期值

    expect(1).to.not.be.below(1);
    expect(1).to.be.below(2);
    
  • most: 实际值小于或等于预期值

    expect(1).to.be.at.most(1);
    expect(1).to.be.at.most(2);
    
  • within: 实际值在预期值的范围内

    expect(1).to.be.within(0, 2);
    
  • closeTo: 实际值在预期值的范围内

    expect(1.2).to.be.closeTo(1, 0.2);
    expect(1.2).to.not.be.closeTo(1, 0.0);
    
  • match: 实际字符串值与预期正则表达式匹配

    expect("foo").to.match(/^f[o]+/);
    
  • string: 实际字符串值包含预期的子字符串

    expect("foo bar").to.have.string("foo");
    
  • satisfy: 评估函数将实际值作为参数,如果断言应该通过则返回 true

    expect(42).to.satisfy(function (value) {
      return value === 6 * 7;
    });
    

对象和数组验证

Chai 提供了一些针对对象和数组的定制化断言(请参阅 chapters/03/test/js/spec/chai-objects.spec.js):

  • a: 当作为函数调用时,这会基于 JavaScript 的原生 typeof 测试检查对象类型,并额外支持正确推断对象和数组。注意,当 a(或 an)用作对象属性时,它作为语言链起作用。

    expect("foo").is.a("string");
    expect("foo").is.not.a("number");
    expect({foo: "bar"}).is.an("object");
    
  • instanceof: 检查对象是否是预期构造函数的实例。

    var Foo = function () {},
      Bar = function () {};
    
    expect(new Foo()).is.an.instanceof(Foo);
    expect(new Bar()).is.not.an.instanceof(Foo);
    
  • property: 检查对象中是否存在预期属性,以及可选地,该属性值是否与预期值匹配。当与 deep 语言链一起使用时,可以通过点或数组符号导航对象结构。

    expect({foo: "bar"}).to.have.property("foo", "bar");
    
    // Deep checking - object, and array.
    expect({foo: {bar: "baz"}})
      .to.have.deep.property("foo.bar", "baz");
    expect({foo: ["bar", "baz"]})
      .to.have.deep.property("foo[1]", "baz");
    
  • ownProperty: 使用 JavaScript 的 hasOwnProperty 测试检查对象上是否存在直接属性,而无需在对象的原型链上查找继承属性。

    expect({foo: "bar"}).to.have.ownProperty("foo");
    
  • length: 检查数组或对象的 length 属性(例如字符串)。

    expect(["bar", "baz"]).to.have.length(2);
    expect("foo").to.have.length(3);
    
  • contain:检查对象是否在数组中或字符串中的子串。请注意,contain(和include)可以作为带有keys的语言链的替代使用。

    expect(["bar", "baz"]).to.contain("bar");
    expect("foo").to.contain("f");
    
  • keys:检查对象是否包含所有预期的属性名称。当与includecontain语言链结合使用时,断言仅验证预期属性名称的子集。

    // Exact matching of all keys.
    expect({foo: 1, bar: 2}).to.have.keys(["foo", "bar"]);
    
    // Exclusion of any keys.
    expect({foo: 1, bar: 2}).to.not.have.keys(["baz"]);
    
    // Inclusion of some keys.
    expect({foo: 1, bar: 2}).to.include.keys(["foo"]);
    expect({foo: 1, bar: 2}).to.contain.keys(["bar"]);
    

错误

Chai 还可以检查异常代码的功能,特别是捕获和验证程序异常。

throw 断言接受一个函数作为输入,该函数在调用时预期会抛出异常。然后,结果错误与构造函数类(例如,Error)或消息字符串/正则表达式(例如,/message/)进行匹配。请注意,传递给断言的是函数引用(例如,bad),而不是被调用的函数(例如,bad())。这使 Chai 能够在内部调用函数,捕获任何异常,并验证结果:

var bad = function () {
  throw new Error("My error message");
};

expect(bad)
  .to.throw(Error).and
  .to.throw(/message/).and
  .not.to.throw("no message match");

准备应用程序和测试以运行

现在我们已经掌握了 Chai 断言库 API,是时候编写和组织测试了。虽然我们意外地覆盖了大部分材料,但 Mocha 测试基础设施的简洁核心包括:

  • 测试运行器:配置总体测试运行和报告

  • 测试套件: 一个或多个组织单元,将许多规格/测试分组在一起

  • 设置/清理: 为每个测试或套件运行设置状态

  • 规格说明: 编写测试函数

从最高级别开始,我们查看我们的测试驱动网页。如前所述,这是我们的核心应用程序库、测试库和测试规格设置和包含的地方。本章其余部分的所有 Backbone.js 应用程序测试都包含在 chapters/03/test/test.html 驱动网页中。

Mocha 测试运行器

Mocha 的 setup() 函数控制所有测试套件和规格执行的总体参数和环境。该函数应在测试驱动网页中执行开始之前(例如,mocha.run())调用一次:

mocha.setup("bdd");

window.onload = function () {
  mocha.run();
};

默认设置对于 Backbone.js 测试来说相当可用,我们在这本书的几乎所有测试中都使用了之前的代码。然而,还有许多其他选项可用,这些选项在 visionmedia.github.io/mocha/ 中进行了描述。以下是一个任意的样本:

mocha.setup({
  ui: "bdd",          // BDD UI.
  reporter: "html",   // HTML reporter.
  timeout: 4000,      // 4s test timeout.
  slow: 1000          // A "slow" test is > 1s
});

重新配置应用程序以进行测试

Backbone.js 应用程序通常需要特定的测试友好配置,以便使测试环境可预测并避免覆盖真实数据。后端信息(例如,主机地址和端口)在开发和测试阶段通常不同。因此,将所有这些信息抽象到一个公共文件中是一个好的做法,这样我们就可以轻松地在中央位置切换值:

小贴士

创建可工作的测试环境的一种替代和补充方法是使用 Sinon.JS 等库来伪造配置细节和依赖项。这里可以帮助我们的 Sinon.JS 抽象是存根和模拟,两者都可以在测试期间替换对象方法的行为。(我们将在第五章“第五章。测试存根和模拟”中详细介绍和讨论这些概念。)

在以下数据存储配置示例中,我们可以使用 Sinon.JS 存根用测试特定的模拟代码替换整个数据存储。存根将允许我们使用正常的应用程序配置,同时确保我们不会修改真实的数据存储。作为这种方法的一个额外好处,存根和模拟外部依赖项通常可以使测试运行得更快,尤其是如果伪造替换了一个相对较慢的应用程序行为(例如,网络通信)。

在笔记应用中,我们要求集合有一个唯一的 localStorage 名称,我们在配置文件 notes/app/js/app/config.js 中指定它:

App.Config = _.extend(App.Config, {
  // Local Storage Name
  storeName: "notes"
});

此代码将 App.Config 命名空间填充为 App.Config.storeName,然后我们将其用于 notes/app/js/app/collections/notes.js 中的 App.Collections.Notes 集合:

App.Collections.Notes = Backbone.Collection.extend({
  model: App.Models.Note,

  // Sets the localStorage key for data storage.
  localStorage: new Backbone.LocalStorage(App.Config.storeName)
});

使用这种设置,实时应用将数据保存到 localStorage 中的 notes 存储。然而,在我们的测试中,我们希望添加、删除和修改笔记数据,而不覆盖我们开发友好的数据存储。因此,通过在我们的应用程序测试驱动程序页面中添加额外的配置指令,我们可以使用 Underscore.js 的 extend() 函数将测试专用存储名称设置为 notes-test

<script src="img/namespace.js"></script>
<script src="img/config.js"></script>
<script>
  // Test overrides (before any app components).
  App.Config = _.extend(App.Config, {
    storeName: "notes-test" // localStorage for tests.
  });
</script>
<script src="img/note.js"></script>
<script src="img/notes.js"></script>

通过首先包含 config.js 然后覆盖特定值,我们使测试期间可用的其他未修改的配置值。使用这种方案,我们现在有一个完全独立的 Backbone.js 测试数据存储,我们可以更改它而不会影响我们的开发环境。

将主题和规范组织到测试套件中

将测试代码组织到主题和应用程序组件中是开发整体测试架构的重要步骤。为此,Mocha 提供了 describe() 测试套件函数来分组逻辑集合的测试规范。

例如,在 App.Collections.Notes 中,我们可能从两个测试子组开始:

  • 创建空集合并验证初始默认状态的测试

  • 使用新的 App.Models.Note 对象修改集合的测试

将此列表转换为一系列嵌套的 Mocha 测试套件将给我们:

describe("App.Collections.Notes", function () {

  describe("creation", function () {
    // Tests.
  });

  describe("modification", function () {
    // Tests.
  });
});

这里我们有两种级别的 describe() 语句,尽管 Mocha 允许更深的套件嵌套。

启动和关闭测试

尽管我们试图将测试规范的行为隔离作为一般做法,但测试通常有共同的设置和拆解需求。例如,如果一组规范都测试同一个对象,那么最合理的方法可能是创建该对象一次,并与所有规范共享。

提示

Mocha 中的上下文/成员变量

Mocha 允许测试代码将值附加到 this 上下文对象,以便在测试运行的其它部分使用。这允许我们在不需要声明和管理全局或更高作用域变量的情况下,在测试之间共享变量。此功能的常见用途是在一组测试的 before() 设置语句中添加一个变量,例如 this.myCollection,然后在组的 after() 语句中移除它。

Mocha 提供了 before(), beforeEach(), after(), 和 afterEach() 函数来帮助我们管理测试状态。如前所述,before()/after() 函数在每个套件中的所有测试之前和之后各运行一次。beforeEach()/afterEach() 函数在每个套件中的每个测试之前和之后运行。

通过这四个构造,我们可以为我们的 Mocha 测试创建细微的状态管理。设置/拆解函数在测试套件的级别上操作。这意味着嵌套的测试套件可以提供它们自己的附加设置/拆解函数。例如,Mocha 将忠实地运行每个 before() 语句,在执行第一个测试之前,它将深入到 describe() 语句中。

注意

使用设置/拆解函数的另一个好理由是它们总是运行——即使测试规范失败或抛出异常也是如此。这防止单个测试失败影响运行中其他测试的数据状态,并导致虚假的测试失败。

Backbone.js 集合测试通常受益于使用设置和拆解辅助函数来创建初始数据状态。通常,这意味着添加一些起始记录(当从单独的数据文件加载时称为数据固定),并在测试修改集合后,将数据存储恢复到原始状态。

在测试驱动页面上,我们已配置 App.Collections.Notes 类使用仅测试的数据存储。Backbone.localStorage 适配器有一个内部方法 _clear(),它清除与集合关联的底层浏览器存储,我们将使用它来在测试中重置我们的数据状态。结果的数据沙盒已准备好以下测试场景:

  • 在套件设置中清除任何现有的集合数据,为单个集合添加一个上下文变量,并在拆解时移除该集合

  • modification 套件中,向集合添加一个初始的 App.Models.Note 对象,并在每个测试后清除集合

测试套件的设置和拆解函数的实现如下:

describe("App.Collections.Notes", function () {

  before(function () {
    // Create a reference for all internal suites/specs.
    this.notes = new App.Collections.Notes();

    // Use internal method to clear out existing data.
    this.notes.localStorage._clear();
  });

  after(function () {
    // Remove the reference.
    this.notes = null;
  });

  describe("creation", function () {
    // Tests.
  });

  describe("modification", function () {

    beforeEach(function () {
      // Load a pre-existing note.
      this.notes.create({
        title: "Test note #1",
        text: "A pre-existing note from beforeEach."
      });
    });

    afterEach(function () {
      // Wipe internal data and reset collection.
      this.notes.localStorage._clear();
      this.notes.reset();
    });

    // Tests.

  });
});

之前代码片段中突出显示的行说明了整体测试套件中所有测试的 before()/after() 调用以及子套件 modificationbeforeEach()/afterEach() 调用。

编写 Mocha 测试规范

在其他所有内容都就绪的情况下,我们最终转向编写测试规范。Mocha BDD 规范使用 it() 函数声明,该函数具有以下函数签名:

it(description, callback);

按照惯例,描述字符串是对测试中预期行为的陈述,回调函数执行测试。例如,假设我们有一个空的 this.notes 集合变量,对 App.Collections.Notes 中的默认值进行测试可以像以下这样简单:

it("has default values", function () {
  expect(this.notes).to.be.ok;
  expect(this.notes).to.have.length(0);
});

测试中的异步行为

尽管基本的测试规范相当简单,但在测试异步应用程序代码时会出现流程控制复杂性。鉴于 Backbone.js 应用程序的行为通常是异步/事件驱动的,我们需要有一个坚实且直接的测试方法。

幸运的是,Mocha 提供了一个异步测试函数参数,用于表示测试是异步的。如果在测试回调中提供了一个参数(按照惯例命名为 done),Mocha 将延迟测试完成,直到 done 被调用或测试超时。

我们可以在 Backbone.js 集合中测试的一种异步行为是,在集合上调用 fetch() 方法后,会触发 reset 事件。在这里,我们创建一个空的 App.Collections.Notes 对象,获取其后端数据,并确认事件已触发。在所有这些都被验证后,我们添加一个调用 done() 的操作来表示测试成功完成。如果 reset 事件从未触发,则不会调用 done(),测试将超时:

it("should be empty on fetch", function (done) {
  var notes = new App.Collections.Notes();

  // "reset" event fires on successful fetch().
  notes.once("reset", function () {
    expect(notes).to.have.length(0);

    // Async code has completed. Signal test is done.
    done();
  });

  notes.fetch({ reset: true });
});

使用额外的 done 参数,我们现在可以在测试完成之前在单个规范中运行一系列异步断言。

一些 Backbone.js 集合测试

既然我们可以编写异步规范,我们将完成 Notes 集合的测试套件。考虑到 第二章 中 Backbone.js 集合的测试目标,即 创建 Backbone.js 应用程序测试计划(即修改模型、触发事件和同步数据),我们将为 App.Collections.Notes 创建以下规范(此处以概要形式展示):

describe("App.Collections.Notes", function () {

  describe("creation", function () {
    it("has default values");
    it("should be empty on fetch");
  });

  describe("modification", function () {
    it("has a single note");
    it("can delete a note");
    it("can create a second note");
  });
});

在应用程序开发期间创建一个空白的测试概要是一个很好的练习和实践。例如,作为测试驱动开发过程的一部分,我们可以首先编写 describeit 声明而不使用回调,以指定相关应用程序组件的程序行为世界。一旦我们满意地认为概要近似于组件的预期用例,我们就可以继续填写测试和编写应用程序代码。

有助于,Mocha 将没有函数的 spec 声明(如前一个代码片段所示)视为挂起的测试。挂起的 spec 与普通测试在 Mocha HTML 测试报告中以不同的颜色区分。有了挂起的测试,开发者可以更容易地扫描测试报告以查找未完成的 spec,然后实现必要的测试和应用程序代码。

考虑到这一点,让我们实现规范以完成测试文件 chapters/03/test/js/spec/collections/notes.spec.js

注意

为了简洁和可读性,我们从以下代码(以及本书的其他地方)中省略了一些 spec 函数。App.Collections.Notes 和其他笔记测试套件的完整 spec 实现可在本书的配套代码示例中找到。

describe("App.Collections.Notes", function () {

  before(function () {
    // Create a reference for all internal suites/specs.
    this.notes = new App.Collections.Notes();

    // Use internal method to clear out existing data.
    this.notes.localStorage._clear();
  });

  after(function () {
    // Remove the reference.
    this.notes = null;
  });

  describe("creation", function () {

    it("has default values", function () {
      expect(this.notes).to.be.ok;
      expect(this.notes).to.have.length(0);
    });

    it("should be empty on fetch", function (done) {
      // ... implemented in previous example ...
    });

  });

  describe("modification", function () {

    beforeEach(function () {
      // Load a pre-existing note.
      this.notes.create({
        title: "Test note #1",
        text: "A pre-existing note from beforeEach."
      });
    });

    afterEach(function () {
      // Wipe internal data and reset collection.
      this.notes.localStorage._clear();
      this.notes.reset();
    });

    it("has a single note", function (done) {
      var notes = this.notes, note;

      // After fetch.
      notes.once("reset", function () {
        expect(notes).to.have.length(1);

        // Check model attributes.
        note = notes.at(0);
        expect(note).to.be.ok;
        expect(note.get("title")).to.contain("#1");
        expect(note.get("text")).to.contain("pre-existing");

        done();
      });

      notes.fetch({ reset: true });
    });

    it("can delete a note", function (done) {
      var notes = this.notes, note;

      // After shift.
      notes.once("remove", function () {
        expect(notes).to.have.length(0);
        done();
      });

      // Remove and return first model.
      note = notes.shift();
      expect(note).to.be.ok;
    });

    it("can create a second note", function (done) {
      // ... omitted ...
    });

  });
});

这个最后的测试文件使用本章中讨论的所有不同的 Mocha 和 Chai 部分,提供了一个合理的框架。我们可以通过打开浏览器到驱动页面 chapters/03/test/test.html 来看到我们的测试在行动。

测试和支持 Backbone.js 视图

现在我们已经为 Backbone.js 模型和集合创建了测试套件,我们将转向扩展我们的测试覆盖率到 Backbone.js 视图。

笔记应用单条笔记视图

我们将要检查的第一个 Backbone.js 视图是 App.Views.NoteView。这个视图负责将 App.Models.Note 的 Markdown 数据渲染成完整的 HTML,如下面的截图所示:

笔记应用单条笔记视图

视图渲染的 Markdown

图像的底层模型数据包括以下属性:

  • 标题:

    My Title
    
  • text

    ## My Heading
    * List item 1
    * List item 2
    

text 属性数据转换为 HTML 的方式如下:

<h2 id="myheading">My Heading</h2>
<ul>
  <li>List item 1</li>
  <li>List item 2</li>
</ul>

App.Views.NoteView 负责执行这个转换。notes/app/js/app/views/note-view.js 文件首先提供了一个 initialize 函数,该函数设置模型监听器以重新渲染或清理视图,然后启动 render()render 函数使用 Showdown 库将模型的 text Markdown 数据转换为 HTML,然后将完成的数据传递给视图模板:

App.Views.NoteView = Backbone.View.extend({

  template: _.template(App.Templates["template-note-view"]),

  converter: new Showdown.converter(),

  initialize: function () {
    this.listenTo(this.model, "change", this.render);
    this.listenTo(this.model, "destroy", this.remove);
    this.render();
  },

  // Convert note data into Markdown.
  render: function () {
    this.$el.html(this.template({
      title: this.model.get("title"),
      text: this.converter.makeHtml(this.model.get("text"))
    }));
    return this;
  }
});

这个视图包括一个 Underscore.js 模板(notes/app/js/app/templates/templates.js 中的 App.Templates["template-note-view"]),它将 titletext 数据插入到 HTML 中:

App.Templates["template-note-view"] =
  "<div class=\"well well-small\">" +
  "  <h2 id=\"pane-title\"><%= title %></h2>" +
  "</div>" +
  "<div id=\"pane-text\"><%= text %></div>";

使用 App.Views.NoteView.render() 将模型数据渲染成 HTML 表单,我们得到以下结果 HTML:

<div class="well well-small">
  <h2 id="pane-title">My Title</h2>
</div>
<div id="pane-text">
  <h2 id="myheading">My Heading</h2>
  <ul>
    <li>List item 1</li>
    <li>List item 2</li>
  </ul>
</div>

现在我们已经介绍了一个简单的视图来工作,我们将检查如何测试其行为。

为视图测试创建 HTML 固定数据

到目前为止,我们编写的 Backbone.js 应用程序测试并不与网页的 DOM 或 HTML 接口。这简化了我们的测试环境,因为应用程序的网页(例如,index.html)与我们测试驱动页面(例如,test.html)非常不同。然而,Backbone.js 视图几乎总是涉及大量的 DOM 交互。

为了达到这个目的,我们需要一个 HTML 测试固定元素——在测试驱动页面中的一个或多个 DOM 元素,我们可以在测试期间与之交互和修改。同时,我们不想让固定 HTML 在驱动页面的测试代码中造成混乱。因此,我们在chapters/03/test/test.html驱动页面中为我们的应用程序视图测试创建了一个单独的、隐藏的div元素:

<body>
  <div id="mocha"></div>

  <!-- Test Fixtures. -->
  <div id="fixtures"style="display: none; visibility: hidden;"></div>

现在,我们的测试可以在 jQuery 中引用$("#fixtures")并获取对固定容器的访问权限。然后,测试可以添加所需元素以练习任何期望的视图/DOM 交互。

小贴士

高级 HTML 固定元素

在本章中,我们只是触及了 HTML 固定元素的表面。存在更复杂的固定方案和库,例如在 iframe 中沙盒化应用 HTML 代码以避免测试代码交叉污染,以及从外部应用程序文件中加载 HTML 固定元素代码。两个与 Mocha 兼容的、有潜力的管理库是 jsFixtures (github.com/kevindente/jsFixtures) 和 js-fixtures (github.com/badunk/js-fixtures)。

遍历视图测试套件

让我们逐步查看chapters/03/test/js/spec/views/note-view.spec.js中的代码,这是App.Views.NoteView的测试套件。回忆一下第二章中 Backbone.js 视图测试的目标,即创建 Backbone.js 应用程序测试计划,我们将检查视图是否使用模型和模板渲染适当的 HTML,将 HTML 结果绑定到预期的 DOM 位置,并且与应用程序事件正确交互。

小贴士

为示例编写自己的测试

为了使本书的叙述更加流畅,我们将遵循一个一般方案:首先展示一个 Backbone.js 应用程序组件,然后通过测试来阐述特定的课程、技巧或工具。不幸的是,这与推荐的测试驱动开发过程相反,后者首先编写描述应用程序行为的测试,然后编写实现代码,并迭代直到整体行为正确。

对于您与本书的工作,我们强烈建议您在本书展示测试示例之前,设计和实现您自己的测试来测试示例应用程序组件。在编写自己的测试后,您可以继续阅读本书的示例,以检查您的作品并识别额外的测试想法和技术。

测试套件以一个describe声明和设置/清理代码开始。在套件执行开始时,创建一个视图测试用例($("<div id='note-view-fixture'></div>"))并存储在this.$fixture中。每个测试的设置(beforeEach()/afterEach())将新的this.$fixture测试用例绑定到 HTML 测试用例持有者$("#fixtures"),并创建一个带有App.Models.Note模型的App.Views.NoteView对象。在套件中的所有测试完成后,测试用例持有者$("#fixtures")将被清空:

describe("App.Views.NoteView", function () {

  before(function () {
    // Create test fixture.
    this.$fixture = $("<div id='note-view-fixture'></div>");
  });

  beforeEach(function () {
    // Empty out and rebind the fixture for each run.
    this.$fixture.empty().appendTo($("#fixtures"));

    // New default model and view for each test.
    //
    // Creation calls `render()`, so in tests we have an
    // *already rendered* view.
    this.view = new App.Views.NoteView({
      el: this.$fixture,
      model: new App.Models.Note()
    });
  });

  afterEach(function () {
    // Destroying the model also destroys the view.
    this.view.model.destroy();
  });

  after(function () {
    // Remove all subfixtures after test suite finishes.
    $("#fixtures").empty();
  });

在这些变量和 DOM 元素可用的情况下,我们可以使用 jQuery 测试默认模型是否渲染了预期的 HTML。请注意,因为其initialize函数调用了render,实例化一个App.Views.NoteView对象会将渲染后的 HTML 添加到我们的 DOM 测试用例中:

  it("can render an empty note", function () {
    var $title = $("#pane-title"),
      $text = $("#pane-text");

    // Default to empty title in `h2` tag.
    expect($title.text()).to.equal("");
    expect($title.prop("tagName").toLowerCase()).to.equal("h2");

    // Have simple default message.
    expect($text.text()).to.equal("Edit your note!");
    expect($text.html()).to.contain("<p><em>Edit your note!</em></p>");
  });

第二个规范更改了模型的属性titletext以渲染更复杂的 HTML。

困难的部分是在模型监听器调用render()并更新 DOM 之后等待,以检查新的 HTML 值。我们在这里的技术是观察render()已经监听模型事件change,并在此事件上添加一个额外的单次once()监听器来检查 HTML。

然而,请注意,这是一种处理测试行为异步性质脆弱的方法。渲染代码可能需要更多的时间来完成,从而破坏测试。更好的解决方案是在render()函数调用完成后等待,然后再运行测试代码——这是一种我们可以更方便地使用 Sinon.JS 间谍、存根和模拟来执行的技术,这些将在后续章节中详细讨论:

  it("can render more complicated markdown", function (done) {
    this.view.model.once("change", function () {
      var $title = $("#pane-title"),
        $text = $("#pane-text");

      // Our new (changed) title.
      expect($title.text()).to.equal("My Title");

      // Rendered Markdown with headings, list.
      expect($text.html())
        .to.contain("My Heading</h2>").and
        .to.contain("<ul>").and
        .to.contain("<li>List item 2</li>");

      done();
    });

    // Make our note a little more complex.
    this.view.model.set({
      title: "My Title",
      text: "## My Heading\n" +
            "* List item 1\n" +
            "* List item 2"
    });
  });
});

聚合并运行应用程序测试

完成集合和视图测试的测试驱动页面,我们在chapters/03/test/test.html中集成必要的脚本包含和 HTML 测试用例(在以下代码片段的相关部分中显示):

<head>
  <!-- ... snipped ... -->

  <!-- Test libraries. -->
  <script src="img/mocha.js"></script>
  <script src="img/chai.js"></script>
  <script src="img/sinon.js"></script>

  <!-- JavaScript Core Libraries -->
  <script src="img/underscore.js"></script>
  <!-- ... snipped ... -->

  <!-- JavaScript Application Libraries -->
  <script src="img/namespace.js"></script>
  <script src="img/config.js"></script>
  <script>
    // Test overrides (before any app components).
    App.Config = _.extend(App.Config, {
      storeName: "notes-test" // localStorage for tests.
    });
  </script>
  <script src="img/note.js"></script>
  <script src="img/notes.js"></script>
  <script src="img/templates.js"></script>
  <script src="img/note-view.js"></script>

  <!-- Test Setup -->
  <script>
    var expect = chai.expect;
    mocha.setup("bdd");

    window.onload = function () {
      mocha.run();
    };
  </script>

  <!-- Tests. -->
  <script src="img/notes.spec.js"></script>
  <script src="img/note-view.spec.js"></script>
</head>
<body>
  <div id="mocha"></div>

  <!-- Test Fixtures. -->
  <div id="fixtures"
       style="display: none; visibility: hidden;"></div>
</body>

打开网络浏览器到chapters/03/test/test.html,我们可以看到集合和视图的完整测试报告:

聚合并运行应用程序测试

测试报告

测试开发技巧、窍门和提示

当我们继续探索 Backbone.js 应用程序的理论和实践时,创建测试架构和编写良好的测试规范更多的是一种艺术,而不是精确的科学。许多经验教训只能通过经验来学习,尤其是在你的应用程序遇到错误和开发失误时。同时,我们可以从一些技术和建议开始:

隔离和排除测试

应用程序开发是一次保证会包括无法解释的错误、突然的应用程序崩溃和复杂的测试失败的旅程。当这些陷阱发生时,了解如何调试问题并继续前进的方向是很重要的。

在软件开发过程中,一个常见的场景是应用程序的更改破坏了一个或多个现有的单元测试。在这种情况下,一个良好的实践是逐个运行测试套件,修复测试,然后再继续进行其他测试。Mocha 提供了两个途径来帮助解决这个问题:

  • Grep:正如我们在 第一章 中讨论的,设置测试基础设施,你可以在测试报告 HTML 页面上点击单个测试,或者直接导航到一个带有 grep 查询参数的测试页面 URL,例如 test.html?grep=PATTERN

  • 仅限使用:另一个选择是临时修改你的 Mocha 测试规范,使用 only 辅助函数仅运行单个测试,跳过所有其他测试和套件。让我们看看一个例子:

    it("doesn't run this test", function () {
      expect(true).to.be.true;
    });
    
    it.only("runs this test", function () {
      expect(false).to.be.false;
    });
    

在这种场景的另一面,有时我们希望在仍然使用其余的测试基础设施的同时忽略一些失败的测试。在这种情况下,我们会转向 skip

  • 跳过skip 修饰符会从测试运行中省略单个规范,并且可以应用于多个规范。跳过的测试也被视为挂起的,可以在 Mocha HTML 测试报告中通过视觉区分:

    it.skip("doesn't run this test", function () {
      expect(true).to.be.true;
    });
    
    it("runs this test", function () {
      expect(false).to.be.false;
    });
    

编写可测试的应用程序代码

除了编写测试的实际方面之外,开发测试基础设施同样重要的一个组成部分是编写可测试的应用程序代码。关于可测试 JavaScript 代码的主题相当广泛——我们在这里只介绍这个问题,并从开发与支持其测试协同工作的应用程序代码的一般目标开始。

Mark Ethan Trostler 的书籍 Testable JavaScript 中可以找到对这一主题的全面处理,该书涵盖了诸如应用程序代码复杂性、基于事件的架构和调试等主题。shop.oreilly.com/product/0636920024699.do。同时,还可以考虑一些通用的 JavaScript 应用程序指南,如 Nicholas ZakasMaintainable JavaScript (shop.oreilly.com/product/0636920025245.do) 和 Douglas Crockford 的开创性作品 JavaScript: The Good Parts (shop.oreilly.com/product/9780596517748.do)。

一些 Backbone.js 应用程序开发提示和可测试代码的良好实践包括:

  • 解耦组件并限制依赖:许多 Backbone.js 组件对其他组件有可选的依赖。例如,一个 Backbone.js 视图可以可选地在视图类中声明一个模型(例如,model: Foo)或模型对象可以在实例化视图时传递给视图(例如,new View({model: foo}))。后一种技术通常为将模拟或测试友好的模型注入视图代码提供了更多机会。相同的逻辑也适用于 Backbone.js 视图中的 el 属性——通过视图对象实例提供值通常比在视图类定义中提供值更适合测试。

  • 隔离配置信息:任何纯配置数据都应该有自己的应用程序文件,并便于覆盖特定配置。典型的例子包括后端服务器的主机和端口信息、日志级别,以及在笔记应用程序的情况下,localStorage 数据存储的名称。之前覆盖 notes/app/js/app/config.js 的例子提供了如何创建配置文件以及为测试目的覆盖值的良好介绍。

  • 分解大型函数:试图做所有事情的单一函数通常很难测试。将大型函数分解成更小的部分,测试它们,然后将较小的函数聚合到你的应用程序中。

  • 避免隐藏状态:使用闭包和匿名函数等技术,JavaScript 允许类和代码拥有不可更改且对应用程序和测试的其他部分不可访问的状态。例如,如果一个类有一个内部计数器,请将其作为成员变量而不是闭包包装的变量。虽然这是一个有争议的话题,但通常更倾向于为了测试(和应用)使用而暴露一些内部状态。同时,我们的测试应该关注应用程序的行为,并避免故意使用不属于应用程序整体预期功能的内部状态。

请注意,这些提示是启发式方法,而不是铁的规则。许多开发情况可能会倾向于做与这些建议相反的事情。希望这些建议中的一些能帮助你使早期应用程序决策更容易随着时间的推移而适应。

摘要

在本章中,我们深入探讨了 Chai 和 Mocha 测试框架,从测试接口的概览开始。我们研究了 Chai 中可用的多种断言语句,然后检查了创建带有设置/清理、应用程序配置和 Backbone.js 组件测试规范的完整 Mocha 测试套件。最后,我们回顾了调试技巧和指南,以使软件开发周期更加适合测试。

到目前为止,我们的应用程序已经对一些 Backbone.js 模型、集合和视图开始了测试覆盖。在后续章节中介绍新主题的同时,我们将继续覆盖 Backbone.js 应用程序的不同部分,并最终构建一个汇总所有工作的最终应用程序测试集合。

在下一章中,我们将更加熟悉 Sinon.JS 库。我们将使用测试间谍来验证程序行为,并内省在 Backbone.js 应用程序中函数是如何被调用以及如何响应的。

第四章。测试间谍

当我们开始查看我们 Backbone.js 应用程序中更复杂的部分时,隔离依赖项和可测试行为的流程可能会变得越来越艰巨。为了在这些领域提供一些帮助,我们将介绍 Sinon.JS,这是一个强大的测试模拟、存根和间谍库,在本章中。

Sinon.JS 允许我们人为地隔离 Backbone.js 组件并测试特定的行为,而无需与应用程序的其他部分交互。我们将从以下主题开始讨论 Sinon.JS:

  • 识别在 Backbone.js 应用程序和测试场景中通常发现的测试限制,以及可以从中受益的测试模拟

  • 介绍 Sinon.JS 测试替身和断言库

  • 学习如何使用测试间谍检查应用程序行为

  • 将 Sinon-Chai 插件集成到 Chai 中以实现更好的测试断言

  • 使用测试间谍和其他 Sinon.JS 工具测试 Backbone.js 应用组件

假装你做到了

理想情况下,我们将在 Backbone.js 应用程序的所有部分上运行隔离、快速且一致的测试,而无需任何修改。实际上,这些目标至少在 Backbone.js 应用程序的一些实际代码路径上会遇到障碍。

我们希望对 Backbone.js 组件进行 隔离 测试,但许多组件依赖于应用程序的其他部分。我们还想让测试 快速 运行,但 Backbone.js 应用程序中的许多部分可能会减慢速度,包括以下内容:

  • 网络通信,例如将模型状态持久化到远程后端数据存储或第三方 API

  • 使用 Backbone.js 视图和模板进行复杂的 DOM 操作

  • 定时事件和 DOM 动画,尤其是那些故意等待的(例如缓慢的 jQuery 淡入)

最后,Backbone.js 应用程序中的许多事件和执行路径是非确定性的。例如,并行网络请求和用户输入可以以任何顺序被应用程序接收。为了处理这些问题,我们有时必须超越实际的程序代码,并在测试期间模拟应用程序的一些部分。有关一些常见的测试限制和模拟动机的深入了解,请参阅 Christian Johansen(Sinon.JS 的创造者)所著的 规划、作弊和模拟通过 JavaScript 测试,可在 msdn.microsoft.com/en-us/magazine/gg649850.aspx 查看。

用于观察和/或替换程序行为的现代技术统称为 测试替身。本书中使用的测试替身包括:

  • 间谍:测试间谍包装要测试的方法并记录输入和输出以供以后使用。然而,它不会更改任何底层方法功能,因为间谍只是一个观察者。测试间谍在需要检查给定函数如何以及何时从应用程序的其他部分被调用的情况下非常有用。

  • 桩(Stubs): 测试桩是一个间谍,它除了替换被测试方法的函数功能外,还添加了新的行为。桩对于测试隔离非常有用。例如,当测试一个通常调用其他函数的单个方法时,我们可以简单地使用预编程的行为“桩化”外部函数调用。这样,测试就可以执行特定的测试代码,同时伪造其他所有内容。

  • 模拟(Mocks): 模拟是间谍和桩的组合(观察函数调用并替换函数行为),在执行期间还验证预期的函数行为。

注意

对于关于测试替身的良好概述,包括我们识别出的三种方法之外的方案,请参阅探索测试替身连续体Exploring The Continuum Of Test Doubles)由马克·西曼Mark Seeman)所著(msdn.microsoft.com/en-us/magazine/cc163358.aspx)以及测试替身模式网页(Test Double Patterns)由杰拉尔德·梅萨罗斯Gerard Meszaros)所著(xunitpatterns.com/Test%20Double%20Patterns.html)。

了解 Sinon.JS

Sinon.JS 是一个流行的测试替身库,它提供了间谍、桩、模拟、假服务器和各种辅助工具。在本章中,我们将介绍两个 Sinon.JS 接口——间谍和沙盒测试辅助工具——并在第五章(example.org/ch05.html "第五章。测试桩和模拟")测试桩和模拟中讨论其余内容。

使用 Sinon.JS 监视函数

Sinon.JS 提供了可扩展的测试间谍,可以记录函数执行的许多不同方面,包括调用参数、返回值和抛出的异常。基本开发者工作流程是创建一个间谍,将其连接到被测试的函数,执行该函数,然后验证间谍记录的信息与测试期望相匹配。在本节中,我们将介绍创建间谍的不同方法,并讨论 Sinon.JS 间谍 API 中一些最有用的部分。

匿名间谍

间谍可以作为匿名独立函数创建,这通常用于测试 Backbone.js 应用程序中的事件逻辑。例如,我们在以下代码中创建了一个 Backbone.js 事件对象和一个匿名 Sinon.JS 间谍。间谍监听 foo 事件,我们触发该事件。然后,我们可以检查间谍并断言间谍被调用了一次,并且传递了 42 作为参数:

it("calls anonymous spy on event", function () {
  var eventer = _.extend({}, Backbone.Events),
    spy = sinon.spy();

  // Set up the spy.
  eventer.on("foo", spy);
  expect(spy.called).to.be.false;

  // Fire event.
  eventer.trigger("foo", 42);

  // Check number of calls.
  expect(spy.calledOnce).to.be.true;
  expect(spy.callCount).to.equal(1);

  // Check calling arguments.
  expect(spy.firstCall.args[0]).to.equal(42);
  expect(spy.calledWith(42)).to.be.true;
});

间谍断言

Sinon.JS 为许多间谍方法和属性提供了 sinon.assert 对象的断言辅助函数。在先前的示例中,我们使用了 Chai 断言来验证间谍记录的信息。但是,我们也可以等效地使用 Sinon.JS 断言,如下所示:

it("verifies anonymous spy on event", function () {
  var eventer = _.extend({}, Backbone.Events),
    spy = sinon.spy();

  eventer.on("foo", spy);
  sinon.assert.notCalled(spy);

  eventer.trigger("foo", 42);
  sinon.assert.callCount(spy, 1);
  sinon.assert.calledWith(spy, 42);
});

sinon.assert 辅助函数与大多数等效的 Chai 断言相比有一个优点,即失败信息是信息性和具体的。例如,对于 sinon.assert.calledWith(spy, 42) 的失败断言会产生错误信息 AssertError: expected spy to be called with arguments 42

函数间谍

Sinon.JS 间谍还可以包装现有的函数。在以下示例中,我们使用间谍包装函数 divide,产生 divAndSpy。然后,我们可以以任何可以用于 divide 的方式调用 divAndSpy。我们还可以检查包装间谍的间谍属性,如 calledWith()

it("calls spy wrapper on function", function () {
  var divide = function (a, b) { return a / b; },
    divAndSpy = sinon.spy(divide);

  // Call wrapped function and verify result.
  expect(divAndSpy(4, 2)).to.equal(2);

  // Now, verify spy properties.
  sinon.assert.calledOnce(divAndSpy);
  sinon.assert.calledWith(divAndSpy, 4, 2);

  // Sinon.JS doesn't have assert for returned.
  expect(divAndSpy.returned(2)).to.be.true;
});

对象方法间谍

最后,Sinon.JS 间谍可以包装对象中的方法。这是在整体类或 Backbone.js 组件中监视一个方法以在整个执行路径中收集信息的特别强大的手段。包装的对象方法包含 Sinon.JS 间谍属性,这意味着我们不需要单独跟踪间谍变量。

包装的对象方法在用 restore() 函数取消包装之前仍然是间谍,该函数移除间谍并恢复原始函数。作为一个例子,让我们考虑以下具有两个方法的对象:

var obj = {
  multiply: function (a, b) { return a * b; },
  error: function (msg) { throw new Error(msg); }
};

我们可以监视 multiply 来验证其调用和返回值,并监视 error 来检查它是否抛出预期的异常。在这两种情况下,我们直接调用包装的对象方法(例如,obj.multiply()),然后访问方法间谍。最后,我们需要在测试结束时调用 restore() 来取消包装 obj 上的间谍:

it("calls spy on wrapped object", function () {
  // Wrap members with `sinon` directly.
  sinon.spy(obj, "multiply");
  sinon.spy(obj, "error");

  expect(obj.multiply(5, 2)).to.equal(10);
  sinon.assert.calledWith(obj.multiply, 5, 2);
  expect(obj.multiply.returned(10)).to.be.true;

  try {
    obj.error("Foo");
  } catch (e) {}
  sinon.assert.threw(obj.error, "Error");

  // Have to restore after tests finish.
  obj.multiply.restore();
  obj.error.restore();
});

使用 Sinon.JS 测试助手在沙盒中玩耍

之前示例规范的问题之一是,如果在调用 restore() 之前断言失败,对象方法仍然被间谍包装。如果任何后续(并且其他方面通过)的测试尝试包装已经包装的方法,Sinon.JS 将抛出错误,例如 TypeError: Attempted to wrap <function name> which is already wrapped>

因此,确保每个间谍最终都调用 restore() 是很重要的,无论底层测试是否通过。实现这一目标的一种方法是在测试中使用 try/finally 块。另一种方法是创建间谍在 before 函数中,并在 after 函数中调用它们上的 restore()。然而,最简单且最灵活的方法可能是使用 sinon.test 沙盒函数。

Sinon.JS 提供了一个称为 沙盒 的执行环境,可以配置间谍、存根、模拟和其他假对象(例如,假定时器和 AJAX 请求)。方便的是,所有伪造的属性和方法都可以通过在沙盒对象上调用单个 restore() 调用来取消包装。

小贴士

高度推荐查看 Sinon.JS 沙盒文档。在沙盒中应用程序执行方式的变化涉及一些微妙的问题和惊喜。例如,默认沙盒会伪造时间和相关函数,如 setTimeout。这意味着如果您的代码在执行前等待 10 毫秒,它将不会在开发者手动推进假 clock 对象的时间之前运行。

sinon.test 包装函数通过创建默认的沙盒,并在包装的代码执行完成后自动恢复,将这一步进一步。使用 sinon.test 重复我们之前的对象方法示例,可以得到一个更优雅的规范版本,其中我们不需要手动在间谍上调用 restore(),同时仍然保证包装的对象方法被恢复:

it("calls spy with test helper", sinon.test(function () {
  // Wrap members using context (`this`) helper.
  this.spy(obj, "multiply");
  this.spy(obj, "error");

  expect(obj.multiply(5, 2)).to.equal(10);
  sinon.assert.calledWith(obj.multiply, 5, 2);
  expect(obj.multiply.returned(10)).to.be.true;

  try {
    obj.error("Foo");
  } catch (e) {}
  sinon.assert.threw(obj.error, "Error");

  // No restore is necessary.
}));

虽然 sinon.test 辅助工具是管理 Sinon.JS 对象的有用工具,但它并不总是适合每个规范。例如,异步 Mocha 测试很棘手,因为 sinon.test 可能会在测试代码中稍后调用 done() 参数之前恢复整个沙盒。此外,使用 sinon.test 的副作用是,Mocha 测试报告器在测试驱动网页中点击规范描述时将不再显示测试代码。这个原因是有道理的——sinon.test 包装了实际的测试函数,因此 sinon.test 是 Mocha 报告器所看到的全部。何时使用 sinon.test 简化方法,最终取决于开发者的直觉和经验。在这本书中,我们使用包装器来处理我们基于 Sinon.JS 的同步规范的一部分子集。

深入了解 Sinon.JS 间谍 API

Sinon.JS 间谍提供了一套相当全面的属性和方法,用于检查执行信息(有关完整列表,请参阅sinonjs.org/docs/#spies)。间谍可以被一般地检查,以查看在执行过程中是否遇到了某个参数或返回值,或者具体地检查单个函数调用的信息。

间谍 API

一套有用的间谍方法和属性包括:

  • spy.callCount(num): 此方法返回被监视函数被调用的次数。这可以作为断言使用,例如sinon.assert.callCount(spy, num)

  • spy.called: 如果函数被调用一次或多次,则此值为true。Sinon.JS 还提供了一些属性来验证特定的调用次数,例如spy.calledOnce。断言版本包括sinon.assert.called(spy)sinon.assert.notCalled(spy)sinon.assert.calledOnce(spy)

  • spy.calledWith*/spy.notCalledWith*: Sinon.JS 提供了可以验证间谍是否有时/总是使用预期参数调用的方法。例如,spy.calledWithExactly(arg1, arg2)检查函数是否一次或多次使用arg1arg2调用。相比之下,spy.alwaysCalledWith(arg1)检查每次函数调用是否都有一个第一个参数arg1以及任意数量的附加参数。

  • spy.returned(obj)/spy.alwaysReturned(obj): 如果函数一次或多次/每次调用都返回了obj,则此方法返回true

Sinon.js 间谍还会记录抛出的异常,可以使用以下方法进行检查:

  • spy.threw(): 如果函数一次或多次抛出异常,则返回 truespy.alwaysThrew() 选项在每次调用都抛出异常时返回 true。两者都可以接受可选的字符串类型(例如,"Error")或实际的错误对象,以要求异常类型匹配。断言版本分别是 sinon.assert.threw(spy)sinon.assert.alwaysThrew(spy)

The spy call API

每次被监视的函数被调用时,Sinon.JS 都会将包含相关信息的 调用对象 存储在一个内部数组中。调用对象在需要检查特定调用,但被监视的函数执行多次的情况下非常有用。

调用对象可以通过多种方式从间谍中访问:

  • spy.getCall(n): 从零索引数组中检索间谍的第 n 个调用对象。例如,spy.getCall(1) 检索间谍函数第二次被调用时的调用对象。

  • spy.firstCallspy.secondCallspy.thirdCallspy.lastCall: 这些是辅助属性,用于访问常用调用对象。

调用对象提供了封装特定函数调用的方法和属性:

  • spyCall.calledOn(obj): 如果 obj 是调用时的上下文(this)变量,则返回 truethis 变量的值也可以直接从 spyCall.thisValue 属性中获取。

  • spyCall.calledWith*/spyCall.notCalledWith*: 这些是间谍调用方法,用于验证是否使用特定参数进行了 单个 调用。它与间谍 API 方法平行,后者检查 所有 函数调用,而不仅仅是单个调用。调用对象还提供了函数被调用时的特定参数,在 spyCall.args 属性中。

  • spyCall.returnValue: 这是包含函数调用返回值的属性。

  • spyCall.threw(): 这会返回 true 如果函数调用抛出了异常。异常对象本身可以通过 spyCall.exception 获取。

使用 Sinon.JS 插件丰富 Chai

使用 Chai 库的主要动机之一是其链式断言的自然语言语法。Chai 的另一个优点是它在断言失败时产生清晰的错误消息。

不幸的是,Sinon.JS 间谍在我们的测试框架中创建了一些断言挑战。为了说明问题,让我们关注之前的例子,该例子断言 obj.multiply() 方法(被间谍包装)以参数 52 被调用。

在这一点上,我们已经遇到了两种在 Sinon.JS 间谍上进行断言的方式——使用 Chai 断言和使用 Sinon.JS 内置的间谍断言。从第一种方法开始,我们可以在间谍上编写一个 Chai 断言,如下所示:

expect(obj.multiply.calledWith(5, 2)).to.be.true;

然而,这个声明的缺点是,如果断言失败,Chai 将产生无用的错误消息 expected false to be true

如果我们使用 Sinon.JS 断言版本,可以得到一个更好的错误消息,AssertError: expected multiply to be called with arguments 5, 2

sinon.assert.calledWith(obj.multiply, 5, 2);

但这样我们就失去了 Chai 点符号语法的自然可读性。

我们真正想要的是一个在出现错误时显示类似expected multiply to have been called with arguments 5, 2的错误消息,并且断言如下所示:

expect(obj.multiply).to.be.calledWith(5, 2);

幸运的是,我们可以通过使用 Chai 的插件功能,获得两个世界的最佳之处——可读的 Chai 断言和具有信息性的库特定错误消息。

引入和安装 Chai 插件

Chai 支持插件(chaijs.com/plugins),这些插件通过上下文有用的更改和错误消息来修改和扩展 Chai 断言 API。在本节中,我们将介绍并安装 Chai 的 Sinon.JS 适配器,为我们提供更简洁且最终更有用的测试替代表述。

注意

推荐使用 Sinon.JS 适配器,但这完全是可选的。尽管我们将在本书的其余部分使用该插件,但我们的所有测试断言都可以使用原生的 Chai 重写为等效语句。

Sinon-Chai插件(chaijs.com/plugins/sinon-chai)可以从 GitHub 下载,地址为raw.github.com/domenic/sinon-chai/2.4.0/lib/sinon-chai.js。目前我们使用的是版本 2.4.0。该文件应放置在我们其他测试库相同的目录(test/js/lib/)中,并在测试驱动网页中与其他库一起包含:

<!-- JavaScript Test Libraries. -->
<script src="img/mocha.js"></script>
<script src="img/chai.js"></script>
<script src="img/sinon-chai.js"></script>
<script src="img/sinon.js"></script>

Sinon-Chai 必须在 Chai 之后包含,并且可以在 Sinon.JS 库之前或之后包含。有了这个额外的包含,我们就准备好开始编写更易读和更有信息的 Chai 断言。

小贴士

其他可能对 Backbone.js 应用程序测试有用的 Chai 插件包括 Backbone.js 适配器(chaijs.com/plugins/chai-backbone)和 jQuery(chaijs.com/plugins/chai-jquery)。Backbone.js 插件为 Backbone.js 特定的结构添加了断言,例如trigger(用于事件)和routes.to(用于路由)。jQuery 插件将各种 jQuery 函数代理到 Chai 断言中,使得可以编写如下语句:expect($text).to.have.html("<em>Edit your note!</em>")

Sinon.JS 插件

Sinon-Chai 插件扩展了 Chai,增加了几个与 spy 相关的断言,包括以下内容:

  • 调用次数expect(spy).to.have.been.calledexpect(spy).to.have.been.calledOnceexpect(spy).to.have.been.calledTwiceexpect(spy).to.have.been.calledThrice

  • 调用顺序expect(spy1).to.have.been.calledAfter(spy2)expect(spy1).to.have.been.calledBefore(spy2)

  • 调用参数expect(spy).to.have.been.calledWithNewexpect(spy).to.have.been.calledOn(context)expect(spy).to.have.been.calledWith(arg1, arg2, ...)expect(spy).to.have.been.calledWithExactly(arg1, arg2, ...),以及 expect(spy).to.have.been.calledWithMatch(arg1, arg2, ...)

  • 返回值expect(spy).to.have.returned(returnVal)

  • 错误expect(spy).to.have.thrown()

该插件还添加了一个新的断言标志:

  • always:它表示间谍函数必须通过 每个 函数调用的断言,而不仅仅是单个或多个函数调用。例如,我们可以将以下检查 任何 函数调用的断言转换为检查 每个 调用的断言:

    expect(spy).to.always.have.been.calledWith(arg1, arg2, ...);
    expect(spy).to.have.always.returned(returnVal);
    expect(spy).to.have.always.thrown();
    

现在,我们可以使用 Sinon-Chai 断言重写我们之前的一个测试示例,如下所示:

it("calls spy with chai plugin", sinon.test(function () {
  this.spy(obj, "multiply");
  this.spy(obj, "error");

  expect(obj.multiply(5, 2)).to.equal(10);
  expect(obj.multiply).to.have.been.calledWith(5, 2);
  expect(obj.multiply).to.have.returned(10);

  try { obj.error("Foo"); } catch (e) {}
  expect(obj.error).to.have.thrown("Error");
}));

在之前的重构测试中,任何间谍断言失败都会产生如expected multiply to have been called with arguments 5, 2之类的信息。因此,Sinon-Chai 插件允许我们在 Chai 的链式点号格式中保留间谍断言,同时生成有用的失败信息。

使用间谍测试 Backbone.js 组件

在我们的 Sinon.JS 间谍和其他实用工具准备就绪后,我们将开始监视我们的 Backbone.js 应用程序。在本节中,我们将介绍并测试两个笔记应用程序视图——菜单栏视图和单个笔记视图。

小贴士

通过示例进行操作

重申前一章的观点,我们将首先展示菜单栏视图和单个笔记视图的实现代码,然后是测试代码,以帮助维持一个正确介绍笔记应用程序的叙述结构(并使内容简短)。这并不是实际测试开发的首选顺序。

因此,在阅读每个组件描述的行为后,我们建议您暂时放下这本书,尝试为示例应用程序组件设计并实现自己的测试。完成这个练习后,您可以继续阅读,并将您的测试与本章中的组件测试套件进行比较。

笔记菜单栏视图

笔记菜单栏视图 App.Views.NoteNav 控制单个笔记的 编辑查看删除 菜单栏按钮。以下截图展示了带有活动 查看 按钮的菜单栏。

笔记菜单栏视图

单页菜单栏视图

App.Views.NoteNav 视图协调视图、编辑和删除菜单操作的事件。例如,如果在之前的图中点击了 编辑 按钮,App.Views.NoteNav 视图将发出以下自定义 Backbone.js 事件:

  • nav:update:edit:这会导致活动 HTML 菜单栏项切换到新的选定操作,例如,从 查看 切换到 编辑

  • nav:edit:这个事件被触发以向其他 Backbone.js 组件发出操作动作(例如,视图或编辑)已更改的信号。例如,App.Views.Note 视图监听此事件,并在其视图区域显示相应动作面板的 HTML。

菜单栏视图附加到 DOM 列表 #note-nav,这是由 notes/app/index.html 应用页面提供的。#note-nav 的 HTML 可以简化为以下基本部分:

<ul id="note-nav"
  class="nav region region-note"
  style="display: none;">
  <li class="note-view active">View</li>
  <li class="note-edit">Edit</li>
  <li class="note-delete">Delete</li>
</ul>

菜单栏列表默认隐藏(但由 App.Views.Note 显示)。实例化后,App.Views.NoteNav 视图设置各种监听器并激活适当的菜单栏项。

菜单栏视图

现在我们已经审查了视图的显示设置和整体功能,我们可以深入研究 notes/app/js/app/views/note-nav.js 中的应用代码:

App.Views.NoteNav = Backbone.View.extend({

  el: "#note-nav",

在指定默认 el 元素以附加视图之后,视图将用户菜单栏点击绑定到适当的动作(例如,编辑)在 events 中,并在 initialize 中设置监听器以在发生外部事件时更新菜单栏。

  events: {
    "click .note-view":   "clickView",
    "click .note-edit":   "clickEdit",
    "click .note-delete": "clickDelete",
  },

  initialize: function () {
    // Defaults for nav.
    this.$("li").removeClass("active");

    // Update the navbar UI for view/edit (not delete).
    this.on({
      "nav:update:view": this.updateView,
      "nav:update:edit": this.updateEdit
    });
  },

函数 updateViewupdateEdit 切换 active CSS 类,这会在菜单栏中视觉上改变高亮的标签:

  updateView: function () {
    this.$("li").not(".note-view").removeClass("active");
    this.$(".note-view").addClass("active");
  },
  updateEdit: function () {
    this.$("li").not(".note-edit").removeClass("active");
    this.$(".note-edit").addClass("active");
  },

函数 clickViewclickEditclickDelete 发出与菜单栏动作对应的视图事件:

  clickView: function () {
    this.trigger("nav:update:view nav:view");
    return false;
  },
  clickEdit: function () {
    this.trigger("nav:update:edit nav:edit");
    return false;
  },
  clickDelete: function () {
    this.trigger("nav:update:delete nav:delete");
    return false;
  }
});

测试和监视菜单栏视图

App.Views.NoteNav 视图相当小,本质上只是代理事件并更新菜单栏 UI。我们的测试目标同样简单:

  • 验证 App.Views.NoteNav 是否正确绑定到 DOM,无论是默认为 #note-nav 还是通过传递的 el 参数

  • 检查菜单栏动作事件是否被正确触发和监听

  • 确保菜单栏 HTML 在适当动作下被修改

在这些指南的指导下,让我们逐步审查 chapters/04/test/js/spec/views/note-nav.spec.js,这是菜单栏视图的套件。

该套件首先设置一个测试固定装置和视图。before() 调用创建了我们为生成适合测试视图的菜单栏列表所需的最低限度的 HTML。beforeEach() 函数将 this.$fixture 附带到 DOM 中已存在的 #fixtures 容器,并创建一个新的 App.Views.NoteNav 对象。afterEach() 调用移除视图,after() 完全清空 #fixtures 容器:

describe("App.Views.NoteNav", function () {
  before(function () {
    this.$fixture = $(
      "<ul id='note-nav'>" +
        "<li class='note-view'></li>" +
        "<li class='note-edit'></li>" +
        "<li class='note-delete'></li>" +
      "</ul>"
    );
  });

  beforeEach(function () {
    this.$fixture.appendTo($("#fixtures"));
    this.view = new App.Views.NoteNav({
      el: this.$fixture
    });
  });

  afterEach(function () {
    this.view.remove();
  });

  after(function () {
    $("#fixtures").empty();
  });

第一个嵌套套件 events 包含一个规格,验证点击菜单栏项是否触发适当的 nav:*nav:update:* 事件。我们创建三个 Sinon.JS 监视器来帮助我们完成这项任务:

  • navSpyupdateSpy:这些对象监视 nav:viewnav:update:view 事件,应该在点击 视图 菜单栏项时被调用

  • otherSpy:这个监视器监听所有其他潜在的动作事件,并用于检查其他事件是否没有触发

我们使用 Sinon-Chai 适配器扩展来制作我们的监视器断言:

  describe("events", function () {
    it("fires events on 'view' click", function () {
      var navSpy = sinon.spy(),
        updateSpy = sinon.spy(),
        otherSpy = sinon.spy();

      this.view.on({
        "nav:view": navSpy,
        "nav:update:view": updateSpy,
        "nav:edit nav:update:edit": otherSpy,
        "nav:delete nav:update:delete": otherSpy
      });

      this.$fixture.find(".note-view").click();

      expect(navSpy).to.have.been.calledOnce;
      expect(updateSpy).to.have.been.calledOnce;
      expect(otherSpy).to.not.have.been.called;
    });
  });

menu bar display 套件中的规范检查 DOM 内容和页面与视图的交互。第一个规范 has no active navs by default 检查菜单栏 HTML 默认没有活动选择——对于一个基于 Bootstrap 的导航栏,这意味着没有 active CSS 类:

  describe("menu bar display", function () {
    it("has no active navs by default", function () {
      // Check no list items are active.
      expect(this.view.$("li.active")).to.have.length(0);

      // Another way - manually check each list nav.
      expect($(".note-view")
        .attr("class")).to.not.include("active");
      expect($(".note-edit")
        .attr("class")).to.not.include("active");
      expect($(".note-delete")
        .attr("class")).to.not.include("active");
    });

然后,剩余的规范检查点击编辑菜单栏标签或触发直接 nav:update:edit 事件是否会导致相应的菜单栏项被激活(通过插入 CSS 类 active):

    it("updates nav on 'edit' click", function () {
      $(".note-edit").click();
      expect($(".note-edit").attr("class")).to.include("active");
    });

    it("updates nav on 'edit' event", function () {
      this.view.trigger("nav:update:edit");
      expect($(".note-edit").attr("class")).to.include("active");
    });
  });
});

通过之前的测试,我们可以验证 App.Views.NoteNav 触发适当的事件,并且其 HTML 对用户点击和外部事件做出响应。

笔记单个笔记视图

App.Views.Note 视图控制了我们迄今为止遇到的关于单个笔记的所有内容。每个 App.Views.Note 对象实例化一个新的 App.Views.NoteView 对象,并引用外部的 App.Views.NoteNav 对象。

类的主要职责,我们将在测试中验证,包括以下内容:

  • 响应菜单栏操作事件更新适当的查看面板模式(例如,编辑或查看)

  • 删除单个笔记模型,然后清理视图并路由回所有笔记列表视图

  • 在删除笔记之前要求用户确认

  • 响应编辑表单字段变化,将笔记模型数据保存到后端存储

  • 响应模型数据变化更新 HTML 显示面板

我们首先查看视图使用的 HTML 模板字符串。它在我们的应用程序模板文件 notes/app/js/app/templates/templates.js 中找到:

App.Templates["template-note"] =
  "<div id=\"note-pane-view\" class=\"pane\">" +
  "  <div id=\"note-pane-view-content\"></div>" +
  "</div>" +
  "<div id=\"note-pane-edit\" class=\"pane\">" +
  "  <form id=\"note-form-edit\">" +
  "    <input id=\"input-title\" class=\"input-block-level\"" +
  "           type=\"text\" placeholder=\"title\"" +
  "           value=\"<%= title %>\">" +
  "    <textarea id=\"input-text\" class=\"input-block-level\"" +
  "              rows=\"15\"><%= text %></textarea>" +
  "  </form>" +
  "</div>";

模板提供了两个 div UI 面板用于操作模式——note-pane-view 用于 查看 笔记和 note-pane-edit 用于 编辑 数据。它还绑定两个模板变量——titletext——到 note-form-edit 表单中的编辑输入。

单个笔记视图

进入应用程序代码 notes/app/js/app/views/note-nav.js,我们首先声明 DOM 标识符和模板,然后设置两个事件——第一个事件在浏览器发生 blur 事件时保存笔记数据,第二个事件防止编辑表单执行真正的 HTTP 页面提交:

App.Views.Note = Backbone.View.extend({

  id: "note-panes",

  template: _.template(App.Templates["template-note"]),

  events: {
    "blur   #note-form-edit": "saveNote",
    "submit #note-form-edit": function () { return false; }
  },

initialize 函数为视图做了大部分繁重的工作。首先,它从参数选项设置 this.nav,从选项或从外部的 app 应用程序对象设置 this.router

注意

我们从 opts 参数中可选地获取路由对象的原因是它使得覆盖 Backbone.js 依赖项变得更容易。在我们的测试中,我们将使用 opts 传递一个 Sinon.JS 间谍而不是一个记录行为但不实际路由的真实路由器。对此场景的另一种方法(在下一章中介绍)是直接模拟或存根 app.router

然后,视图通过调用辅助函数 _addListeners 在各种对象上设置事件监听器。最后,视图对象使用模型数据将 Underscore.js 模板渲染为 HTML,设置动作状态,并实例化一个子 App.Views.NoteView 对象以处理 Markdown 渲染:

initialize: function (attrs, opts) {
    opts || (opts = {});
    this.nav = opts.nav;
    this.router = opts.router || app.router;

    // Add our custom listeners.
    this._addListeners();

    // Render HTML, update to action, and show note.
    this.$el.html(this.template(this.model.toJSON()));
    this.update(opts.action || "view");
    this.render();

    // Add in viewer child view (which auto-renders).
    this.noteView = new App.Views.NoteView({
      el: this.$("#note-pane-view-content"),
      model: this.model
    });
  },

作为初始化的一部分,_addListeners 辅助函数将对象事件绑定如下:

  • 模型 (this.model):当模型被销毁时,视图会移除自己。当模型数据更改时,它会重新渲染并将模型保存到后端。

  • 菜单栏视图 (this.nav):笔记视图监听菜单栏导航事件,并在用户点击 查看 时调用特定的动作函数,如 viewNote()

  • 笔记视图 (this):笔记视图还直接监听来自外部 Backbone.js 组件的动作状态(查看或编辑)事件。例如,应用程序路由器使用这些事件激活现有的 App.Views.Note 视图对象并设置适当的动作状态。

将此转换为代码产生以下函数:

  _addListeners: function () {
    // Model controls view rendering and existence.
    this.listenTo(this.model, {
      "destroy": function () { this.remove(); },
      "change":  function () { this.render().model.save(); }
    });

    // Navbar controls/responds to panes.
    this.listenTo(this.nav, {
      "nav:view":   function () { this.viewNote(); },
      "nav:edit":   function () { this.editNote(); },
      "nav:delete": function () { this.deleteNote(); }
    });

    // Respond to update events from router.
    this.on({
      "update:view": function () { this.render().viewNote(); },
      "update:edit": function () { this.render().editNote(); }
    });
  },

render() 函数显示单个笔记视图的 HTML,并隐藏其他视图使用的任何 HTML 内容:

  // Rendering the note is simply showing the active pane.
  // All HTML should already be rendered during initialize.
  render: function () {
    $(".region").not(".region-note").hide();
    $(".region-note").show();
    return this;
  },

remove() 方法首先移除包含的 App.Views.NoteView 对象,然后是 App.Views.Note 对象本身:

  remove: function () {
    // Remove child, then self.
    this.noteView.remove();
    Backbone.View.prototype.remove.call(this);
  },

update() 方法接受一个动作字符串参数("view""edit"),然后触发菜单栏视图更新到新状态,显示适当的 HTML 动作面板,并更新 URL 案件片段:

  update: function (action) {
    action = action || this.action || "view";
    var paneEl = "#note-pane-" + action,
      loc = "note/" + this.model.id + "/" + action;

    // Ensure menu bar is updated.
    this.nav.trigger("nav:update:" + action);

    // Show active pane.
    this.$(".pane").not(paneEl).hide();
    this.$(paneEl).show();

    // Store new action and navigate.
    if (this.action !== action) {
      this.action = action;
      this.router.navigate(loc, { replace: true });
    }
  },

接下来的三个方法——viewNote()editNote()deleteNote()——处理单个笔记的基本动作。前两个方法只是使用适当的动作调用 update(),而 deleteNote() 则销毁笔记模型并路由回所有笔记列表(即应用程序主页):

  viewNote: function () {
    this.update("view");
  },
  editNote: function () {
    this.update("edit");
  },
  deleteNote: function () {
    if (confirm("Delete note?")) {
      this.model.destroy();
      this.router.navigate("", { trigger: true, replace: true });
    }
  },

最后,saveNote() 接收编辑表单输入并更新底层的笔记模型:

  saveNote: function () {
    this.model.set({
      title: this.$("#input-title").val().trim(),
      text: this.$("#input-text").val().trim()
    });
  }
});

测试单个笔记视图

我们对 App.Views.Note 的测试集中在我们在介绍视图时讨论的类的各种职责。具体来说,我们想要验证笔记视图可以更新动作(例如查看和编辑)的 UI 元素,删除笔记,保存模型数据,并在各种其他 Backbone.js 应用程序组件之间正确绑定事件。

chapters/04/test/js/spec/views/note.spec.js 的单个笔记测试套件中,我们首先创建一个初始测试状态。在套件范围的 before() 函数中,我们添加了区域(其中 App.Views.Note 使用 region-note)的固定元素,为视图本身添加了 HTML 固定元素,然后存根笔记模型原型的 save() 方法。

笔记

尽管 Sinon.JS 存根在本章中未完全介绍,但我们在这里使用它来记录对 save() 的调用,就像间谍一样,并防止该方法尝试将数据保存到远程后端,这在本测试上下文中会引发错误。

describe("App.Views.Note", function () {

  before(function () {
    // Regions for different views.
    $("#fixtures").append($(
      "<div class='region-note' style='display: none;'></div>" +
      "<div class='region-notes' style='display: none;'></div>"
    ));

    // App.Views.Note fixture.
    this.$fixture = $(
      "<div id='note-fixture region-note'>" +
        "<div id='#note-pane-view-content'></div>" +
      "</div>"
    );

    // Any model changes will trigger a `model.save()`, which
    // won't work in the tests, so we have to fake the method.
    sinon.stub(App.Models.Note.prototype, "save");
  });

beforeEach()设置方法中,我们将视图固定装置附加到固定装置容器,并创建一个用来替换我们真实 Backbone.js 路由的间谍函数。然后,我们创建一个App.Views.Note对象,并将固定装置和一个新的App.Models.Note绑定到它。我们还向App.Views.Note实例提供了两个初始化选项:

  • nav:我们传递一个原始的Backbone.View对象作为菜单栏视图的替代品,通过它代理事件,同时省略了真实的视图逻辑和 DOM 交互

  • router:我们传递this.routerSpy以记录 Backbone.js 路由事件,而实际上并不改变我们的浏览器历史/URL 状态

    beforeEach(function () {
        this.routerSpy = sinon.spy();
        this.$fixture.appendTo($("#fixtures"));
    
        this.view = new App.Views.Note({
          el: this.$fixture,
          model: new App.Models.Note()
        }, {
          nav: new Backbone.View(),
          router: {
            navigate: this.routerSpy
          }
        });
      });
    

值得注意的是,我们将四个视图依赖项(elmodelnavrouter)注入到App.Views.Note中,以帮助隔离实例并使其可测试。在这种配置下,我们套件中的规范可以被认为是部分集成测试,因为我们正在使用(并测试)真实的 Backbone.js 对象,而不仅仅是测试下的视图。

关于之前的设置,另一个观察结果是navrouter选项参数是特意选择的,以避免触发完整应用程序的真实行为;例如,操作菜单栏 DOM 或更改浏览器的 URL。正如我们将在第五章中学习到的,“测试存根和模拟”,这种行为替换可以通过 Sinon.JS 存根或模拟更简洁、更适当地完成。

接下来进行测试拆解,在afterEach()中,我们清除测试固定装置并删除任何仍然存在的视图对象。(规范可能已经销毁了测试视图对象。)最后,在套件末尾的after()中,我们清除顶级固定装置容器并恢复App.Models.Note类的save()方法到其原始状态:

  afterEach(function () {
    this.$fixture.empty();
    if (this.view) { this.view.model.destroy(); }
  });

  after(function () {
    $("#fixtures").empty();
    App.Models.Note.prototype.save.restore();
  });

我们的设置/拆解完成,接下来进入第一个嵌套测试套件,视图模式和操作,该套件验证用户 DOM 交互和 Backbone.js 事件可以控制笔记视图,并使其在编辑、查看和删除模式之间切换:

  describe("view modes and actions", function () {

默认情况下,App.Views.Note视图路由到 URL 哈希片段#note/:id/view并显示查看模式 HTML。我们使用我们的路由间谍来验证使用 Sinon-Chai calledWithMatch扩展调用的哈希片段的后缀。然后,我们通过简单的 CSS display属性检查断言只有查看面板#note-pane-view是可见的:

    it("navigates / displays 'view' by default", function () {
      expect(this.routerSpy).to.be.calledWithMatch(/view$/);

      // Check CSS visibility directly. Not necessarily a best
      // practice as it uses internal knowledge of the DOM, but
      // gets us a quick check on what should be the visible
      // view pane.
      expect($("#note-pane-view")
        .css("display")).to.not.equal("none");
      expect($("#note-pane-edit")
        .css("display")).to.equal("none");
    });

下一个规范触发update:edit事件,然后验证这会将 URL 哈希片段更改为#note/:id/edit并显示编辑面板:

    it("navigates / displays 'edit' on event", function () {
      this.view.trigger("update:edit");
      expect(this.routerSpy).to.be.calledWithMatch(/edit$/);

      expect($("#note-pane-edit")
        .css("display")).to.not.equal("none");
      expect($("#note-pane-view")
        .css("display")).to.equal("none");
    });

我们通过模拟confirm()弹出窗口始终返回false(防止实际笔记删除)并调用deleteNote()来测试笔记删除行为。我们需要这个模拟来防止在测试运行期间实际弹出浏览器确认窗口。然后,我们使用存根的间谍属性来验证confirm()是否被正确调用:

    it("confirms note on delete", sinon.test(function () {
      this.stub(window, "confirm").returns(false);
      this.view.deleteNote();
      expect(window.confirm)
        .to.have.been.calledOnce.and
        .to.have.been.calledWith("Delete note?");
    }));
  });

下一个测试套件model interaction包含一个规范,验证模型的删除会导致App.Views.Note对象及其包含的App.Views.NoteView对象移除。因此,我们在两个视图的remove()方法上设置了间谍。

小贴士

一旦不再使用视图、模型等,未能清理它们可能导致内存泄漏,这可能会对整体应用程序性能产生重大影响。在底层笔记模型被销毁时触发App.Views.NoteApp.Views.NoteView对象移除是回收 Backbone.js 应用程序各个组件使用内存的一种方式。

同时,还有许多其他技术可以用来控制内存。僵尸!跑!(lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/)和Backbone.js 与 JavaScript 垃圾回收(lostechies.com/derickbailey/2012/03/19/backbone-js-and-javascript-garbage-collection/)是Derick Bailey发表的帖子,提供了对 Backbone.js 内存管理问题和解决方案的极佳介绍。

  describe("model interaction", function () {
    afterEach(function () {
      // Wipe out to prevent any further use.
      this.view = null;
    });

    it("is removed on destroyed model", sinon.test(function () {
      this.spy(this.view, "remove"),
      this.spy(this.view.noteView, "remove");

      this.view.model.trigger("destroy");

      expect(this.view.remove).to.be.calledOnce;
      expect(this.view.noteView.remove).to.be.calledOnce;
    });
  });

最后一个嵌套测试套件note rendering检查模型数据是否正确渲染到 HTML,以及渲染是否在预期应用程序事件响应时触发。第一个规范可以渲染笔记验证render()显示了适当的 HTML 区域元素并隐藏了其余部分:

  describe("note rendering", function () {

    it("can render a note", function () {
      // Don't explicitly call `render()` because
      // `initialize()` already called it.
      expect($(".region-note")
        .css("display")).to.not.equal("none");
      expect($(".region-notes")
        .css("display")).to.equal("none");
    });

接下来的两个规范检查render()方法是否在适当的变化时被触发。在模型事件上调用 render的规范验证每当模型更改时都会调用render()

    it("calls render on model events", sinon.test(function () {
      // Spy on `render` and check call/return value.
      this.spy(this.view, "render");

      this.view.model.trigger("change");

      expect(this.view.render)
        .to.be.calledOnce.and
        .to.have.returned(this.view);
    }));

最终规范修改了单个笔记编辑表中的数据,就像用户操作一样,然后触发blur事件以强制模型更改事件。规范监视render()方法,并检查渲染的 Markdown HTML 是否已更新以反映新数据:

    it("calls render on changed data", sinon.test(function () {
      this.spy(this.view, "render");

      // Replace form value and blur to force changes.
      $("#input-text").val("# A Heading!");
      $("#note-form-edit").blur();

      // `Note` view should have rendered.
      expect(this.view.render)
        .to.be.calledOnce.and
        .to.have.returned(this.view);

      // Check the `NoteView` view rendered the new markdown.
      expect($("#pane-text").html())
        .to.match(/<h1 id=".*?">A Heading!<\/h1>/);
    }));
  });
});

在这个套件的所有规范中,我们增加了对App.Views.Note类能够发出/监听适当事件、在模型删除时清理应用程序对象以及其他我们之前确定的视图核心职责的信心。

连接和运行视图测试

现在我们有了App.Views.NoteNavApp.Views.Note的测试套件,让我们连接测试驱动页面chapters/04/test/test.html。我们可以重用chapters/03/test/test.html中的相同代码,其中有一些(在下述代码中突出显示)差异,添加了 Sinon-Chai 插件、更多的 Notes 应用程序库以及我们的新规范文件:

<head>
  <!-- ... snipped ... -->

  <!-- JavaScript Test Libraries. -->
  <script src="img/mocha.js"></script>
  <script src="img/chai.js"></script>
  <script src="img/sinon-chai.js"></script>
  <script src="img/sinon.js"></script>

  <!-- JavaScript Core Libraries -->
  <!-- ... snipped ... -->

  <!-- JavaScript Application Libraries -->
  <script src="img/namespace.js"></script>
  <script src="img/config.js"></script>
  <script>
    // Test overrides (before any app components).
    App.Config = _.extend(App.Config, {
      storeName: "notes-test" // localStorage for tests.
    });
  </script>
  <script src="img/note.js"></script>
  <script src="img/notes.js"></script>
  <script src="img/templates.js"></script>
  <script src="img/note-nav.js"></script>
  <script src="img/note-view.js"></script>
  <script src="img/note.js"></script>

  <!-- ... snipped ... -->

  <!-- Tests. -->
  <script src="img/note-nav.spec.js"></script>
  <script src="img/note.spec.js"></script>
</head>

我们可以通过打开浏览器到chapters/04/test/test.html来运行测试。(注意,代码示例包含本章节为简洁而省略的附加规范)。

连接并运行视图测试

测试报告

摘要

我们在本章中介绍了 Sinon.JS 测试库,并学习了如何在各种测试场景中集成间谍。我们研究了 Chai 的插件架构,并使用 Sinon-Chai 适配器编写了更好的测试间谍断言。我们为菜单栏和单个笔记 Backbone.js 视图编写了规范,完成了笔记应用单个笔记部分的测试。

下一章将继续探索 Sinon.JS,通过研究存根、模拟和其他有用的假测试对象。我们将使用这些工具来完善我们的 Backbone.js 应用程序测试集合,并完善使用 Mocha、Chai 和 Sinon.JS 编写测试的基础知识。

第五章。测试存根和模拟

随着 Sinon.JS 集成到我们的测试基础设施中,我们现在对 Backbone.js 应用程序中的方法和动作有了更深入的了解。当我们转向测试剩余的应用程序组件时,我们将超越在测试期间仅仅 观察 方法,并实际上 替换 方法行为。

Sinon.JS 在这方面也为我们提供了保障——该库为功能行为修改提供了坚实的支持。具体来说,我们可以利用其强大的存根和模拟抽象来减少 Backbone.js 组件的依赖性和测试期间的跨应用程序副作用。在本章中,我们将通过以下主题来探索这些和其他 Sinon.JS 功能:

  • 使用 Sinon.JS 存根替换函数行为并在测试中隔离 Backbone.js 组件

  • 介绍 Sinon.JS 模拟,它以单个抽象来监视、存根和验证应用程序行为

  • 为我们 Backbone.js 应用程序的剩余组件编写测试,并决定给定测试场景中适当的 Sinon.JS 工具

  • 调查其他上下文有用的 Sinon.JS 测试辅助工具

  • 在 Backbone.js 集合测试中伪造远程后端服务器

使用 Sinon.JS 存根替换方法行为

到目前为止,我们已经能够通过巧妙的设计类和一些手动伪造来处理我们的 Backbone.js 测试依赖项。然而,我们很快就会达到一个需要更可靠和一致的方法的点。

我们将寻求使用存根提供一种简单直接且可预测的方法,以在任何 Backbone.js 组件中替换方法行为,从而减少意外的应用程序副作用和依赖问题。在测试期间临时替换现有方法的能力提供了巨大的灵活性,尤其是在以下情况下:

  • Backbone.js 应用程序处于早期开发阶段,并非所有计划中的组件都已存在。存根允许我们在测试中编写缺失功能的模拟等效代码,这些代码在真实应用程序代码编写后可以删除。即使应用程序代码已经实现,存根仍然可能适用于原始规范的一部分,具体取决于正在测试的行为类型。

  • 应用程序代码对 UI 和/或其他事件的时机敏感。

  • 应用程序依赖于外部资源,如数据库或云服务。

  • Backbone.js 组件具有过于复杂,无法手动在测试中交换的应用程序依赖性和/或交互。

开始使用存根

为了开始,让我们将一些存根集成到上一章中的对象字面量中:

var obj = {
  multiply: function (a, b) { return a * b; },
  error: function (msg) { throw new Error(msg); }
};

在接下来的规范中,我们将向您展示两种不同的方法使用 Sinon.JS 占位 obj.multiply()。在第一次调用(sinon.stub(obj, "multiply").returns(5))中,我们使用 returns 方法始终返回一个硬编码的值。第二个占位符采用不同的方法,插入一个替换函数(而不是相乘)。在两种情况下,测试完成后都调用 restore() 以防止 obj 被永久修改:

it("stubs multiply", function () {
  // Stub with a hard-coded return value.
  sinon.stub(obj, "multiply").returns(5);
  expect(obj.multiply(1, 2)).to.equal(5);
  obj.multiply.restore();

  // Stub with a function.
  sinon.stub(obj, "multiply", function (a, b) {
    return a + b;
  });
  expect(obj.multiply(1, 2)).to.equal(3);
  obj.multiply.restore();
});

将注意力转向接下来的代码片段中的 obj.error(),我们在对象方法上创建一个空的占位符以防止真实函数抛出异常。我们不需要替换函数或 returns 值,因为我们只想 避免 默认行为。此外,我们使用 sinon.test 沙盒助手来自动在测试函数内部创建的任何占位符上调用 restore()

it("stubs error", sinon.test(function () {
  this.stub(obj, "error");
  expect(obj.error).to.not.throw();
}));

如前述代码片段所示,我们现在可以轻松地用不同的代码和/或返回值替换任意方法。

占位 API

Sinon.JS 占位符实现了整个间谍 API,并提供了一些额外的方法,可以在测试期间用新代码和行为替换现有的应用程序函数。占位的第一步是创建一个占位对象,并可能替换一个或多个对象方法:

  • sinon.stub(): 这创建了一个没有指定行为的匿名占位符。

  • sinon.stub(obj, methodName): 这用一个空函数占位单个对象的方法。这本身就足以替换我们看到的 obj.error() 代码中的底层代码执行。或者,您还可以进一步调用占位 API 方法来修改占位符的返回、回调或其他行为。

  • sinon.stub(obj, methodName, fn): 这使用在 fn 参数中提供的替换函数占位单个对象的方法。

  • sinon.stub(obj): 这将用占位符替换对象中的 所有 方法。

一旦我们有了占位对象,我们就可以根据给定的测试情况添加相应的伪造行为和响应。其中一些方法适用于同步(非回调)函数响应:

  • stub.returns(obj): 当调用时,这个占位符将返回值 obj

  • stub.throws(): 当调用时,这个占位符将抛出一个 Error 对象 异常。如果 throws() 被带有类型字符串(例如,"TypeError")或错误对象(例如,new TypeError())调用,将使用特定的错误。

Sinon.JS 还支持在占位方法中异步回调:

  • stub.yields(arg1, arg2, ...): 占位方法的第一参数必须是一个回调函数,占位符将使用参数 arg1arg2 等调用该函数。在下面的代码片段中,我们将占位 obj.async() 并使用 yield() 将伪造的参数 12 注入回调:

    it("stubs with yields", function (done) {
      var obj = {
        async: function (callback) { callback("a", "b"); }
      };
    
      sinon.stub(obj, "async").yields(1, 2);
    
      // Verify stub calls with (1, 2), *not* ("a", "b").
      obj.async(function (first, second) {
        expect(first).to.equal(1);
        expect(second).to.equal(2);
    
        obj.async.restore();
        done();
      });
    });
    
  • stub.yieldsOn(context, arg1, arg2, ...): 这与 stub.yields() 等效,除了它还会在调用回调时将 context 参数作为特殊变量 this 注入。

  • stub.yieldsTo(property, arg1, arg2, ...): 这与 stub.yields() 方法类似,但期望的底层方法的回调是一个具有与 property 参数值匹配的属性名的对象。

  • stub.yieldsToOn(property, context, arg1, arg2, ...): 这是 stub.yieldsOn()stub.yieldsTo() 的组合,它使用一个对象回调属性和上下文变量。

  • stub.callsArgWith(index, arg1, arg2, ...): stub.yields* 方法集合利用了被模拟方法的第一个参数。然而,异步回调可能发生在其他参数位置。stub.callsArgWith() 方法允许我们指定要使用的回调参数的索引以及传递给函数的参数。

  • stub.callsArg*: 除了 stub.callsArgWith() 方法外,stub.callsArg(index)stub.callsArgOn(index, context)stub.callsArgOnWith(index, context, arg1, arg2, ...) 方法都接受一个名为 index 的第一个参数,该参数指定了在包装方法中要调用的回调函数的索引,并且它们的工作方式与之前提到的 yields* 对应方法类似。

这组桩功能足以覆盖大多数 Backbone.js 测试场景。同时,回顾完整的 Sinon.JS 桩 API 文档(sinonjs.org/docs/#stubs)以了解额外的方法和辅助工具是值得的。

使用 Sinon.JS 模拟进行行为伪造和验证

本书将要介绍的最后一个测试双重抽象是测试模拟(mock)。模拟可以替换桩(stubs)的功能,像间谍(spies)和桩一样观察方法调用,并且还可以验证函数行为。本质上,模拟是伪造和测试方法的“一站式”解决方案。

决定何时使用模拟

那么,我们应该在何时使用模拟?Sinon.JS 模拟 API 文档(sinonjs.org/docs/#mocks)从模拟的适当使用案例开始:

“模拟应该仅用于测试中的方法。在每次单元测试中,应该有一个单元正在被测试。如果你想控制你的单元是如何被使用的,并且喜欢提前声明期望(而不是事后断言),那么请使用模拟。”

文档警告说,在许多情况下应避免使用模拟:

“模拟内置了可能会使你的测试失败的期望。因此,它们强制实施实现细节。一般来说,如果你不会为某个特定的调用添加断言,那么不要模拟它。相反,使用桩。通常,你不应该在单个测试中拥有超过一个模拟(可能包含多个期望)。”

本书倾向于使用 Sinon.JS 模拟桩(stubs)的原因是前面讨论的,以及以下原因:

  • Chai 和 Sinon-Chai 适配器库允许我们编写简洁、表达性强且易于阅读的桩测试断言。

  • Sinon.JS 模拟期望 API 比使用 Chai 断言对桩 API 的使用灵活性低。

同时,模拟也是存根。因此,测试可以将预编程的 Sinon.JS 模拟期望与随后的 Chai 存根断言混合匹配。最终,在我们完成本章中存根和模拟的细节审查后,我们将抽象的选择留给开发者和具体的测试场景。

模拟 API

Sinon.JS 模拟(sinonjs.org/docs/#mocks-api)实现了间谍和存根 API,并额外提供了验证应用程序行为的期望。我们将从对核心模拟方法的简要讨论开始:

  • sinon.mock(obj): 这个方法模拟obj的所有方法,并返回一个模拟对象

  • mock.expects(methodName): 这个方法为模拟对象的指定方法创建一个期望

  • mock.verify(): 这个方法检查并验证是否所有期望都得到了满足,在断言失败时抛出异常

  • mock.restore(): 这个方法撤销并移除正在测试的底层对象的所有模拟修改

在我们模拟了一个对象之后,通常的工作流程是调用一个或多个方法的mock.expects(),并为后续的mock.verify()调用配置期望。对于完整的期望列表,请参阅sinonjs.org/docs/#expectations。一些有用的期望方法包括以下内容:

  • expectation.atLeast(num), expectation.atMost(num), 和 expectation.exactly(num): 这些模拟方法应该分别至少/最多/恰好被调用num

  • expectation.never(), expectation.once(), expectation.twice(), 和 expectation.thrice(): 这些是用于指定模拟方法被调用次数的常用断言辅助工具

  • expectation.withArgs(arg1, arg2, ...)expectation.withExactArgs(arg1, arg2, ...): 每次对模拟方法的调用至少/恰好包含期望中指定的参数

  • expectation.on(obj): 这个模拟方法应该以obj作为上下文(this)变量被调用

  • expectation.verify(): 这个方法对特定的期望运行断言(与mock.verify()不同,后者确认所有期望)

在下面的代码片段中,我们在obj周围创建我们的mock对象,并声明期望multiply将被调用两次到四次,并且每次调用的第一个参数将是2。然后我们使用适当的参数三次调用multiply。最后,一个单独的mock.verify()调用检查是否所有模拟期望都得到了满足:

// Our (now very familiar) object under test.
var obj = {
  multiply: function (a, b) { return a * b; },
  error: function (msg) { throw new Error(msg); }
};

it("mocks multiply", function () {
  // Create the mock.
  var mock = sinon.mock(obj);

  // The multiply method is expected to be called:
  mock.expects("multiply")
    .atLeast(2)    // 2+ times,
    .atMost(4)     // no more than 4 times, and
    .withArgs(2);  // 2 was first arg on *all* calls.

  // Make 3 calls to `multiply()`.
  obj.multiply(2, 1);
  obj.multiply(2, 2);
  obj.multiply(2, 3);

  // Verify **all** of the previous expectations.
  mock.verify();

  // Restore the object.
  mock.restore();
});

使用存根和模拟测试 Backbone.js 组件

在将存根和模拟添加到我们的测试基础设施后,我们准备好处理本书中将要涵盖的 Backbone.js 应用程序的剩余组件:App.Views.NotesItem视图和App.Routers.Router路由器。对于跟随代码示例的开发者,我们将将这些应用程序组件的规范集成到测试驱动页面chapters/05/test/test.html中。

确保存根和模拟实际上已绑定

Sinon.JS 的一个初步问题可能会让开发者感到困惑,那就是确保在测试期间间谍、存根和模拟实际上绑定到了 Backbone.js 应用程序对象预期的方法上。

让我们从简单的 Backbone.js 视图MyView开始。该视图有一个名为foo()的自定义方法,该方法绑定到两个事件监听器,wrappedunwrapped。这两个监听器在功能上是等效的,除了wrapped将调用包裹在一个函数中(function () { this.foo(); }),而unwrapped绑定的是真正的(或“裸露的”)this.foo方法:

var MyView = Backbone.View.extend({

  initialize: function () {
    this.on("wrapped", function () { this.foo(); });
    this.on("unwrapped", this.foo);
  },

  foo: function () {
    return "I'm real";
  }

});

虽然非常相似,但在使用 Sinon.JS 伪造时,事件监听器有一个重要的区别;一旦调用initialize(),裸露的方法引用,如传递给unwrapped的,就不能被 Sinon.JS 后来伪造。其根本原因在于 Sinon.JS 只能更改视图对象上的属性,而不能直接更改方法引用。

让我们考察一个实例化MyView对象并随后对foo进行存根的测试。当我们触发wrapped监听器时,我们的存根被调用并返回伪造的值I'm fake。然而,触发unwrapped监听器从未调用存根,而是调用了真正的foo方法。注意,我们使用 Sinon.JS 的reset()方法清除任何记录的函数调用信息,并将间谍、存根或模拟恢复到其原始状态:

it("stubs after initialization", sinon.test(function () {
  var myView = new MyView();

  // Stub prototype **after** initialization.
  // Equivalent to:
  // this.stub(myView, "foo").returns("I'm fake");
  this.stub(MyView.prototype, "foo").returns("I'm fake");

  // The wrapped version calls the **stub**.
  myView.foo.reset();
  myView.trigger("wrapped");
  expect(myView.foo)
    .to.be.calledOnce.and
    .to.have.returned("I'm fake");

  // However, the unwrapped version calls the **real** function.
  myView.foo.reset();
  myView.trigger("unwrapped");
  expect(myView.foo).to.not.be.called;
}));

解决这个问题的方法是在对象实例化之前进行存根。在下面的代码片段中,在调用new MyView()之前创建存根,正确地将存根连接到了wrappedunwrapped监听器:

it("stubs before initialization", sinon.test(function () {
  // Stub prototype **before** initialization.
  this.stub(MyView.prototype, "foo").returns("I'm fake");

  var myView = new MyView();

  // Now, both versions are correctly stubbed.
  myView.foo.reset();
  myView.trigger("wrapped");
  expect(myView.foo)
    .to.be.calledOnce.and
    .to.have.returned("I'm fake");

  myView.foo.reset();
  myView.trigger("unwrapped");
  expect(myView.foo)
    .to.be.calledOnce.and
    .to.have.returned("I'm fake");
}));

对于单个测试,如前两个代码片段所示,跟踪 Backbone.js 对象初始化和存根的顺序相对简单。然而,在测试套件的设置和清理过程中,特别是当对象在稍后模拟或存根的地方实例化时,保持绑定是很重要的。此外,这个问题可以在 Backbone.js 应用程序的多个地方体现出来,如下所示:

  • 视图事件:视图可以声明一个events属性,通过方法的字符串名称将 UI 事件绑定到方法。当 Backbone.js 初始化新的视图对象时,这内部表现得类似于裸露的函数引用。以下是一个此类声明的示例:

    events: {
      "click #id": "foo"
    }
    
  • 路由器路由:类似地,路由器通常声明一个routes属性,将哈希/URL 片段绑定到路由器对象上的命名方法。

最重要的是始终考虑如何将 Sinon.JS 存根绑定到正在测试的 Backbone.js 组件。有时在 Backbone.js 应用组件中避免裸函数引用可能更容易,而在其他时候,最好重新编写测试代码,以便在组件初始化之前绑定存根。在笔记应用中,我们将在这章剩余的测试中使用这两种方法。

笔记列表项视图

在本书中我们将讨论和测试的最后一个笔记视图是列表项视图。当用户导航到笔记应用的首页时,他们会被展示一个以标题标识的笔记列表。App.Views.NotesItem视图负责渲染每个单独的笔记行,并允许用户查看、编辑或删除笔记。以下截图展示了单个列表项视图的渲染输出:

笔记列表项视图

笔记列表项视图

列表项的标题文本可以点击以查看单个笔记的渲染 Markdown。列表项还包含两个操作按钮,一个带有铅笔图标用于编辑,另一个带有垃圾桶图标用于删除。

列表项模板字符串在notes/app/js/app/templates/templates.js中的App.Templatestemplate-notes-item属性中声明:

App.Templates["template-notes-item"] =
  "<td class=\"note-name\">" +
  "  <div class=\"note-title note-view\"><%= title %></div>" +
  "</td>" +
  "<td class=\"note-action\">" +
  "  <div class=\"btn-group pull-right\">" +
  "    <button class=\"btn note-edit\">" +
  "      <i class=\"icon-pencil\"></i>" +
  "    </button>" +
  "    <button class=\"btn note-delete\">" +
  "      <i class=\"icon-trash\"></i>" +
  "    </button>" +
  "  </div>" +
  "</td>";

模板在表格行内渲染两个td单元格,一个用于笔记标题,另一个用于编辑/删除按钮。

列表项视图

App.Views.NotesItem视图在notes/app/js/app/views/notes-list.js中定义。类定义从用于渲染tr标签的 DOM 属性开始,一个notes-item类和一个与笔记模型标识符对应的id属性:

App.Views.NotesItem = Backbone.View.extend({

  id: function () { return this.model.id; },

  tagName: "tr",

  className: "notes-item",

  template: _.template(App.Templates["template-notes-item"]),

列表项的标题和编辑/删除按钮上的点击事件绑定到它们各自的方法,即viewNoteeditNotedeleteNote。就我们之前关于 Sinon.JS 绑定的讨论而言,请注意所有的事件回调都有函数包装器,这允许我们在测试期间任何时候创建可以被存根的App.Views.NotesItem对象:

  events: {
    "click .note-view":   function () { this.viewNote(); },
    "click .note-edit":   function () { this.editNote(); },
    "click .note-delete": function () { this.deleteNote(); }
  },

initialize方法中,视图存储了一个路由引用,并设置了监听器,以响应模型事件重新渲染或删除视图。render方法以相当传统的方式将模型数据绑定到模板:

  initialize: function (attrs, opts) {
    opts || (opts = {});
    this.router = opts.router || app.router;

    this.listenTo(this.model, {
      "change":   function () { this.render(); },
      "destroy":  function () { this.remove(); }
    });
  },

  render: function () {
    this.$el.html(this.template(this.model.toJSON()));
    return this;
  },

转到单个列表项可以执行的操作,viewNoteeditNote方法在查看或编辑模式下导航到单个笔记视图。deleteNote函数删除底层的笔记模型,然后触发事件,清理并从所有笔记列表中删除视图:

  viewNote: function () {
    var loc = ["note", this.model.id, "view"].join("/");
    this.router.navigate(loc, { trigger: true });
  },

  editNote: function () {
    var loc = ["note", this.model.id, "edit"].join("/");
    this.router.navigate(loc, { trigger: true });
  },

  deleteNote: function () {
    // Destroying model triggers view cleanup.
    this.model.destroy();
  }
});

测试列表项视图

我们希望在测试套件文件chapters/05/test/js/spec/views/notes-item.spec.js中验证的App.Views.NotesItem视图行为包括以下内容:

  • 视图渲染笔记列表表中的单行 HTML,并显示笔记的标题和操作按钮

  • 它将点击事件绑定到适当的笔记操作(例如,编辑)并导航到适当的单个笔记页面以阅读或编辑笔记

  • 当用户删除笔记时,它正确地清理了对象状态

测试套件从 before() 设置方法开始,我们在其中创建了一个 App.Views.NotesItem 对象,该对象包含一个假的具有 navigate 存根的路由器对象字面量(包含一个 navigate 存根)和一个真实的 App.Models.Note 模型。在 afterEach() 方法中,我们重置 navigate 存根,以便每个规范都得到一个没有之前记录的函数信息的存根。after() 清理函数移除正在测试的视图。

再次,考虑到 Sinon.JS 方法中的绑定问题,我们注意到 this.view 是在 before() 设置中为整个测试套件创建的。这意味着存根、间谍和/或模拟将仅适用于包装的 App.Views.NotesItem 视图方法。同时,如果现有的 App.Views.NotesItem 测试套件不适合我们需要的所有测试双倍绑定,我们可以轻松地创建一个额外的套件,在实例化之前模拟类原型,以在测试所需的应用程序行为时提供额外的灵活性:

describe("App.Views.NotesItem", function () {

  before(function () {
    this.navigate = sinon.stub();
    this.view = new App.Views.NotesItem({
      model: new App.Models.Note({ id: "0", title: "title" })
    }, {
      router: { navigate: this.navigate }
    });
  });

  afterEach(function () {
    this.navigate.reset();
  });

  after(function () {
    this.view.remove();
  });

第一个嵌套测试套件检查底层模型的 destroy 事件是否触发 view.remove() 方法,清理视图。我们存根 view.remove() 以防止在调用时实际从测试环境中移除视图。然后,我们触发所需的模型事件,以便我们可以验证存根被调用了一次:

  describe("remove", function () {
    it("is removed on model destroy", sinon.test(function () {
      // Empty stub for view removal to prevent side effects.
      this.stub(this.view, "remove");
      this.view.model.trigger("destroy");
      expect(this.view.remove).to.be.calledOnce;
    }));
  });

在接下来的两个规范中,我们处理了一个类似场景,验证笔记模型的 change 事件将触发视图上的 render() 调用。我们在两个规范中都做出相同的断言,在一个规范中使用存根,在另一个规范中使用模拟来展示如何使用任何抽象编写相同的函数规范。renders on model change w/ stub 规范使用存根来验证视图的行为:

  describe("render", function () {
    // One way to verify is with a stub.
    it("renders on model change w/ stub", sinon.test(function () {
      this.stub(this.view);
      this.view.model.trigger("change");
      expect(this.view.render).to.have.been.calledOnce;
    }));

renders on model change w/ mock 规范中,我们依赖于模拟,使用 Sinon.JS 的 once() 预期修改器和 mock.verify() 来做出相同的断言,而不是在存根上使用 Chai 断言:

    // Here is another way to do the same check with a mock.
    it("renders on model change w/ mock", sinon.test(function () {
      var exp = this.mock(this.view).expects("render").once();
      this.view.model.trigger("change");
      exp.verify();
    }));
  });

在接下来的两个规范中,我们检查用户点击列表项标题(用于查看)或铅笔按钮(用于编辑)的场景。我们需要检查这两个点击是否调用适当的视图函数并导致路由器导航到预期的单个笔记页面。在随后的代码片段中,我们通过断言路由器的 navigate 存根已用适当的参数调用来验证此行为:

  describe("actions", function () {
    it("views on click", function () {
      this.view.$(".note-view").click();

      expect(this.navigate)
        .to.be.calledOnce.and
        .to.be.calledWith("note/0/view");
    });

    it("edits on click", function () {
      this.view.$(".note-edit").click();

      expect(this.navigate)
        .to.be.calledOnce.and
        .to.be.calledWith("note/0/edit");
    });

最后,我们确保点击垃圾桶按钮会触发底层笔记模型被销毁。我们存根模型的 destroy 方法以验证它被调用,并防止模型实际被修改:

    it("deletes on click", sinon.test(function () {
      // Empty stub for model destroy to prevent side effects.
      this.stub(this.view.model, "destroy");
      this.view.$(".note-delete").click();

      expect(this.view.model.destroy).to.be.calledOnce;
    }));
  });
});

总的来说,我们对 App.Views.NotesItem 的测试展示了如何用模拟和存根替换方法行为,从而简化我们的测试并限制程序方法的副作用。

笔记应用程序路由器

我们将在 Notes 应用程序中测试的最后一个 Backbone.js 组件是路由器,App.Routers.Router。路由器负责管理客户端页面位置(URL 或 hash 片段)并将路由绑定到视图、事件和动作。

为了本章的目的,我们将使用chapters/05/test/js/spec/routers/router.js中可用的App.Routers.Router类的简化版本,而不是真实的笔记路由文件(在notes/app/js/app/routers/router.js中的代码示例中找到)。

注意

虽然真实的 Backbone.js 路由器不是最复杂的生物,但它有足够的复杂依赖和应用逻辑,足以在文本中省略完整实现,尤其是在我们只需要介绍一些路由器的测试技巧时。

同时,我们不会因为存在组件依赖而回避测试。因此,我们在代码示例的notes/test/js/spec/routers/router.spec.js中为真实的App.Routers.Router组件提供了一个全面的测试套件。我们鼓励您审查完整路由及其相应的测试套件的实现。

Notes 应用程序包含两个路由,分别对应笔记列表页面和单个笔记页面。我们在简化的App.Routers.Router类中包含这种行为:

App.Routers.Router = Backbone.Router.extend({

  routes: {
    "": "notes",
    "note/:id/:action": "note",
  },

  // Show notes list.
  notes: function () {
    // ... omitted ...
  },

  // Common single note edit/view.
  note: function (noteId, action) {
    // ... omitted ...
  }

});

我们的测试应该检查路由规范是否绑定到正确的路由器方法,以及 URL / hash 片段是否正确解析为路由器方法的参数。我们在测试套件文件chapters/05/test/js/spec/routers/router.spec.js中验证这种行为。

我们的设置逻辑首先在路由器的notenotes方法周围创建占位符。然后我们实例化一个路由器对象并启动历史记录(这启用了实际的路由)。我们的设置以将匿名间谍绑定到每个route事件(在任何路由被激活时触发)结束。

注意

总是关注 Sinon.JS 绑定问题,请注意,我们必须在实例化路由器对象之前占位路由器原型,因为路由器对象的routes属性将路由绑定到方法名字符串,而不是到包装函数。

describe("App.Routers.Router", function () {

  // Default option: Trigger and replace history.
  var opts = { trigger: true, replace: true };

  beforeEach(function () {
    // Stub route methods.
    sinon.stub(App.Routers.Router.prototype, "note");
    sinon.stub(App.Routers.Router.prototype, "notes");

    // Create router with stubs and manual fakes.
    this.router = new App.Routers.Router();

    // Start history to enable routes to fire.
    Backbone.history.start();

    // Spy on all route events.
    this.routerSpy = sinon.spy();
    this.router.on("route", this.routerSpy);
  });

我们的拆解逻辑会停止历史记录并回滚占位符:

  afterEach(function () {
    Backbone.history.stop();

    App.Routers.Router.prototype.note.restore();
    App.Routers.Router.prototype.notes.restore();
  });

第一个规范检查我们是否可以通过在期望的路由"note/1/edit"上调用路由器的navigate方法来导航到单个笔记进行编辑。我们断言这调用了我们占位符的note方法(我们已占位)并带有提取的参数"1""edit"。我们还通过routerSpy事件监听器确认相同类型的信息:

  it("can route to note", function () {
    this.router.navigate("note/1/edit", opts);

    // Check router method.
    expect(App.Routers.Router.prototype.note)
      .to.have.been.calledOnce.and
      .to.have.been.calledWithExactly("1", "edit");

    // Check route event.
    expect(this.routerSpy)
      .to.have.been.calledOnce.and
      .to.have.been.calledWith("note", ["1", "edit"]);
  });

我们的第二个规范验证我们是否可以导航到主页,然后导航到单个笔记页面,然后返回主页。我们使用与上一个规范类似的验证逻辑,依赖于notes占位符(在""主页路由上调用两次)和routerSpy间谍(在所有三个路由上调用):

  it("can route around", function () {
    // Bounce between routes.
    this.router.navigate("", opts);
    this.router.navigate("note/1/edit", opts);
    this.router.navigate("", opts);

    // Check router method.
    expect(App.Routers.Router.prototype.notes)
      .to.have.been.calledTwice.and
      .to.have.been.calledWithExactly();

    // Check route event.
    expect(this.routerSpy)
      .to.have.been.calledThrice.and
      .to.have.been.calledWith("notes");
  });

});

这些路由测试与 Backbone.js 视图测试中的事件测试并没有太大的不同——两者都将字符串(一个路由或一个 UI 事件)绑定到组件方法(通过字符串名称或函数)。总的来说,我们在本章中学到的 Sinon.JS 模拟和存根方法通常适用于任何类型的 Backbone.js 组件。

运行视图和路由测试

现在我们已经有了 App.Views.NotesItemApp.Routers.Router 的测试套件,我们可以将它们集成到一个测试驱动页面上。在之前的 chapters/04/test/test.html 驱动页面上(增加了一些突出显示的内容),我们的最终驱动页面 chapters/05/test/test.html 包含以下相关部分:

<head>
  <!-- ... snipped ... -->

  <!-- JavaScript Application Libraries -->
  <script src="img/namespace.js"></script>
  <script src="img/config.js"></script>
  <script>
    // Test overrides (before any app components).
    App.Config = _.extend(App.Config, {
      storeName: "notes-test" // localStorage for tests.
    });
  </script>
  <script src="img/note.js"></script>
  <script src="img/notes.js"></script>
  <script src="img/templates.js"></script>
  <script src="img/note-nav.js"></script>
  <script src="img/note-view.js"></script>
  <script src="img/note.js"></script>
  <script src="img/notes-item.js"></script>

  <!-- The shortened, teaching router for Chapter 05 -->
  <script src="img/router.js"></script>

  <!-- ... snipped ... -->

  <!-- Tests. -->
  <script src="img/notes-item.spec.js"></script>
  <script src="img/router.spec.js"></script>
</head>

提示

到目前为止,我们在供应商库和我们的 Backbone.js 应用程序组件之间积累了大量 JavaScript 文件。虽然这对于测试(有时甚至是被期望的)是可以接受的,但在生产应用程序中使用像 Google Closure Compiler (developers.google.com/closure/compiler/) 或 UglifyJS (github.com/mishoo/UglifyJS2) 这样的工具来连接和优化 JavaScript 文件是一种良好的实践。

我们现在可以导航到浏览器窗口中的 chapters/05/test/test.html 来运行测试。

注意

如果你正在从代码示例中运行报告,结果中会出现一些本书未讨论的额外视图规范。

你可能会注意到,在路由测试中对 navigate 方法的调用实际上修改了浏览器位置,添加了哈希片段。虽然这不会影响测试的正确性,但这有点出乎意料。采用另一种方法,Backbone.js 库测试套件通过创建一个假的 Location 对象来替代真实的浏览器导航栏,从而绕过这个问题。有关更多详细信息,请参阅 github.com/documentcloud/backbone/blob/master/test/router.js

查找 Notes 应用程序的其余组件

在完成之前的视图和路由测试后,我们现在已经完成了将在本书中展示的基于 localStorage 的 Notes 应用程序的应用程序和测试代码。然而,Notes 应用程序中还有一些其他部分,我们无法在本书的范围内进行讨论。

幸运的是,每个组件(以及它们的相关测试文件)都可以作为本书可下载代码示例的一部分找到。在示例中可以找到的 Notes 应用程序的其余部分如下:

  • App.Views.NotesFilter (notes/app/js/app/views/notes-filter.js): 这个视图控制过滤输入框和显示的笔记列表中笔记行的可见性。这个视图的测试文件可以在 notes/test/js/spec/views/notes-filter.spec.js 中找到。

  • App.Views.Notes (notes/app/js/app/views/notes.js): App.Views.Notes 视图包含 App.Views.NotesItemApp.Views.NotesFilter 视图,负责从集合中获取笔记数据并渲染笔记的完整列表。相应的测试文件位于 notes/test/js/spec/views/notes.spec.js

  • App.Routers.Router (notes/app/js/app/routers/router.js): 这是 Notes 的 Backbone.js 路由的完整实现。其测试文件可在 notes/test/js/spec/routers/router.spec.js 中找到。

  • app (notes/app/js/app/app.js): app 对象控制 Notes 应用程序的整体功能。它实例化所有顶级应用程序组件;例如,App.Views.Notes 视图、App.Routers.Router 路由器和 App.Collections.Notes 集合。它还启动一个初始集合 fetch 操作以导入现有的笔记数据。我们不为此文件包含规范,因为创建和启动实际应用程序通常包含在完整集成测试的范围内——这是我们提到的 第二章,创建 Backbone.js 应用程序测试计划,我们鼓励您在本书之外学习。

  • notes/test/test.html: 这是所有 Notes 应用程序测试套件和规范的测试驱动页面。此页面汇总了我们在本书中讨论的所有 Notes 规范以及省略的视图的规范,以及完整的路由实现。

这些额外的文件将我们在本书中学到的基本原理应用于不同的应用程序代码和场景。因此,审查剩余的 Notes 应用程序文件将为您提供一个更全面的 Backbone.js 应用程序和测试基础设施的图景,该基础设施遵循我们建议的测试原则。总的来说,我们希望这些代码示例能够传达本书中涵盖的主题,并可能为您在测试开发教育中提供一些想法和下一步行动。

更多 Sinon.JS 测试辅助工具

Sinon.JS 提供了许多有用的工具,超出了核心测试替身抽象(间谍、存根和模拟)。我们已经在 第四章 中介绍了 sinon.test 包装器,测试间谍,本章我们将探讨一些同样方便的辅助工具。

小贴士

一些测试辅助工具,如计时器和服务器,在使用 Internet Explorer 网络浏览器时可能需要使用 IE 特定的 Sinon.JS 库。有关更多详细信息,请参阅相关的 Sinon.JS 文档部分。

模拟计时器

Sinon.JS 可以修补时间和日期间隔,以帮助在测试中管理异步事件和回调。Sinon.JS 伪造计时器覆盖了原生 JavaScript 函数,如 setTimeoutDate 类。一旦伪造,测试代码必须通过 API tick(ms) 函数手动推进时间,该函数模拟程序中任何基于时间的异步事件的时间流逝。有关完整 API 参考,请参阅 sinonjs.org/docs/#clock

假计时器对于测试 Backbone.js 应用程序非常有用。例如,如果某些 UI 代码有一个延迟的 jQuery 效果,需要 200 毫秒才能完成,包含此行为的测试将不得不等待这么长时间,从而减慢整个测试套件的运行速度。此外,原生 JavaScript 中的计时器并不完全可预测。(例如,请参阅 John Resig 的 JavaScript 时间精度 ejohn.org/blog/accuracy-of-javascript-time。)使用 Sinon.JS 伪造计时器,我们可以在测试中 同步可预测 地模拟 jQuery 效果的 200 毫秒推进,而无需任何延迟。

假服务器

Sinon.JS 还可以修补程序的一些通信内部,并覆盖 XMLHttpRequest (XHR) 以及其他相关机制。典型的 Backbone.js 应用程序使用 XHR 将模型和集合同步到后端数据存储,如数据库或云服务,这使得此功能对我们的测试基础设施特别相关。Sinon.JS 提供的整个 XHR 伪造功能范围在 sinonjs.org/docs/#server 中讨论。

假服务器 API

Sinon.JS 提供的第一个 API 是 FakeXMLHttpRequest;这是一个围绕 XHR 接口的低级抽象,提供了对请求、响应、标题和其他细节的精细控制。有关完整 API 列表,请参阅 sinonjs.org/docs/#FakeXMLHttpRequest

Sinon.JS 还提供了一个高级 API,形式为假服务器,为现代 JavaScript 网络应用程序中的常见用例提供了一个更简单的接口。在本章中,我们将使用后者接口,因为更简单的接口仍然非常适合我们的 Backbone.js 应用程序测试需求。

Sinon.JS 假服务器 API 文档可在 sinonjs.org/docs/#fakeServer 找到。API 的一个有用子集包括以下内容:

  • sinon.fakeServer.create(): 这将创建一个假服务器对象,并伪造 XHR 接口以进行测试。

  • server.respondWith(response): 这将配置服务器对所有请求响应一个响应对象。响应可以采取各种形式,但我们将使用的是一个包含 HTTP 状态码、标题字典和 JSON 响应字符串的数组。默认响应是 [404, {}, ""]

  • server.respondWith(method, url, response): 这将配置服务器以响应对象响应与指定 HTTP 方法和方法匹配的请求。respondWith 有进一步的变体,可以使用正则表达式进行 URL 匹配。

  • server.respond(): 在服务器配置完成并开始测试后,任何对 respond() 的调用都会导致模拟服务器立即发出预安排的响应对象。

  • server.autoRespond = true: 模拟服务器将自动响应服务器请求,无需调用 respond()。默认情况下,模拟服务器将在响应前等待 10 毫秒。可以将不同的等待时间分配给 server.autoRespondAfter 配置变量。

  • server.restore(): 这将撤销模拟的 XHR 接口。

在 Backbone.js 应用程序中模拟远程后端

本书所介绍的笔记应用没有外部后端,而是依赖于 HTML5 localStorage 来存储集合数据。虽然它是一个有用的教学工具,但大多数现实世界的 Backbone.js 应用确实拥有远程后端存储。因此,本书的配套代码示例包括了一个作为 Node.js Express (expressjs.com) 应用程序提供的笔记版本,并使用 MongoDB (www.mongodb.org) 后端数据库。您可以在代码示例仓库的 notes-rest/ 目录中找到完整的应用程序及其测试套件。

基于 localStorage 的 notes/app 和基于 MongoDB 的 notes-rest/app Backbone.js 应用程序之间的主要区别在于 App.Collections.Notes 集合实现。notes-rest/app 版本,可在 notes-rest/app/js/app-rest/collections/notes.js 中找到,如下定义集合类:

App.Collections.Notes = Backbone.Collection.extend({

  model: App.Models.Note,

  url: "/api/notes"

});

URL /api/notes 指向由 Node.js Express 服务器提供的后端 REST 接口(代码示例中的 notes-rest/server.js),该接口与 MongoDB 数据存储进行交互。

我们对新 App.Collections.Notes 集合的测试将依赖于模拟服务器来拦截所有的远程后端调用,并用我们期望的测试数据替换网络响应。

小贴士

本节中的集合测试模拟了整个后端,这意味着测试根本不使用 Node.js 或 MongoDB 服务器。这提供了测试运行速度极快和给我们提供可预测响应的优势。然而,测试场景如果试图测试整个应用程序(例如,完整的集成测试)可能需要测试基础设施在真实后端服务器和数据存储上运行。

在查看测试套件文件 notes-rest/test/js/spec-rest/collections/notes.spec.js 中的 beforeEach 设置调用时,我们创建了一个空集合和一个自动响应后端请求的模拟服务器。afterEach 调用将恢复正常的 XHR 操作:

describe("App.Collections.Notes", function () {

  beforeEach(function () {
    this.server = sinon.fakeServer.create();
    this.server.autoRespond = true;
    this.notes = new App.Collections.Notes();
  });

  afterEach(function () {
    this.server.restore();
  });

以下规范检查集合是否可以从后端获取并填充数据。我们配置伪造服务器以对 GET 请求返回单个笔记的 JSON 序列化数据。然后,我们在reset事件上设置一个回调来验证集合具有预期的长度,并且已将数据反序列化为笔记模型:

  describe("retrieval", function () {

    it("has a single note", function (done) {
      var notes = this.notes, note;

      // Return a single model on GET.
      this.server.respondWith("GET", "/api/notes", [
        200,
        { "Content-Type": "application/json" },
        JSON.stringify([{
          id: 1,
          title: "Test note #1",
          text: "A pre-existing note from beforeEach."
        }])

      ]);

      // After fetch.
      notes.once("reset", function () {
        expect(notes).to.have.length(1);

        // Check model attributes.
        note = notes.at(0);
        expect(note).to.be.ok;
        expect(note.get("title")).to.contain("#1");
        expect(note.get("text")).to.contain("pre-existing");

        done();
      });

      notes.fetch({ reset: true });
    });

  });

本规范说明了请求可以如何被伪造——我们只需为特定的伪造 URL 调用一次this.server.respondWith()设置即可,Backbone.js 集合并不知道它实际上并没有与远程数据存储进行通信。有关使用 Sinon.JS 伪造服务器的附加集合测试,请参阅本书配套代码示例中的notes-rest/test/js/spec-rest/collections/notes.spec.js文件,这些示例可以从测试驱动页面notes-rest/test/test.html运行。

摘要

在本章中,我们学习了如何应用 Sinon.JS 存根、模拟和其他伪造来隔离 Backbone.js 组件,减少测试复杂性,并增强可预测的测试行为。我们还完成了本书中将要讨论的所有参考笔记应用程序的应用程序测试。花点时间回顾一下到目前为止的进展,我们现在已经涵盖了创建测试基础设施和应用基本测试概念到所有各种 Backbone.js 应用程序组件的基础知识。

但是,这实际上只是实质性测试旅程的开始;本书中展示的测试只是那些对于一个完整的生产级 Backbone.js 应用程序所期望的测试的一个子集。我们希望你现在拥有了提供完整测试支持的必要工具、开发技术和起点,以支持你的 Backbone.js 应用程序。

在下一章中,我们将致力于通过测试自动化扩展我们的测试能力和用例。我们将超越在本地浏览器中手动运行测试套件,并引入可以在不同环境中(如命令行或构建服务器)执行测试且无需网络浏览器的测试工具。

第六章. 自动化 Web 测试

在讨论了测试 Backbone.js 应用程序的实际技术之后,我们现在将探讨自动化测试基础设施的各种方法。能够以编程方式运行我们的测试集合,使得在开发过程中单个开发者手动运行测试驱动器页面的单一用例之外,出现了新的和令人兴奋的用例。在本章中,我们将探讨以下自动化和开发主题:

  • 调查自动化测试基础设施的场景和动机

  • 调查以编程方式运行 Backbone.js 应用程序测试套件的多种方法

  • 介绍用于前端测试的 PhantomJS 和适配器工具

  • 将我们现有的测试基础设施集成到 PhantomJS 环境中

  • 在完成本书后,总结我们对 Backbone.js 应用程序测试原则和实践的讨论,并提供下一步的建议和资源

超越人类和浏览器的测试世界

到目前为止,我们的测试开发工作流程包括编写测试套件,将它们添加到测试驱动器页面,并在开发计算机上的网络浏览器中启动测试页面。然而,测试基础设施可以用于比手动运行网络报告更多的场景。通过检查以下用例,我们将看到自动在任意环境中运行我们的测试集合(例如,从命令行或构建脚本,可能无需网络浏览器)在应用程序开发过程中具有巨大的潜力。

持续集成

在协作软件开发中,当工程师分别开发代码,稍后将其合并到公共代码库中时,可能会出现问题。更改之间的意外交互可能导致集成错误,破坏整体应用程序。

对于此类错误的一种缓解方法是持续集成,它大量依赖于自动化测试。持续集成将应用程序代码聚合并测试,以早期和自动地检测集成错误。关于这个主题的深入介绍,请参阅马丁·福勒《持续集成》,可在martinfowler.com/articles/continuousIntegration.html找到。

持续集成的过程通常是通过一个专用的服务器来实现的,该服务器逐步收集代码更改,创建一个干净的应用程序环境,运行构建命令,并对命令输出采取行动。例如,假设我们有一个存储在 GitHub 上的 Node.js 应用程序。一个持续集成服务器可以从 GitHub 下载代码更改,为应用程序创建一个新的构建目录,安装包依赖项(例如,npm install),并运行测试(例如,npm test)。如果任何测试失败,服务器将通知负责这些更改的开发者。一些流行的持续集成服务器包括 Jenkins (jenkins-ci.org/) 和 Travis (travis-ci.org/)。

持续部署

持续部署服务器是持续集成服务器的增强版本,如果所有测试都通过,它还会将代码部署到实际的应用程序环境(例如,生产环境)。它依赖于自动化测试来验证整个应用程序,以便代码更改可以尽可能快地推出,同时至少保留一些质量保证的表象。《为什么持续部署?》这篇文章由埃里克·莱斯撰写,可在www.startuplessonslearned.com/2009/06/why-continuous-deployment.html找到,是了解持续部署背后的动机和实践的好起点。

其他场景

测试自动化使得许多其他有用的应用成为可能。例如,称为监视器守卫的开发工具定期检查代码的修改,并在文件更改时执行进一步的操作。监视器通常在开发机器上使用,以自动运行测试并在代码更改导致一个或多个测试失败时显示警报,这样开发者可以快速且轻松地发现错误。

跨浏览器测试是自动化使工作变得更简单的另一个领域。虽然程序员可以手动在不同的目标浏览器上运行测试集合,但这通常既耗时又容易出错,而且很无聊。幸运的是,有一些测试工具可以在不进行人工交互的情况下,在多个任意浏览器上自动运行测试。

自动化浏览器环境

在介绍了一些激励性的用例之后,我们现在转向自动化测试基础设施的细节。我们将介绍以下用于程序化驱动 Backbone.js 测试的技术:

  • 在真实浏览器中远程控制测试

  • 在浏览器模拟库中运行测试

  • 在无头浏览器环境中执行测试

  • 结合前三种方法

远程控制浏览器

最全面的自动化技术是远程控制网络浏览器。远程控制意味着程序可以执行人类使用真实网络浏览器所能做的操作——打开浏览器到指定页面、点击链接、填写输入等。

最受欢迎的远程控制框架之一是 Selenium (docs.seleniumhq.org/)。Selenium 提供了许多 web drivers,这些是程序适配器,可以连接到真实浏览器并触发通过正常用户界面的操作。Selenium 支持多种环境,为各种浏览器提供不同操作系统的 web drivers,包括 Chrome、Safari、Firefox 和 Internet Explorer。

小贴士

Selenium 项目包含的功能和功能远不止浏览器远程控制。值得注意的是,Selenium 可以使用其他测试执行方法,包括无头网络工具如 PhantomJS。有关起点和更多信息,请参阅 Selenium 项目页面 (docs.seleniumhq.org/projects/) 和 web driver 列表 (docs.seleniumhq.org/projects/webdriver/)。

使用像 Selenium 这样的远程控制工具自动化我们的测试基础设施涉及两个基本步骤:打开并运行测试驱动程序页面,然后推断测试是否通过。例如,我们可以在代码示例中编写一个 Selenium 脚本,打开浏览器窗口到笔记应用程序测试驱动程序页面 notes/test/test.html。然后,Selenium 脚本可以抓取报告页面 HTML 以检查 DOM 中的明显字符串,如 failures: 0,并使用适当的成功/失败退出代码终止脚本。

因此,像 Selenium 这样的远程控制工具非常强大,因为它们可以自动执行任何真实浏览器可以执行的操作。而且,使用像 Selenium 这样的跨平台兼容工具,我们可以从单个脚本中运行几乎所有现代浏览器/操作系统组合的测试。

小贴士

托管测试自动化提供商

利用 Selenium 广泛的测试环境支持,供应商现在提供允许用户上传 Selenium 测试脚本、指定所需的操作系统/浏览器配置数组,并让服务运行并返回测试报告的服务。这样的供应商之一是 Sauce Labs (saucelabs.com/),它使用各种 Selenium 支持的测试环境在虚拟机上运行用户脚本。此类托管服务通常是获得广泛浏览器兼容性覆盖的最快方式,且开发者工作量最小。

远程控制方法确实有一些缺点,其中第一个是测试工具可能相对较慢。脚本可能需要几秒钟甚至几分钟才能连接到目标浏览器并运行测试驱动页面。此外,这些框架需要真实网络浏览器和桌面窗口化系统。这对于无头构建/持续集成来说可能是一个问题,这意味着它们默认没有安装图形用户界面或窗口环境。

模拟的浏览器环境

另一种自动化方法是使用测试友好的浏览器环境和状态的模拟来替换网络浏览器。通常,浏览器模拟库在浏览器内部提供 JavaScript API 的实现,例如 DOM 对象(例如,windowdocument)、CSS 选择器和 JSON 接口。

JSDom (github.com/tmpvar/jsdom) 是一个流行的模拟库,它提供了一个相当完整的浏览器环境。JSDom 是用 JavaScript 编写的,并打包为 Node.js 模块。由于 Node.js 可以轻松脚本化,JSDom 为我们提供了一个从命令行集成和运行 Backbone.js 测试的良好起点。

测试自动化是一个如此常见的用例,以至于围绕 JSDom 编写了几个测试友好的库。其中一个这样的库是 Zombie.js (zombie.labnotes.org/),它提供了方便的浏览器抽象和与各种测试框架的集成,包括 Mocha。使用 Zombie.js 这样的库,我们可以编写一个 Node.js 脚本,创建一个模拟的浏览器模拟,导航到我们的 Backbone.js 测试驱动页面,并抓取测试结果的 HTML 以检查是否有测试失败。有关使用 Zombie.js 和 Mocha 测试 JavaScript 网络应用的更深入介绍,请参阅 Pedro Teixeira 的《Using Node.js for UI Testing》(www.packtpub.com/testing-nodejs-web-uis/book)。

浏览器模拟库之所以运行速度快,是因为它们在与测试代码相同的底层 JavaScript 引擎中运行模拟代码,而不需要外部依赖(例如,在真实的网络浏览器可执行文件中)。由于模拟 JavaScript 代码与应用程序和测试在同一进程中运行,因此模拟库通常非常易于扩展。

然而,模拟存在一些关键缺点。一个主要问题是模拟可能会偏离真实网络浏览器的真实环境。复杂的浏览器交互,如高度链式的事件触发或复杂的 DOM 操作,可能会破坏模拟或与真实浏览器表现不同。此外,浏览器模拟库只提供单一浏览器环境实现,因此无法测试各种真实浏览器之间的怪癖和差异。

无头网络浏览器

在远程控制浏览器和模拟库之间是无头网络浏览器。无头浏览器取一个真实的网络浏览器,去掉用户界面,只留下 JavaScript 引擎和环境。剩下的就是一个可以导航到网页、在浏览器环境中执行 JavaScript 以及通过非图形界面(如警报和控制台日志)进行通信的命令行工具。

最受欢迎的无头工具包之一是 PhantomJS (phantomjs.org/),它基于为 Safari 等浏览器提供动力的 WebKit 开源浏览器 (www.webkit.org/)。PhantomJS 通过脚本支持和 JavaScript API 丰富了 WebKit。

将 Backbone.js 应用程序测试与无头浏览器集成类似于配置远程控制浏览器。方便的是,PhantomJS 内置了对广泛测试基础设施的原生支持,并为许多其他测试提供了第三方适配器。有关更多测试支持细节,请参阅github.com/ariya/phantomjs/wiki/Headless-Testing

无头网络工具结合了之前自动化方法的一些最佳特性,包括以下内容:

  • 无头 JavaScript 引擎通常比远程控制框架更快

  • 浏览器环境是真实的,这避免了在浏览器模拟中可能发现的某些 API 和正确性问题

  • 无头框架通常易于安装,可以在没有窗口环境的服务器上运行

同时,无头浏览器在启动和运行浏览器引擎时会带来一些性能损失。它们也放弃了跨浏览器功能,因为无头工具绑定到特定的网络浏览器引擎实现。考虑到整体优缺点,无头框架在许多相互排斥的自动化功能之间提供了一个良好的折衷方案。

多环境聚合器

利用各种方法的好处,许多框架将不同的自动化方案聚合到一个单一包中。例如,以下测试框架可以编程驱动主要网络浏览器和 PhantomJS 的测试:

聚合框架是可取的,因为它们允许单个测试集合在不同的自动化环境中重复使用,尽管一些工具的设置和维护比单个自动化工具更困难。

使用 PhantomJS 进行无头测试

作为具体的自动化示例,我们将调整现有的 Backbone.js 测试基础设施以使用 PhantomJS。PhantomJS 为 Backbone.js 测试提供了一套易于使用的特性和功能——它运行速度快,相对容易设置,并提供了一个真实的(无头)浏览器。从实际的角度来看,较大的 Backbone.js 应用程序通常需要一个真实的浏览器引擎才能正常工作,尤其是那些测试浏览器环境中的模糊和复杂部分的程序。

安装 PhantomJS 和支持工具

要使用 PhantomJS 启动,请按照 phantomjs.org/download.html 上的说明安装工具包。请注意,安装过程取决于操作系统,有适用于 Windows、Mac OS X 和 Linux 的软件包。或者,可以直接使用 NPM 通过 phantomjs Node.js 包装器安装 PhantomJS (github.com/Obvious/phantomjs)。

注意

我们在本节中提供了来自类似 UNIX 的操作系统(如 Linux 和 Mac OS X)的命令行示例。同时,PhantomJS 和 Node.js 在 Windows 上也有第一级支持,因此接下来的示例应该与 Windows 上可以运行的内容大致相似。

安装完成后,您可以验证 PhantomJS 二进制文件是否可用:

$ phantomjs --help

在 PhantomJS 安装完成后,我们接下来转向 Mocha-PhantomJS 桥接库。Mocha-PhantomJS 使用 PhantomJS 运行 Mocha 测试驱动页面,并将测试结果转换为格式化的命令行输出。该库在测试失败时抛出适当的错误,这使得它对于脚本编写非常有用。有关附加功能和详细信息的在线文档请参阅 metaskills.net/mocha-phantomjs/

要安装 Mocha-PhantomJS,您需要 Node.js 框架,可以通过查阅 nodejs.org/download/ 上的说明来获取。现代 Node.js 安装包括用于 Mocha-PhantomJS 的 NPM 包管理器工具。我们可以使用以下命令来确认 Node.js 和包管理器已正确安装:

$ node --help
$ npm --help

接下来,使用全局 NPM 标志(-g)安装 Mocha-PhantomJS,以便在 shell 的任何位置都可以使用 mocha-phantomjs 二进制文件:

$ npm install -g mocha-phantomjs

在 NPM 完成安装后,使用以下命令检查 Mocha-PhantomJS 是否可用:

$ mocha-phantomjs --help

使用 PhantomJS 运行 Backbone.js 测试

在安装了必要的工具后,我们现在可以将 Backbone.js 测试基础设施调整以运行 PhantomJS。Mocha-PhantomJS 提供了一个替换代理对象 mochaPhantomJS,用于控制 Mocha 测试和报告。我们只需在测试驱动网页中替换通常调用 mocha.run() 的真实 mocha 对象。将以下代码片段插入测试驱动页面将允许 Mocha 在真实浏览器和 PhantomJS 中同时运行:

<!-- Test Setup -->
<script>
  var expect = chai.expect;
  mocha.setup("bdd");

  window.onload = function () {
    (window.mochaPhantomJS || mocha).run();
  };
</script>

一旦我们修改了测试驱动页面中的 (window.mochaPhantomJS || mocha).run() 函数调用,我们就可以使用 Mocha-PhantomJS 执行页面测试。例如,如果我们修改了上一章中的 Notes 应用程序测试驱动文件 chapters/05/test/test.html 中的 mochaPhantomJS 变更,我们就可以运行该文件并生成以下命令行报告:

$ mocha-phantomjs chapters/05/test/test.html

 App.Views.NotesItem
 remove
√ is removed on model destroy
 render
√ renders on model change w/ stub
√ renders on model change w/ mock
 DOM
√ renders data to HTML
 actions
√ views on click
√ edits on click
√ deletes on click
 App.Routers.Router
√ can route to note
√ can route around

 9 tests complete (39 ms)

审查这份报告,我们可以看到所有测试都通过了,并且 PhantomJS 测试运行相当快,耗时 39 毫秒。通过这些微小的测试驱动网页更改,我们可以从命令行或构建脚本中使用 PhantomJS 运行几乎任何测试网页。

在代码示例中自动化测试

将这些建议的原则付诸实践,本书中展示的大多数测试代码示例都被脚本化为在 PhantomJS 下从命令行运行。如果您审查可下载的代码示例仓库,您会注意到所有章节和应用程序测试页面实际上都使用了 (window.mochaPhantomJS || mocha).run() 函数调用,而不是原始的 mocha.run() 语句。

将 PhantomJS 集成到代码示例中为我们在本章前面讨论的一些自动化测试用例提供了一个实用的起点。具体来说,以下示例实现了以下自动化场景:

  • 命令行测试:代码示例包含一个 Node.js NPM package.json 文件,其中包含可以运行章节和应用程序测试驱动页面的脚本命令,使用 PhantomJS。

  • 持续集成服务器:代码示例的 GitHub 仓库([github.com/ryan-roemer/backbone-testing/](https://github.com/ryan-roemer/backbone-testing/))使用 Travis 持续集成服务器进行自动化的失败警报。Travis 被配置为在每次代码更改时使用 PhantomJS 运行所有示例测试。对于本书中展示的测试基础设施,Travis 是一个特别好的选择,因为它的构建环境已经包含了 PhantomJS,并且对 Node.js 和 NPM 模块(如 Mocha-PhantomJS)非常友好。要查看所有这些功能在实际中的运行情况,您可以在任何时间通过浏览器导航到 https://travis-ci.org/ryan-roemer/backbone-testing 来检查我们在这本书中讨论的所有代码的实时构建状态。(希望您会发现我们的所有测试都通过了!)

离别时的思考,下一步行动和未来想法

我们现在已经完成了使用 Mocha、Chai 和 Sinon.JS 测试 Backbone.js 应用程序基础知识的旅程。我们探讨了每个测试框架的背景、配置和使用,并尝试了多种互补的工具和助手。我们回顾了 Backbone.js 应用程序开发、特定组件测试目标,并在一个完整的 Backbone.js 应用程序周围编写了测试集合。那么,接下来是什么?

我们的第一建议是回顾各种测试技术的在线文档。书中使用的所有框架的官方 API 和指南都相当不错,可以为实际中可能出现的更复杂的测试场景提供起点。作为一个复习,我们核心测试堆栈的文档网站包括以下内容:

在框架文档之后,您可以回顾本书中提供的文章、博客和书籍建议。特别是,第二章中关于一般测试方法和 Backbone.js 测试的创建 Backbone.js 应用程序测试计划参考,对于寻求更广泛背景的软件开发和测试技术的读者来说是非常好的资源。

最后,我们建议您下载并安装本书的代码示例。这些示例基本上是我们在这本书中讨论的原理的实际应用,将有用的应用程序、测试和文件组合成一个单一包。此外,它们还提供了更多测试和自动化技术的示例,供您自行探索,包括以下内容:

剩下的部分就留给你了。虽然我们已到达这本书的终点,但测试的世界将继续以新的和有趣的方式不断前进。我们祝愿你在继续学习和探索更多 Backbone.js 应用程序开发的测试工具、方法和主题时好运。

摘要

在本章中,我们学习了如何通过介绍测试自动化方法和用例,从测试过程中移除手动浏览器交互。我们调查了不同的工具来从命令行驱动我们的测试,并通过使用 PhantomJS 驱动我们的 Backbone.js 应用程序测试,完成了一个具体的测试自动化实现。

此外,我们还留下了一些关于我们在本书过程中发展出的原则以及下一步该何去何从的最终思考。希望你现在有了创建自己的 Backbone.js 测试基础设施、应用良好的测试驱动开发实践以及自信地应对前端测试的基础和方向。

posted @ 2025-09-24 13:53  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报