Jasmine-JavaScript-测试指南第二版-全-

Jasmine JavaScript 测试指南第二版(全)

原文:zh.annas-archive.org/md5/609e3ff0eaf224d6c133c816e9155d97

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书旨在成为更好的 JavaScript 开发者。因此,在整个章节中,你不仅将学习如何在 Jasmine '风格' 中编写测试,还将学习在 JavaScript 语言中编写软件的最佳实践。这是关于承认 JavaScript 是一个真正的应用开发平台,并利用其全部潜力的内容。这同样也是关于工具和自动化,以及如何使你的生活更轻松、更高效。

最重要的是,本书不仅关于工作软件的工艺,也关于精心制作的软件的工艺。

Jasmine JavaScript 测试,第二版 是一本关于为网络应用编写和自动化 JavaScript 测试的实用指南。它使用了诸如 Jasmine、Node.js 和 webpack 等技术。

在章节的进程中,通过开发一个简单的股票市场投资追踪器应用程序,解释了测试驱动开发的概念。它从测试的基础知识开始,通过开发基础领域类(如股票和投资),过渡到可维护浏览器代码的概念,并以重构到基于 ECMAScript 6 模块和自动化构建的 React.js 应用程序结束。

本书涵盖的内容

第一章,使用 Jasmine 入门,涵盖了测试 JavaScript 应用背后的动机。它介绍了 BDD 的概念以及它是如何帮助你编写更好的测试的。它还演示了下载 Jasmine 并开始编写你的第一个测试是多么简单。

第二章,你的第一个规范,帮助你学习以测试驱动开发为前提的思考过程。你将编写第一个由测试驱动的 JavaScript 功能。你还将学习 Jasmine 的基本功能以及如何构建你的测试。此外,还将演示 Jasmine 匹配器的工作原理以及如何创建你自己的匹配器来提高测试代码的可读性。

第三章,测试前端代码,涵盖了编写可维护浏览器代码的一些模式。你将学习从组件的角度思考以及如何使用模块模式更好地组织你的源文件。你还将了解 HTML 固定装置的概念以及如何使用它来测试你的 JavaScript 代码,而无需服务器渲染 HTML。你还将了解一个名为 jasmine-jquery 的 Jasmine 插件以及它是如何帮助你使用 jQuery 编写更好的测试的。

第四章,异步测试 – AJAX,讨论了测试 AJAX 请求的挑战以及如何使用 Jasmine 测试任何异步代码。你将了解 Node.js 以及如何创建一个非常简单的 HTTP 服务器作为测试的固定装置。

第五章, Jasmine Spies,介绍了测试双胞胎的概念以及如何使用间谍进行行为检查。

第六章, 光速单元测试,帮助你了解 AJAX 测试的问题以及如何使用存根或模拟来加快测试的运行速度。

第七章, 测试 React 应用程序,向您介绍 React,一个用于构建用户界面的库,并涵盖如何使用它来改进第三章中提出的概念,即测试前端代码,以创建更丰富、更易于维护的应用程序,当然,这些应用程序是由测试驱动的。

第八章, 构建自动化,向您展示了自动化的力量。它介绍了 webpack,一个用于前端资源的打包工具。您将开始从模块及其依赖关系的角度思考,并将学习如何将测试编码为模块。您还将了解打包和压缩代码到生产环境以及如何自动化此过程。最后,您将学习如何从命令行运行测试,以及如何在带有Travis.ci的持续集成环境中使用它。

您需要这本书的内容

除了浏览器和文本编辑器之外,运行一些示例的唯一要求是 Node.js 0.10.x。

这本书面向的对象

这本书是针对对单元测试概念的新手网络开发者的必备材料。假设您具备 JavaScript 和 HTML 的基本知识。

约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称应如下显示:“我们可以通过使用include指令来包含其他上下文。”

代码块应如下设置:

describe("Investment", function() {
  it("should be of a stock", function() {
    expect(investment.stock).toBe(stock);
  });
});

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

describe("Investment", function() {
  it("should be of a stock", function() {
    expect(investment.stock).toBe(stock);
  });
});

任何命令行输入或输出都应如下编写:

# npm install --save-dev webpack

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

注意

警告或重要注意事项将以如下方式显示在框中。

小贴士

小贴士和技巧将以如下方式显示。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

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

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

客户支持

现在您已经是 Packt 书籍的骄傲拥有者,我们有许多事情可以帮助您从购买中获得最大收益。

下载示例代码

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

错误清单

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

要查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在错误清单部分显示。

盗版

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

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

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

问题

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

第一章. 使用 Jasmine 入门

对 JavaScript 开发者来说,这是一个激动人心的时代;技术已经成熟,网络浏览器更加标准化,每天都有新事物可以探索。JavaScript 已经成为一门成熟的语言,而网络是当今真正的开放平台。我们见证了单页网络应用的兴起,模型-视图-控制器(MVC)框架,如 Backbone.js 和 AngularJS 的普及,Node.js 在服务器端的 JavaScript 使用,甚至使用 PhoneGap 等技术完全用 HTML、JavaScript 和 CSS 创建的移动应用。

从最初处理 HTML 表单的朴素起点,到今天的大规模应用,JavaScript 语言已经走得很远,与之相伴的是,许多工具已经成熟,以确保你可以像使用任何其他语言一样,使用它达到相同的质量水平。

这本书是关于帮助你控制 JavaScript 开发的工具。

JavaScript – 不好的一面

处理客户端 JavaScript 代码时存在许多复杂问题;最明显的一个问题是,你无法控制客户端的运行时。在服务器端,你可以运行你 Node.js 服务器的特定版本,但你无法强迫你的客户端运行最新版本的 Chrome 或 Firefox。

JavaScript 语言由 ECMAScript 规范定义;因此,每个浏览器都可以有自己的运行时实现,这意味着它们之间可能存在细微的差异或错误。

此外,你还会遇到语言本身的问题。布兰登·艾奇在 Netscape 的巨大管理压力下仅用 10 天就开发了 JavaScript。尽管它在简洁性、一等函数和对象原型方面做得很好,但它也试图使语言具有可塑性并允许其进化,这引入了一些问题。

每个 JavaScript 对象都是可变的;这意味着你无法阻止一个模块覆盖其他模块的部分。以下代码说明了覆盖全局 console.log 函数是多么简单:

console.log('test');
>> 'test'
console.log = 'break';
console.log('test');
>> TypeError: Property 'log' of object #<Console> is not a function

这是在语言设计上做出的一个有意识的决定;它允许开发者对语言进行修改并添加缺失的功能。但鉴于这种强大的能力,犯错相对容易。

ECMA 规范的第五版引入了 Object.seal 函数,它可以在调用后防止对任何对象的进一步更改。但它的当前支持并不广泛;例如,Internet Explorer 只在它的第 9 版中实现了它。

另一个问题,是 JavaScript 处理类型的方式。在其他语言中,像 '1' + 1 这样的表达式可能会引发错误;在 JavaScript 中,由于一些不直观的类型强制规则,上述代码的结果是 '11'。但主要问题是其不一致性;在乘法运算中,字符串会被转换为数字,所以 '3' * 4 实际上是 12

这可能导致在大表达式上出现一些难以发现的问题。假设你从服务器获取了一些数据,尽管你期望得到数字,但其中一个值却是一个字符串:

var a = 1, b = '2', c = 3, d = 4;
var result = a + b + c * d;

前一个示例的结果是字符串'1212'

这些只是开发者面临的两个常见问题。在本书中,你将应用最佳实践并编写测试,以确保你不会陷入这些以及其他陷阱。

Jasmine 和行为驱动开发

Jasmine 是由 Pivotal Labs 的开发者创建的一个小型行为驱动开发(BDD)测试框架,允许你编写自动化的 JavaScript 单元测试。

但在我们继续前进之前,首先我们需要确保一些基本概念的正确性,从测试单元的定义开始。

测试单元是一段代码,用于测试应用程序代码的功能单元。但有时,理解功能单元可能有些棘手,因此,丹·诺斯(Dan North)提出了一个解决方案,即行为驱动开发(BDD),这是对测试驱动开发TDD)的重新思考。

在传统的单元测试实践中,开发者只能得到一些关于如何开始测试过程、测试什么、测试应该有多大或者甚至如何调用测试的模糊指导。

为了解决这些问题,丹从标准的敏捷结构中借鉴了用户故事的概念,作为编写测试的模型。

例如,一个音乐播放器应用程序可能有一个验收标准,如下所示:

Given一个玩家,when歌曲被暂停,then应该指示歌曲当前处于暂停状态。

如以下列表所示,这个验收标准是按照一个基本模式编写的:

  • Given:这提供了一个初始上下文

  • When:这定义了发生的事件

  • Then:这确保了结果

在 Jasmine 中,这转化为一种非常表达性的语言,允许测试以反映实际业务价值的方式编写。前面编写的验收标准作为 Jasmine 测试单元将如下所示:

describe("Player", function() {
  describe("when song has been paused", function() {
    it("should indicate that the song is paused", function() {

    });
  });
});

你可以看到标准如何很好地转换为 Jasmine 语法。在下一章中,我们将详细介绍这些函数的工作原理。

与其他 BDD 框架一样,使用 Jasmine,每个验收标准直接对应一个测试单元。因此,每个测试单元通常被称为spec,即规格的缩写。在本书的整个过程中,我们将使用这个术语。

下载 Jasmine

开始使用 Jasmine 实际上非常简单。

打开 Jasmine 网站jasmine.github.io/2.1/introduction.html#section-Downloads,下载独立发布版(本书中将使用版本 2.1.3)。

当你在 Jasmine 网站上时,你可能会注意到它实际上是一个执行其中包含的规格的实时页面。这是由 Jasmine 框架的简单性所实现的,允许它在最多样化的环境中执行。

在你下载了发行版并解压缩之后,你可以在浏览器中打开 SpecRunner.html 文件。它将显示一个示例测试套件的结果(包括我们之前展示的验收标准):

下载 Jasmine

这显示了在浏览器中打开的 SpecRunner.html 文件

这个 SpecRunner.html 文件是一个 Jasmine 浏览器规格运行器。它是一个简单的 HTML 文件,引用了 Jasmine 代码、源文件和测试文件。为了方便起见,我们将把这个文件简单地称为 运行器

你可以通过在文本编辑器中打开它来看到它的简单性。它是一个小的 HTML 文件,引用了 Jasmine 源代码:

<script src="img/jasmine.js"></script>
<script src="img/jasmine-html.js"></script>
<script src="img/boot.js"></script>

运行器引用了源文件:

<script type="text/javascript" src="img/Player.js"></script>
<script type="text/javascript" src="img/Song.js"></script>

跑步者引用了一个特殊的 SpecHelper.js 文件,该文件包含在规格之间共享的代码:

<script type="text/javascript" src="img/SpecHelper.js"></script>

运行器还引用了规格文件:

<script type="text/javascript" src="img/PlayerSpec.js"></script>

小贴士

下载示例代码

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

Jasmine 框架设置在 lib/jasmine-2.1.3/boot.js 文件中,尽管它是一个庞大的文件,但其中大部分内容是关于设置实际发生情况的文档。建议你在文本编辑器中打开它并研究其内容。

虽然现在我们是在浏览器中运行规格,但在第八章 构建自动化 中,我们将使相同的规格和代码在 无头浏览器(如 PhantomJS)上运行,并将结果写入控制台。

无头浏览器是一个没有图形用户界面的浏览器环境。它可以是实际的浏览器环境,例如使用 WebKit 渲染引擎的 PhantomJS,或者是一个模拟的浏览器环境,例如 Envjs。

尽管本书没有涉及,但 Jasmine 也可以用来测试为 Node.js 等环境编写的服务器端 JavaScript 代码。

这种 Jasmine 的灵活性非常令人惊叹,因为你可以使用相同的工具来测试各种 JavaScript 代码。

摘要

在本章中,你看到了测试 JavaScript 应用程序的某些动机。我向你展示了 JavaScript 语言的常见陷阱以及 BDD 和 Jasmine 如何帮助你编写更好的测试。

你也看到了下载并开始使用 Jasmine 是多么简单。

在下一章中,你将学习如何用 BDD 思考并编写你的第一个规格。

第二章. 你的第一个规范

本章是关于基础知识的,我们将指导你如何编写你的第一个规范,以测试优先的思维方式进行开发,并展示所有可用的全局 Jasmine 函数。到本章结束时,你应该了解 Jasmine 的工作原理,并准备好开始自己进行第一次测试。

投资跟踪器应用程序

为了让你开始,我们需要一个示例场景:假设你正在开发一个用于跟踪股票市场投资的程序。

下面的表格截图说明了用户如何在这个应用程序上创建一个新的投资:

投资跟踪器应用程序

这是一个添加投资的表格

这个表格将允许输入定义投资的三个值:

  • 首先,我们将输入符号,它代表用户正在投资的公司(股票)

  • 然后,我们将输入用户购买的股票数量(或投资的数量)

  • 最后,我们将输入用户为每份股票支付的价格(股票价格

如果你对股市的工作方式不熟悉,想象你正在购买杂货。为了购买,你必须指定你要买什么,你要买多少件,以及你打算支付多少钱。这些概念在投资中可以转化为:

  • 一个由符号定义的股票,例如PETO,可以理解为一种杂货类型

  • 股票数量是你所购买的物品数量

  • 股票价格是每个项目的单价

一旦用户添加了投资,它必须与其他投资一起列出,如下面的屏幕截图所示:

投资跟踪器应用程序

这是一个投资表格和列表

目的是显示他们的投资进展得如何。由于股票价格随时间波动,用户支付的价格与当前价格之间的差额表明这是一项好(盈利)的投资还是一项坏(亏损)的投资。

在前面的屏幕截图中,我们可以看到用户有两个投资:

  • 另一个是AOUE股票,盈利了101.80%

  • 另一个是PETO股票,损失了-42.34%

这是一个非常简单的应用程序,随着我们继续其开发,我们将对其功能有更深入的了解。

Jasmine 基础知识和 BDD 思维

基于之前提出的应用,我们可以开始编写定义投资的验收标准:

  • 对于一项投资,它应该是股票

  • 对于一项投资,它应该有投资的股票数量

  • 对于一项投资,它应该有支付的股票价格

  • 对于一项投资,它应该有一个成本

使用上一章下载的独立分发版,我们首先需要创建一个新的规范文件。此文件可以创建在任何位置,但坚持一种约定是个好主意,Jasmine 已经有一个很好的约定:规范应该放在/spec文件夹中。创建一个InvestmentSpec.js文件,并添加以下行:

describe("Investment", function() {

});

describe函数是一个全局 Jasmine 函数,用于定义测试上下文。当在规范中作为第一个调用时,它创建一个新的测试套件(一组测试用例)。它接受两个参数,如下所述:

  • 测试套件的名称——在这个例子中,是Investment

  • 一个将包含所有其规范的function

然后,为了将第一个验收标准(给定一项投资,它应该是股票)翻译成 Jasmine 规范(或测试用例),我们将使用另一个全局 Jasmine 函数it

describe("Investment", function() {
  it("should be of a stock", function() {

  });
});

它也接受两个参数,如下所述:

  • 规范的标题——在这个例子中,是should be of a stock

  • 一个将包含规范代码的function

要运行此规范,将其添加到运行器中,如下所示:

<!-- include spec files here... -->
<script type="text/javascript" src="img/InvestmentSpec.js"></script>

通过在浏览器上打开运行器来执行规范。可以看到以下输出:

Jasmine 基础和 BDD 思维

这是第一个规范在浏览器上的通过结果

空规范通过可能听起来很奇怪,但在 Jasmine 中,就像其他测试框架一样,需要失败的断言来使规范失败。

断言(或期望)是两个值之间的比较,必须得到一个布尔值。只有当比较的结果为真时,断言才被认为是成功的。

在 Jasmine 中,断言是通过全局 Jasmine 函数expect以及一个匹配器来编写的,该匹配器指示必须对值进行何种比较。

关于当前规范(预期这是一项股票投资),在 Jasmine 中这会转换为以下代码:

describe("Investment", function() {
  it("should be of a stock", function() {
    expect(investment.stock).toBe(stock);
  });
});

将前面高亮的代码添加到InvestmentSpec.js文件中。expect函数只接受一个参数,该参数定义了实际值,换句话说,将要测试的内容——investment.stock——并期望链式调用到一个匹配器函数,在这种情况下是toBe。这定义了预期值stock以及要执行的比较方法(相同)。

在幕后,Jasmine 会进行一个比较,检查实际值(investment.stock)和预期值(stock)是否相同,如果不相同,测试就会失败。

写下断言后,之前通过的规范现在失败了,如下截图所示:

Jasmine 基础和 BDD 思维

这显示了第一个规范的失败结果

这个规范失败了,因为,正如错误信息所述,investment未定义。

这里的主要思想是只做错误指示我们做的事情,所以尽管你可能想写其他内容,但现在让我们先在InvestmentSpec.js文件中创建一个名为investmentInvestment实例变量,如下所示:

describe("Investment", function() {
  it("should be of a stock", function() {
    var investment = new Investment();
    expect(investment.stock).toBe(stock);
  });
});

不要担心Investment()函数还不存在;规范将在下一次运行时请求它,如下所示:

Jasmine 基础和基于 BDD 的思考

这里规范要求一个Investment

你可以看到错误已经变成了Investment is not defined。现在它要求Investment函数。因此,在src文件夹中创建一个新的Investment.js文件,并将其添加到运行器中,如下面的代码所示:

<!-- include source files here... -->
<script type="text/javascript" src="img/Investment.js"></script>

为了定义Investment,在src文件夹内的Investment.js文件中写入以下构造函数:

function Investment () {};

这使得错误改变。现在它抱怨缺少stock变量,如下面的截图所示:

Jasmine 基础和基于 BDD 的思考

这显示了缺少股票的错误

再次,我们将它请求的代码放入InvestmentSpec.js文件中,如下面的代码所示:

describe("Investment", function() {
  it("should be of a stock", function() {
    var stock = new Stock();
    var investment = new Investment();
    expect(investment.stock).toBe(stock);
  });
});

错误再次改变;这次是关于缺少Stock函数:

Jasmine 基础和基于 BDD 的思考

这里规范要求一个Stock

src文件夹中创建一个新的文件,命名为Stock.js,并将其添加到运行器中。由于Stock函数将是Investment的依赖项,我们应该在Investment.js之前添加它:

<!-- include source files here... -->
<script type="text/javascript" src="img/Stock.js"></script>
<script type="text/javascript" src="img/Investment.js"></script>

Stock构造函数写入Stock.js文件:

function Stock () {};

最后,错误是关于预期的,如下面的截图所示:

Jasmine 基础和基于 BDD 的思考

预期未定义,应为Stock

为了修复这个问题并完成这个练习,打开src文件夹内的Investment.js文件,并添加对stock参数的引用:

function Investment (stock) {
  this.stock = stock;
};

在规范文件中,将stock作为参数传递给Investment函数:

describe("Investment", function() {
  it("should be of a stock", function() {
    var stock = new Stock();
    var investment = new Investment(stock);
    expect(investment.stock).toBe(stock);
  });
});

最后,你将有一个通过规范:

Jasmine 基础和基于 BDD 的思考

这显示了通过的投资规范

这个练习被精心设计,以展示开发者如何在测试驱动开发中通过向规范提供所需的内容来工作。

小贴士

编写代码的动力必须来自失败的规范。除非其目的是修复失败的规范,否则你不应该编写代码。

设置和清理

还有三个更多可接受的标准需要实现。列表中的下一个如下:

"给定一个投资,它应该有已投资股份的数量。"

编写它应该和之前的规范一样简单。在spec文件夹内的InvestmentSpec.js文件中,你可以将这个新标准转换成一个新的规范,称为should have the invested shares' quantity,如下所示:

describe("Investment", function() {
  it("should be of a stock", function() {
    var stock = new Stock();
    var investment = new Investment({
      stock: stock,
      shares: 100
    });
    expect(investment.stock).toBe(stock);
  });

  it("should have the invested shares' quantity", function() {
 var stock = new Stock();
 var investment = new Investment({
 stock: stock,
 shares: 100
 });
 expect(investment.shares).toEqual(100);
 });
});

你可以看到,除了编写了新的规范外,我们还更改了对Investment构造函数的调用,以支持新的shares参数。

要做到这一点,我们在构造函数中使用了对象作为单个参数来模拟命名参数,这是 JavaScript 本身不具备的功能。

Investment函数中实现这一点相当简单——在函数声明上不再有多个参数,只有一个,它期望是一个对象。然后,函数从这个对象中探测每个期望的参数,进行适当的分配,如下所示:

function Investment (params) {
  this.stock = params.stock;
};

代码现在已重构。我们可以运行测试来查看只有新的规格失败,如下所示:

设置和拆卸

这显示了失败的份额规格

为了修复这个问题,将Investment构造函数更改为将分配给shares属性,如下所示:

function Investment (params) {
  this.stock = params.stock;
  this.shares = params.shares;
};

最后,屏幕上的所有内容都是绿色的:

设置和拆卸

这显示了通过股票的份额规格

但正如你所看到的,以下代码,它实例化了StockInvestment,在两个规格中都是重复的:

var stock = new Stock();
var investment = new Investment({
  stock: stock,
  shares: 100
});

为了消除这种重复,Jasmine 提供了一个名为beforeEach的另一个全局函数,正如其名称所示,它在每个规格之前执行一次。因此,对于这两个规格,它将运行两次——在每个规格之前各运行一次。

通过使用beforeEach函数提取设置代码来重构先前的规格:

describe("Investment", function() {
  var stock, investment;

  beforeEach(function() {
    stock = new Stock();
    investment = new Investment({
      stock: stock,
      shares: 100
    });
  });

  it("should be of a stock", function() {
    expect(investment.stock).toBe(stock);
  });

  it("should have the invested shares quantity", function() {
    expect(investment.shares).toEqual(100);
  });
});

这看起来更干净;我们不仅消除了代码重复,还简化了规格。由于它们现在的唯一责任是满足期望,因此它们变得更容易阅读和维护。

此外,还有一个拆卸函数(afterEach),它在每个规格之后执行代码。在需要在每个规格之后进行清理的情况下非常有用。我们将在第六章中看到其应用的示例,光速单元测试

要完成Investment的规格说明,请将剩余的两个规格添加到spec文件夹中的InvestmentSpec.js文件中:

describe("Investment", function() {
  var stock;
  var investment;

  beforeEach(function() {
    stock = new Stock();
    investment = new Investment({
      stock: stock,
      shares: 100,
      sharePrice: 20
    });
  });

  //... other specs

  it("should have the share paid price", function() {
    expect(investment.sharePrice).toEqual(20);
  });

  it("should have a cost", function() {
    expect(investment.cost).toEqual(2000);
  });
});

运行规格以查看它们失败,如下面的截图所示:

设置和拆卸

这显示了失败的代价和价格规格

将以下代码添加到src文件夹中的Investment.js文件中,以修复它们:

function Investment (params) {
  this.stock = params.stock;
  this.shares = params.shares;
  this.sharePrice = params.sharePrice;
  this.cost = this.shares * this.sharePrice;
};

最后一次运行规格以查看它们通过:

设置和拆卸

这显示了所有四个投资规格都通过

小贴士

在编写修复代码之前始终看到规格失败是很重要的;否则,你怎么知道你真的需要修复它?想象一下这是一种测试测试的方法。

嵌套描述

嵌套描述在您想要描述规格之间相似行为时很有用。假设我们想要以下两个新的验收标准:

  • 给定一个投资,当其股票股价增值时,它应该有一个正的投资回报率ROI

  • 给定一个投资,当其股票股价增值时,它应该是一个好的投资

这两个标准在投资的股票股价增值时具有相同的行为。

要将此转换为 Jasmine,你可以在InvestmentSpec.js文件中嵌套对describe函数的调用(为了演示目的,我移除了其余代码;它仍然在那里):

describe("Investment", function()
  describe("when its stock share price valorizes", function() {

  });
});

它应该表现得像外部的一个,因此你可以添加规范(it)并使用设置和清理函数(beforeEachafterEach)。

设置和清理

当使用设置和清理函数时,Jasmine 也会尊重外部的设置和清理函数,以便它们按预期运行。对于每个规范(it),执行以下操作:

  • Jasmine 从外向内运行所有设置函数(beforeEach

  • Jasmine 运行规范代码(it

  • Jasmine 从内向外运行所有清理函数(afterEach

因此,我们可以向这个新的describe函数添加一个设置函数,该函数会改变股票的股价,使其高于投资的股价:

describe("Investment", function() {
  var stock;
  var investment;

  beforeEach(function() {
    stock = new Stock();
    investment = new Investment({
      stock: stock,
      shares: 100,
      sharePrice: 20
    });
  });

  describe("when its stock share price valorizes", function() {
    beforeEach(function() {
      stock.sharePrice = 40;
    });
  });
});

使用共享行为编写规范

现在我们已经实现了共享行为,我们可以开始编写之前描述的验收标准。每个都是,就像之前一样,对全局 Jasmine 函数it的调用:

describe("Investment", function() {
  describe("when its stock share price valorizes", function() {
    beforeEach(function() {
      stock.sharePrice = 40;
    });

    it("should have a positive return of investment", function() {
      expect(investment.roi()).toEqual(1);
    });

    it("should be a good investment", function() {
      expect(investment.isGood()).toEqual(true);
    });
  });
});

Investment.js文件中添加缺失的功能后:

Investment.prototype.roi = function() {
  return (this.stock.sharePrice - this.sharePrice) / this.sharePrice;
};

Investment.prototype.isGood = function() {
  return this.roi() > 0;
};

你可以运行规范并查看它们是否通过:

使用共享行为编写规范

这显示了嵌套的描述规范传递

理解匹配器

到现在为止,你已经看到了许多匹配器的使用示例,可能已经感受到了它们是如何工作的。

你已经看到了如何使用toBetoEqual匹配器。这些是 Jasmine 中可用的两个基本内置匹配器,但我们可以通过编写自己的匹配器来扩展 Jasmine。

因此,为了真正理解 Jasmine 匹配器是如何工作的,我们需要自己创建一个。

自定义匹配器

考虑上一节中的这个期望:

expect(investment.isGood()).toEqual(true);

虽然它工作,但表达性不强。想象一下,如果我们能将其重写为:

expect(investment).toBeAGoodInvestment();

这与验收标准建立了更好的关系:

因此,这里“应该是一个好的投资”变为“期望投资是一个好的投资”。

实现它相当简单。你可以通过调用jasmine.addMatchers函数来完成,理想情况下在设置步骤(beforeEach)中。

虽然你可以将这个新的匹配器定义放在InvestmentSpec.js文件中,但 Jasmine 已经在spec文件夹内的SpecHelper.js文件中提供了一个默认位置来添加自定义匹配器。如果你使用的是独立发行版,它已经包含了一个示例自定义匹配器;删除它,让我们从头开始。

addMatchers函数接受一个单一参数——一个对象,其中每个属性都对应一个新匹配器。因此,要添加以下新匹配器,将SpecHelper.js文件的内容更改为以下内容:

beforeEach(function() {
  jasmine.addMatchers({
    toBeAGoodInvestment: function() {}
  });
});

在这里定义的函数不是匹配器本身,而是一个用于构建匹配器的工厂函数。一旦调用,它的目的是返回一个包含比较函数的对象,如下所示:

jasmine.addMatchers({
  toBeAGoodInvestment: function () {
    return {
 compare: function (actual, expected) {
 // matcher definition
 }
    };
  }
});

compare函数将包含实际的匹配器实现,并且可以通过其签名观察到,它接收两个被比较的值(actualexpected值)。

对于给定的示例,investment对象将在actual参数中可用。

然后,Jasmine 期望,作为此compare函数的结果,一个具有pass属性的布尔值true的对象,以指示期望通过,如果期望失败则为false

让我们看看以下toBeAGoodInvestment匹配器的有效实现:

toBeAGoodInvestment: function () {
  return {
    compare: function (actual, expected) {
      var result = {};
 result.pass = actual.isGood();
 return result;
    }
  };
}

到目前为止,这个匹配器已经准备好供规格说明使用:

it("should be a good investment", function() {
  expect(investment).toBeAGoodInvestment();
});

在更改后,规格说明应该仍然通过。但如果一个规格说明失败了怎么办?Jasmine 报告的错误信息是什么?

我们可以通过故意在src文件夹中的Investment.js文件中打破investment.isGood的实现,使其始终返回false来看到这一点:

Investment.prototype.isGood = function() {
  return false;
};

再次运行规格说明时,Jasmine 生成一个错误信息,指出Expected { stock: { sharePrice: 40 }, shares: 100, sharePrice: 20, cost: 2000 } to be a good investment,如下面的截图所示:

自定义匹配器

这是自定义匹配器的消息

Jasmine 在生成此错误信息方面做得很好,但它还允许通过匹配器返回的对象的result.message属性进行自定义。Jasmine 期望此属性是一个包含以下错误信息的字符串:

toBeAGoodInvestment: function () {
  return {
    compare: function (actual, expected) {
      var result = {};
      result.pass = actual.isGood();
      result.message = 'Expected investment to be a good investment';
      return result;
    }
  };
}

再次运行规格说明,错误信息应该会改变:

自定义匹配器

这是自定义匹配器的自定义消息

现在,让我们考虑另一个验收标准:

"给定一项投资,当其股票股价贬值时,它应该是一项不良投资。"

虽然可以创建一个新的自定义匹配器(toBeABadInvestment),但 Jasmine 允许通过在匹配器调用之前链式not来否定任何匹配器。因此,我们可以写出“一项不良投资”是“不是一个好投资”。

expect(investment).not.toBeAGoodInvestment();

通过在spec文件夹内的InvestmentSpec.js文件中添加新的嵌套describespec,实现这个新的验收标准,如下所示:

describe("when its stock share price devalorizes", function() {
  beforeEach(function() {
    stock.sharePrice = 0;
  });

  it("should have a negative return of investment", function() {
    expect(investment.roi()).toEqual(-1);
  });

  it("should be a bad investment", function() {
    expect(investment).not.toBeAGoodInvestment();
  });
});

但有一个问题!让我们打破Investment.js文件中的investment实现代码,使其始终是一项良好的投资,如下所示:

Investment.prototype.isGood = function() {
  return true;
};

再次运行规格说明后,你可以看到这个新的规格说明失败了,但错误信息Expected investment to be a good investment是错误的,如下面的截图所示:

自定义匹配器

这是自定义匹配器的错误自定义否定消息

那是匹配器内部硬编码的消息。要修复它,你需要使消息动态化。

Jasmine 仅在匹配器失败时显示消息,因此使此消息动态化的正确方法是在给定比较无效时考虑应显示什么消息:

compare: function (actual, expected) {
  var result = {};
  result.pass = actual.isGood();

 if (actual.isGood()) {
 result.message = 'Expected investment to be a bad investment';
 } else {
 result.message = 'Expected investment to be a good investment';
 }

  return result;
}

如下面的截图所示,这修复了消息:

自定义匹配器

这显示了自定义匹配器的自定义动态消息

现在,这个匹配器可以在任何地方使用。

在继续本章之前,将isGood方法再次更改为正确的实现:

Investment.prototype.isGood = function() {
  return this.roi() > 0;
};

这个例子缺少的是展示如何将期望值传递给像这样的匹配器的方法:

expect(investment.cost).toBe(2000)

结果表明,匹配器可以接收任何数量的期望值作为参数。所以,例如,前面的匹配器可以在SpecHelper.js文件中实现,在spec文件夹内,如下所示:

beforeEach(function() {
  jasmine.addMatchers({
    toBe: function () {
      return {
        compare: function (actual, expected) {
          return actual === expected;
        }
      };
    }
  });
});

通过实现任何匹配器,首先检查是否已经有一个可以完成你想要的功能的匹配器。

更多信息,请查看 Jasmine 网站上的官方文档jasmine.github.io/2.1/custom_matcher.html

内置匹配器

Jasmine 附带了一些默认匹配器,涵盖了 JavaScript 语言中值检查的基础。要了解它们是如何工作的以及在哪里正确使用它们,就是了解 JavaScript 如何处理类型的旅程。

toEqual内置匹配器

toEqual匹配器可能是最常用的匹配器,每次你想检查两个值之间的相等性时都应该使用它。

它适用于所有原始值(数字、字符串和布尔值)以及任何对象(包括数组),如下面的代码所示:

describe("toEqual", function() {
  it("should pass equal numbers", function() {
    expect(1).toEqual(1);
  });

  it("should pass equal strings", function() {
    expect("testing").toEqual("testing");
  });

  it("should pass equal booleans", function() {
    expect(true).toEqual(true);
  });

  it("should pass equal objects", function() {
    expect({a: "testing"}).toEqual({a: "testing"});
  });

  it("should pass equal arrays", function() {
    expect([1, 2, 3]).toEqual([1, 2, 3]);
  });
});

toBe内置匹配器

toBe匹配器的行为与toEqual匹配器非常相似;事实上,在比较原始值时,它们给出相同的结果,但相似之处到此为止。

虽然toEqual匹配器有一个复杂的实现(你应该看看 Jasmine 的源代码),它会检查一个对象的所有属性和数组的所有元素是否相同,但这里它只是简单使用了严格的等于操作符===)。

如果你不太熟悉严格的等于操作符,它与等于操作符==)的主要区别在于后者如果比较的值不是同一类型,则会执行类型转换。

提示

严格的等于操作符始终认为不同类型的值之间的比较是 false。

这里有一些这个匹配器(以及严格的等于操作符)的工作示例:

describe("toBe", function() {
  it("should pass equal numbers", function() {
    expect(1).toBe(1);
  });

  it("should pass equal strings", function() {
    expect("testing").toBe("testing");
  });

  it("should pass equal booleans", function() {
    expect(true).toBe(true);
  });

  it("should pass same objects", function() {
    var object = {a: "testing"};
    expect(object).toBe(object);
  });

  it("should pass same arrays", function() {
    var array = [1, 2, 3];
    expect(array).toBe(array);
  });

  it("should not pass equal objects", function() {
    expect({a: "testing"}).not.toBe({a: "testing"});
  });

  it("should not pass equal arrays", function() {
    expect([1, 2, 3]).not.toBe([1, 2, 3]);
  });
});

建议你在大多数情况下使用toEqual操作符,只有在你想检查两个变量是否引用同一个对象时才使用toBe匹配器。

toBeTruthytoBeFalsy匹配器

除了 JavaScript 语言中的原始布尔类型之外,其他所有内容也都有固有的布尔值,通常被认为是truthyfalsy

幸运的是,在 JavaScript 中,只有少数值被识别为 falsy,以下是一些toBeFalsy匹配器的示例:

describe("toBeFalsy", function () {
  it("should pass undefined", function() {
    expect(undefined).toBeFalsy();
  });

  it("should pass null", function() {
    expect(null).toBeFalsy();
  });

  it("should pass NaN", function() {
    expect(NaN).toBeFalsy();
  });

  it("should pass the false boolean value", function() {
    expect(false).toBeFalsy();
  });

  it("should pass the number 0", function() {
    expect(0).toBeFalsy();
  });

  it("should pass an empty string", function() {
    expect("").toBeFalsy();
  });
});

其他所有内容都被认为是 truthy,如下面的toBeTruthy匹配器的示例所示:

describe("toBeTruthy", function() {
  it("should pass the true boolean value", function() {
    expect(true).toBeTruthy();
  });

  it("should pass any number different than 0", function() {
    expect(1).toBeTruthy();
  });
  it("should pass any non empty string", function() {
    expect("a").toBeTruthy();
  });

  it("should pass any object (including an array)", function() {
    expect([]).toBeTruthy();
    expect({}).toBeTruthy();
  });
});

但是,如果你想检查某个值是否等于实际的布尔值,使用toEqual匹配器可能是个更好的主意。

内置的toBeUndefinedtoBeNulltoBeNaN匹配器

这些匹配器非常直接,应用于检查undefinednullNaN值:

describe("toBeNull", function() {
  it("should pass null", function() {
    expect(null).toBeNull();
  });
});

describe("toBeUndefined", function() {
  it("should pass undefined", function() {
    expect(undefined).toBeUndefined();
  });
});

describe("toBeNaN", function() {
  it("should pass NaN", function() {
    expect(NaN).toBeNaN();
  });
});

toBeNulltoBeUndefined都可以写成toBe(null)toBe(undefined),但toBeNaN不是这样。

在 JavaScript 中,NaN值不等于任何值,甚至不等于NaN。因此,尝试将其与自身比较总是返回false,如下面的代码所示:

NaN === NaN // false

作为良好实践,尽可能使用这些匹配器而不是它们的toBe对应物。

内置的toBeDefined匹配器

如果您只想检查一个变量是否已定义,而不关心其值,则此匹配器很有用,如下所示:

describe("toBeDefined", function() {
  it("should pass any value other than undefined", function() {
    expect(null).toBeDefined();
  });
});

除了undefined之外,任何东西都会通过此匹配器,即使是null

内置的toContain匹配器

有时,检查数组是否包含元素,或者字符串是否可以在另一个字符串中找到是有用的。对于这些用例,您可以使用toContain匹配器,如下所示:

describe("toContain", function() {
  it("should pass if a string contains another string", function()  {
    expect("My big string").toContain("big");
  });

  it("should pass if an array contains an element", function() {
    expect([1, 2, 3]).toContain(2);
  });
});

内置的toMatch匹配器

虽然toContaintoEqual匹配器可以在大多数字符串比较中使用,但有时唯一断言字符串值是否正确的方法是通过正则表达式。对于这些情况,您可以使用toMatch匹配器与正则表达式一起使用,如下所示:

describe("toMatch", function() {
  it("should pass a matching string", function() {
    expect("My big matched string").toMatch(/My(.+)string/);
  });
});

此匹配器通过将实际值("My big matched string")与预期正则表达式(/My(.+)string/)进行比较来工作。

内置的toBeLessThantoBeGreaterThan匹配器

toBeLessThantoBeGreaterThan匹配器简单,用于执行数值比较——这最好通过以下示例来描述:

  describe("toBeLessThan", function() {
    it("should pass when the actual is less than expected", function() {
      expect(1).toBeLessThan(2);
    });
  });

  describe("toBeGreaterThan", function() {
    it("should pass when the actual is greater than expected", function() {
      expect(2).toBeGreaterThan(1);
    });
  });

内置的toBeCloseTo匹配器

这是一个特殊的匹配器,用于比较具有定义精度的浮点数——这最好通过以下示例来解释:

describe("toBeCloseTo", function() {
    it("should pass when the actual is closer with a given precision", function() {
      expect(3.1415).toBeCloseTo(2.8, 0);
      expect(3.1415).not.toBeCloseTo(2.8, 1);
    });
  });

第一个参数是要比较的数字,第二个参数是数字的小数位数精度。

内置的toThrow匹配器

异常是语言展示出错的方式。

例如,在编写 API 时,您可能会决定在参数传递错误时抛出异常。那么,您如何测试这段代码呢?

Jasmine 内置的toThrow匹配器可以用来验证是否抛出了异常。

它的工作方式与其他匹配器略有不同。由于匹配器必须运行一段代码并检查是否抛出异常,因此匹配器的实际值必须是一个函数。

下面是一个如何工作的例子:

describe("toThrow", function() {
  it("should pass when the exception is thrown", function() {
    expect(function () {
      throw "Some exception";
    }).toThrow("Some exception");
  });
});

当测试运行时,匿名函数被执行,如果它抛出Some exception异常,则测试通过。

概述

本章中,你学习了如何用行为驱动开发(BDD)的思维来驱动你的代码从你的规范出发。你还熟悉了 Jasmine 的基本全局函数(describeitbeforeEachafterEach),并对在 Jasmine 中创建规范所需的内容有了良好的理解。

你已经熟悉了 Jasmine 匹配器,并知道它们在描述规范意图方面的强大功能。你甚至学会了创建自己的匹配器。

到现在为止,你应该已经熟悉了创建新的规范并驱动你新应用程序的开发。

在下一章中,我们将探讨如何利用本章学到的概念来开始测试网络应用程序,这些应用程序最常见的是 jQuery 和 HTML 表单。

第三章:测试前端代码

测试 JavaScript 浏览器代码一直被认为很难,尽管在处理跨浏览器测试时有许多复杂性,但最常见的问题不是测试过程,而是应用代码本身不可测试。

由于浏览器文档中的每个元素都是全局可访问的,因此很容易编写一个处理整个页面的单一 JavaScript 代码块。这导致了一系列问题,其中最大的问题是很难进行测试。

在本章中,我们将了解如何编写可维护和可测试的浏览器代码的最佳实践。

为了实现用户界面,我们将使用 jQuery,这是一个知名的 JavaScript 库,它通过一个干净简单的 API 抽象了浏览器的 DOM,并在不同的浏览器中工作。

为了使编写规格说明更容易,我们将使用 Jasmine jQuery,这是一个向 Jasmine 添加新匹配器的 Jasmine 扩展,用于对 jQuery 对象进行断言。为了安装它及其 jQuery 依赖项,请下载以下文件:

将这些文件分别保存为jasmine-jquery.jsjquery.js,存放在lib文件夹中,并将它们添加到SpecRunner.html中,如下所示:

<script src="img/jquery.js"></script>
<script src="img/jasmine-jquery.js"></script>

如至今所见,我们已创建了处理投资及其相关股票的独立抽象。现在,是时候开发这个应用的用户界面并取得良好的结果了,这完全是组织良好和良好实践的问题。

在编写前端 JavaScript 代码时,我们应用于服务器端代码的软件工程原则不应被忽视。考虑组件和适当的关注点分离仍然很重要。

从组件(视图)的角度思考

我们已经讨论了困扰大多数 Web 应用的单一 JavaScript 代码库,这些代码库是无法测试的。避免陷入这个陷阱的最佳方法是通过测试驱动的应用开发来编写代码。

考虑我们的投资跟踪应用的原型界面:

从组件(视图)的角度思考

这展示了投资跟踪应用的原型界面

我们将如何实现它?很容易看出这个应用有两个不同的职责:

  • 一个职责是添加投资

  • 另一个职责是列出已添加的投资

因此,我们可以从将这个界面拆分为两个不同的组件开始。为了更好地描述它们,我们将借鉴MVC 框架(如Backbone.js)的概念,并称它们为视图

所以,这就是它,在接口的最高级别,有两个基本组件:

  • NewInvestmentView: 这将负责创建新的投资

  • InvestmentListView: 这将是一个所有添加的投资列表

模块模式

因此,我们理解了如何拆分代码,但我们应该如何组织它?到目前为止,我们为每个新函数创建了一个文件。这是一个好的做法,我们将看到我们如何改进这一点。

让我们先思考一下我们的NewInvestmentView组件。我们可以遵循到目前为止使用的模式,创建一个新的文件,NewInvestmentView.js,并将其放置在src文件夹中,如下所示:

(function ($, Investment, Stock) {
  function NewInvestmentView (params) {

  }

  this.NewInvestmentView = NewInvestmentView;
})(jQuery, Investment, Stock);

你可以看到,这个 JavaScript 文件比到目前为止展示的例子更健壮。我们已经在立即调用的函数表达式IIFE)中包装了所有的NewInvestmentView代码。

它被称为 IIFE,因为它声明了一个函数并立即调用它,有效地创建了一个新的作用域来声明局部变量。

一个好的做法是在 IIFE 内部只使用局部变量。如果它需要使用全局依赖项,可以通过参数传递。在这个例子中,它已经将三个依赖项传递给NewInvestmentView代码:jQueryInvestmentStock

你可以在函数声明中看到这一点:

function ($, Investment, Stock)

立即调用:

})(jQuery, Investment, Stock);

这种做法的最大优点是,我们不再需要担心污染全局命名空间,因为我们声明的所有内容都在 IIFE 内部是局部的。这使得干扰全局作用域变得更加困难。

如果我们需要使任何内容成为全局的,我们可以通过将全局对象附加来实现,如下所示:

this.NewInvestmentView = NewInvestmentView;

另一个优点是显式依赖项声明。通过查看文件的第一行,我们可以了解一个文件的所有外部依赖项。

虽然这种做法现在没有很大的优势(因为所有组件都被公开为全局的),但我们将看到如何在第八章 构建自动化 中从中受益。

这种模式也被称为模块模式,我们将在本书的其余部分使用它(尽管有时为了简化目的会省略)。

使用 HTML fixtures

继续开发NewInvestmentView组件,我们可以编写一些基本的验收标准,如下所示:

  • NewInvestmentView应允许输入股票代码

  • NewInvestmentView应允许输入股份

  • NewInvestmentView应允许输入股价

还有更多,但这是一个好的开始。

spec文件夹中的新文件NewInvestmentViewSpec.js中为这个组件创建一个新的规范文件,然后我们可以开始翻译这些规范,如下所示:

describe("NewInvestmentView", function() {
  it("should allow the input of the stock symbol", function() {
  });

  it("should allow the input of shares", function() {
  });

  it("should allow the input of the share price", function() {
  });
});

然而,在我们开始实施这些之前,我们首先必须理解HTML fixtures的概念。

测试固定文件提供了测试运行的基态。这可能是一个类的实例化、对象的定义或一段 HTML。换句话说,为了测试处理表单提交的 JavaScript 代码,在运行测试时我们需要有可用的表单。包含表单的 HTML 代码是一个 HTML 固定文件。

处理此要求的一种方法是在设置函数中手动附加所需的 DOM 元素,如下所示:

beforeEach(function() {
  $('body').append('<form id="my-form"></form>');
});

然后,在拆卸过程中将其移除,如下所示:

afterEach(function() {
  $('#my-form').remove();
});

否则,规格可能会在文档内部附加大量垃圾,这可能会干扰其他规格的结果。

小贴士

重要的是要知道,规格应该是独立的,并且它们可以以任何特定的顺序运行。因此,通常情况下,将规格完全独立于彼此处理。

更好的方法是,在文档中有一个容器,我们总是将 HTML 固定文件放在那里,如下所示:

<div id="html-fixtures">
</div>

将代码更改为以下内容:

beforeEach(function() {
  $('#html-fixtures').html('<form id="my-form"></form>');
});

这样,下次运行规格时,它会自动用其自己的内容覆盖之前的固定文件。

但是,随着固定文件变得更加复杂,这可能会很快变成难以理解的混乱:

beforeEach(function() {
  $('#html-fixtures').html('<form id="new-investment"><h1>New  investment</h1><label>Symbol:<input type="text" class="new-investment-stock-symbol" name="stockSymbol"  value=""></label><input type="submit" name="add"  value="Add"></form>');
});

如果这个固定文件可以从外部文件加载,那岂不是很好?这正是 Jasmine jQuery 扩展程序通过其 HTML 固定 模块所做的事情。

我们可以将该 HTML 代码放在一个外部文件中,并通过简单的 loadFixtures 调用将其加载到文档中,如下所示:

beforeEach(function() {
  loadFixtures('MyFixture.html');
});

默认情况下,扩展程序会在 spec/javascripts/fixtures 文件夹内查找文件(对于前面的示例,将是 spec/javascripts/fixtures/MyFixture.html),并在容器内加载其内容,如下所示:

<div id="jasmine-fixtures">
  <form id="new-investment">
    <h1>New investment</h1>
    <label>
      Symbol:
      <input type="text" class="new-investment-stock-symbol" name="stockSymbol" value="">
    </label>
    <input type="submit" name="add" value="Add">
  </form>
</div>

我们还可以使用扩展程序的另一个全局函数来重新创建第一个示例。setFixtures(html) 函数接受一个参数,其中包含要放置在容器中的内容:

beforeEach(function() {
  setFixtures('<form id="my-form"></form>');
});

其他可用的函数如下:

  • appendLoadFixtures(fixtureUrl[, fixtureUrl, …]):而不是覆盖固定容器的内容,它会将其附加到内容上

  • readFixtures(fixtureUrl[, fixtureUrl, …]):这个函数读取一个固定容器的内容,但不是将其附加到文档中,而是返回一个包含其内容的字符串

  • appendSetFixtures(html):这与 appendLoadFixtures 相同,但使用 HTML 字符串而不是文件

Jasmine jQuery 固定模块缓存每个文件,因此我们可以在测试套件的速率下多次加载相同的固定文件,而不会受到惩罚。

它使用 AJAX 加载固定文件,有时,一个测试可能想要修改 JavaScript 或 jQuery AJAX 的内部工作方式,正如我们将在第六章(“Light Speed Unit Testing”)中看到的,这可能会破坏固定文件的加载。解决此问题的方法是使用 preloadFixtures() 函数在缓存中预加载所需的固定文件。

preloadFixtures(fixtureUrl[, fixtureUrl, …]) 函数在缓存中加载一个或多个文件,但不将它们附加到文档中。

然而,在使用 HTML 时存在一个问题。Jasmine jQuery 使用 AJAX 加载 HTML 固定装置,但由于同源策略SOP),现代浏览器在用file://协议打开SpecRunner.html时将阻止所有 AJAX 请求。

解决这个问题的方法是通过 HTTP 服务器提供 spec 运行器,如第四章异步测试 – AJAX 中所述。

目前,在 Chrome 中,可以通过命令行界面CLI)参数--allow-file-access-from-files找到一个解决方案。

例如,在 Mac OS X 中,需要以下 bash 命令来以该标志打开 Chrome:

$ open "Google Chrome.app" --args --allow-file-access-from-files

更多关于这个问题的细节可以在 GitHub 问题跟踪github.com/velesin/jasmine-jquery/issues/4中查看。

回到NewInvestmentView组件,我们可以借助这个 HTML 固定装置插件开始 spec 的开发。

spec文件夹内创建一个名为fixtures的文件夹。根据模拟界面,我们可以在fixtures文件夹内创建一个新的 HTML 固定装置,名为NewInvestmentView.html,如下所示:

<form id="new-investment">
  <h1>New investment</h1>
  <label>
    Symbol:
    <input type="text" class="new-investment-stock-symbol" name="stockSymbol" value="">
  </label>
  <label>
    Shares:
    <input type="number" class="new-investment-shares" name="shares" value="0">
  </label>
  <label>
    Share price:
    <input type="number" class="new-investment-share-price" name="sharePrice" value="0">
  </label>
  <input type="submit" name="add" value="Add">
</form>

这是一个 HTML 固定装置,因为它否则会被服务器渲染,JavaScript 代码将简单地附加到它并添加行为。

由于我们没有在插件的默认路径上保存这个固定装置,我们需要在SpecHelper.js文件的末尾添加一个新的配置,如下所示:

jasmine.getFixtures().fixturesPath = 'spec/fixtures';

NewInvestmentSpec.js文件中,添加一个调用以加载固定装置:

describe("NewInvestmentView", function() {
  beforeEach(function() {
    loadFixtures('NewInvestmentView.html');
  });
});

最后,在添加Stock.jsInvestment.js文件之后,将 spec 和源代码添加到运行器中,如下所示:

<script src="img/NewInvestmentView.js"></script>
<script src="img/NewInvestmentViewSpec.js"></script>

基本视图编码规则

现在,是时候开始编写第一个视图组件的代码了。为了帮助我们完成这个过程,我们将为视图编码制定两个基本规则:

  • 视图应该封装一个 DOM 元素

  • 将视图与观察者集成

因此,让我们看看它们是如何单独工作的。

视图应该封装一个 DOM 元素

如前所述,视图是与 DOM 元素关联的行为,因此将此元素与视图相关联是有意义的。一个好的模式是在视图实例化时传递一个 CSS选择器,以指示它应该引用的元素。以下是NewInvestmentView组件的 spec:

describe("NewInvestmentView", function() {
  var view;
  beforeEach(function() {
    loadFixtures('NewInvestmentView.html');
    view = new NewInvestmentView({
      selector: '#new-investment'
    });
  });
});

NewInvestmentView.js文件的构造函数中,它使用 jQuery 获取此选择器的元素并将其存储在实例变量$element(来源)中,如下所示:

function NewInvestmentView (params) {
  this.$element = $(params.selector);
}

为了确保这段代码能正常工作,我们应该在NewInvestmentViewSpec.js文件中为它编写以下测试:

it("should expose a property with its DOM element", function() {
  expect(view.$element).toExist();
});

toExist匹配器是 Jasmine jQuery 扩展提供的自定义匹配器,用于检查元素是否存在于文档中。它验证 JavaScript 对象上属性的存在性,以及与 DOM 元素的关联是否成功。

selector模式传递给视图允许它被实例化多次,以不同的文档元素。

明确关联的一个优点是知道这个视图不会改变文档中的其他任何内容,正如我们接下来将要看到的。

视图是与 DOM 元素关联的行为,因此它不应该在页面的任何地方胡乱操作。它应该只更改或访问与其关联的元素。

为了演示这个概念,让我们实现另一个关于视图默认状态的验收标准,如下所示:

it("should have an empty stock symbol", function() {
  expect(view.getSymbolInput()).toHaveValue('');
});

getSymbolInput方法的简单实现可能会使用全局 jQuery 查找来找到输入并返回其值:

NewInvestmentView.prototype = {
  getSymbolInput: function () {
    return $('.new-investment-stock-symbol')
  }
};

然而,这可能会导致问题;如果文档的另一个地方有相同类名的输入,它可能会得到错误的结果。

一个更好的方法是使用视图关联的元素来执行范围查找,如下所示:

NewInvestmentView.prototype = {
  getSymbolInput: function () {
    return this.$element.find('.new-investment-stock-symbol')
  }
};

find函数将只查找this.$element的子元素。就像this.$element代表视图的整个文档一样。

由于我们将在视图代码的每个地方使用这个模式,我们可以创建一个函数并使用它,如下面的代码所示:

NewInvestmentView.prototype = {
  $: function () {
    return this.$element.find.apply(this.$element, arguments);
  },
  getSymbolInput: function () {
    return this.$('.new-investment-stock-symbol')
  }
};

现在假设我们从应用程序的另一个地方想要更改NewInvestmentView表单输入的值。我们知道它的类名,所以这可能就像这样简单:

$('.new-investment-stock-symbol').val('from outside the view');

然而,这种简单性隐藏了一个严重的封装问题。这一行代码正在创建与NewInvestmentView的实现细节应该分离的耦合。

如果另一个开发者更改NewInvestmentView,将输入类的名称从.new-investment-stock-symbol更改为.new-investment-symbol,那么这一行就会出错。

为了修复这个问题,开发者需要查看整个代码库中对该类名的引用。

一个更安全的做法是尊重视图并使用其 API,如下面的代码所示:

newInvestmentView.setSymbol('from outside the view');

当实施时,它看起来如下所示:

NewInvestmentView.prototype.setSymbol = function(value) {
  this.$('.new-investment-stock-symbol').val(value);
};

这样,当代码被重构时,只有一个地方需要执行更改——在NewInvestmentView实现内部。

由于浏览器文档中没有沙箱机制,这意味着从 JavaScript 代码的任何地方,我们都可以在文档的任何地方进行更改,因此除了良好的实践之外,我们几乎无法做任何事情来防止这些错误。

将视图与观察者集成

随着投资跟踪应用程序的开发,我们最终需要实现投资列表。但你是如何将NewInvestmentViewInvestmentListView集成的呢?

你可以为NewInvestmentView编写一个验收标准,如下所示:

给定新的投资视图,当其添加按钮被点击时,它应该将投资添加到投资列表中。

这种思考方式非常直接,从写作中我们可以看到我们正在在两个视图之间创建直接关系。将此转化为规范可以澄清这一认识,如下所示:

describe("NewInvestmentView", function() {
  beforeEach(function() {
    loadFixtures('NewInvestmentView.html');
    appendLoadFixtures('InvestmentListView.html');

    listView = new InvestmentListView({
      id: 'investment-list'
    });

    view = new NewInvestmentView({
      id: 'new-investment',
      listView: listView
    });
  });

  describe("when its add button is clicked", function() {
    beforeEach(function() {
      // fill form inputs
      // simulate the clicking of the button
    });

    it("should add the investment to the list", function() {
      expect(listView.count()).toEqual(1);
    });
  });
});

这个解决方案在两个视图之间创建了一个依赖关系。NewInvestmentView 构造函数现在接收一个 InvestmentListView 实例作为其 listView 参数。

在其实施过程中,NewInvestmentView 在表单提交时会调用 listView 对象的 addInvestment 方法:

function NewInvestmentView (params) {
  this.listView = params.listView;

  this.$element.on('submit', function () {
    this.listView.addInvestment(/* new investment */);
  }.bind(this));
}

为了更好地阐明这段代码的工作原理,以下是集成是如何进行的图示:

集成视图与观察者

这显示了两个视图之间的直接关系。

虽然很简单,但这个解决方案引入了许多架构问题。首先,也是最明显的,是 NewInvestmentView 规范的复杂性增加。

其次,由于紧密耦合,这使得这些组件的演变变得更加困难。

为了更好地阐明最后一个问题,想象一下,在未来,我们还想在表格中列出投资。这将要求对 NewInvestmentView 进行更改以支持列表和表格视图,如下所示:

function NewInvestmentView (params) {
  this.listView = params.listView;
  this.tableView = params.tableView;

  this.$element.on('submit', function () {
    this.listView.addInvestment(/* new investment */);
    this.tableView.addInvestment(/* new investment */);
  }.bind(this));
}

重新思考验收标准,我们可以得到一个更好、更具前瞻性的解决方案。让我们将其重写如下:

在投资跟踪器应用程序中,当创建新的投资时,它应该将投资添加到投资列表中。

通过验收标准,我们可以看到它引入了一个新的待测试主题:投资跟踪器。这暗示了新的源文件和规范文件。在相应地创建这两个文件并将它们添加到运行器之后,我们可以将此验收标准作为规范编写,如下面的代码所示:

describe("InvestmentTracker", function() {
  beforeEach(function() {
    loadFixtures('NewInvestmentView.html');
    appendLoadFixtures('InvestmentListView.html');

    listView = new InvestmentListView({
      id: 'investment-list'
    });

    newView = new NewInvestmentView({
      id: 'new-investment'
    });

    application = new InvestmentTracker({
      listView: listView,
      newView: newView
    });
  });

  describe("when a new investment is created", function() {
    beforeEach(function() {
      // fill form inputs
      newView.create();
    });

    it("should add the investment to the list", function() {
      expect(listView.count()).toEqual(1);
    });
  });
});

我们可以看到之前在 NewInvestmentView 规范中曾有的相同设置代码。它加载了两个视图所需的固定数据,实例化了 InvestmentListViewNewInvestmentView,并创建了一个新的 InvestmentTracker 实例,将两个视图作为参数传递。

在后来描述行为 when a new investment is created 时,我们可以看到调用 newView.create 函数来创建新的投资。

之后,它通过检查 listView.count() 是否等于 1 来确认是否向 listView 对象添加了新项。

但集成是如何发生的呢?我们可以通过查看 InvestmentTracker 的实现来了解:

function InvestmentTracker (params) {
  this.listView = params.listView;
  this.newView = params.newView;

  this.newView.onCreate(function (investment) {
    this.listView.addInvestment(investment);
  }.bind(this));
}

它使用 onCreate 函数在 newView 上注册一个观察函数作为回调。这个观察函数将在创建新投资时被调用。

NewInvestmentView 内部的实现相当简单。onCreate 方法将 callback 参数存储为对象的属性,如下所示:

NewInvestmentView.prototype.onCreate = function(callback) {
  this._callback = callback;
};

_callback 属性的命名约定可能听起来有些奇怪,但将其作为私有成员的约定是一个好习惯。

虽然前置的下划线字符实际上不会改变属性的可见性,但它至少会通知用户这个对象,_callback 属性可能会在未来更改或甚至被删除。

当调用 create 方法时,它会调用 _callback,并将新的投资作为参数传递,如下所示:

NewInvestmentView.prototype.create = function() {
  this._callback(/* new investment */);
};

一个更完整的实现需要允许多次调用 onCreate,并存储每个传递的回调。

这里是用于更好理解的解决方案图示:

将视图与观察者集成

使用回调来集成两个视图

在后面的 第七章,测试 React.js 应用程序 中,我们将看到 NewInvestmentView 规范的实现结果。

使用 jQuery 匹配器测试视图

除了其 HTML 固件模块之外,Jasmine jQuery 扩展还附带了一套自定义匹配器,这些匹配器有助于使用 DOM 元素编写期望。

如所示,使用这些自定义匹配器的最大优势是它们生成更好的错误信息。因此,尽管我们可以编写所有规范而不使用这些匹配器,但如果我们在出错时使用匹配器,我们会得到更多有用的信息。

为了更好地理解这个优势,我们可以回顾一下 should expose a property with its DOM element 规范的示例。在那里,它使用了 toExist 匹配器:

it("should expose a property with its DOM element", function() {
  expect(view.$element).toExist();
});

如果这个规范失败,我们会得到一个如以下截图所示的错误信息:

使用 jQuery 匹配器测试视图

这显示了自定义匹配器错误信息的示例

现在,我们重写这个规范,不使用自定义匹配器(仍然进行相同的验证):

it("should expose a property with its DOM element", function() {
  expect($(document).find(view.$element).length).toBeGreaterThan(0);
});

这次,错误信息变得不那么有信息量:

使用 jQuery 匹配器测试视图

读取错误后,我们无法理解它真正测试的是什么

因此,每当可能的时候,使用这些匹配器来获取更好的错误信息。让我们通过一些示例来回顾一下可用的自定义匹配器,这些示例展示了 NewInvestmentView 类的验收标准:

  • NewInvestmentView 应该允许输入股票符号

  • NewInvestmentView 应该允许输入股份

  • NewInvestmentView 应该允许输入股价

  • NewInvestmentView 应该有一个空的股票符号

  • NewInvestmentView 应该将其股份值设置为零

  • NewInvestmentView 应该将其股价值设置为零

  • NewInvestmentView 的股票符号输入应该在获得焦点时

  • NewInvestmentView 不应该允许添加

重要的是要理解,尽管以下示例对于演示 Jasmine jQuery 匹配器的工作方式很有用,但它们实际上并没有测试任何 JavaScript 代码,只是测试了由 HTML 固件模块加载的 HTML 元素。

toBeMatchedBy jQuery 匹配器

这个匹配器检查元素是否匹配传递的 CSS 选择器,如下所示:

it("should allow the input of the stock symbol", function() {
  expect(view.$element.find('.new-investment-stock-symbol')).toBeMatchedBy('input[type=text]');
});

toContainHtml jQuery 匹配器

此匹配器检查元素的内容是否与传递的 HTML 匹配,如下所示:

it("should allow the input of shares", function() {
  expect(view.$element).toContainHtml('<input type="number" class="new-investment-shares" name="shares" value="0">');
});

The toContainElement jQuery matcher

此匹配器检查元素是否包含任何匹配传递的 CSS 选择器的子元素,如下所示

it("should allow the input of the share price", function() {
  expect(view.$element).toContainElement('input[type=number].new-investment-share-price');
});

The toHaveValue jQuery matcher

仅对输入有效,此验证将预期值与元素的值属性进行比较,以下代码为:

it("should have an empty stock symbol", function() {
  expect(view.$element.find('.new-investment-stock-symbol')).toHaveValue('');
});

it("should have its shares value to zero", function() {
  expect(view.$element.find('.new-investment-shares')).toHaveValue('0');
});

The toHaveAttr jQuery matcher

此匹配器测试元素是否具有指定的名称和值的任何属性。以下示例展示了如何使用此匹配器测试输入的值属性,这可以用toHaveValue匹配器来编写预期:

it("should have its share price value to zero", function() {
  expect(view.$element.find('.new-investment-share-price')).toHaveAttr('value', '0');
});

The toBeFocused jQuery matcher

以下代码说明了如何检查输入元素是否聚焦:

it("should have its stock symbol input on focus", function() {
 expect(view.$element.find('.new-investment-stock-symbol')).toBeFocused();
});

The toBeDisabled jQuery matcher

此匹配器检查元素是否被禁用,以下代码为:

function itShouldNotAllowToAdd () {
 it("should not allow to add", function() {
  expect(view.$element.find('input[type=submit]')).toBeDisabled();
});

更多匹配器

该扩展提供了更多可用的匹配器;请确保检查项目的文档,github.com/velesin/jasmine-jquery#jquery-matchers

摘要

在本章中,你了解到一旦通过测试驱动应用程序开发,测试可以变得多么简单。你看到了如何使用模块模式更好地组织项目代码,以及视图模式如何帮助创建更易于维护的浏览器代码。

你学习了如何使用 HTML 固定文件,使你的规格更加可读和易懂。我还展示了如何通过使用自定义 jQuery 匹配器来测试与浏览器 DOM 交互的代码。

在下一章中,我们将更进一步,开始测试服务器集成和异步代码。

第四章. 异步测试 – AJAX

在每个 JavaScript 应用程序中,不可避免地会有一个时刻需要测试异步代码。

异步意味着你不能以线性方式处理它——一个函数可能在执行后立即返回,但结果会在稍后到来,通常是通过回调。

在处理 AJAX 请求时,这是一个非常常见的模式,例如,通过 jQuery:

$.ajax('http://localhost/data.json', {
  success: function (data) {
    // handle the result
  }
});

在本章中,我们将学习 Jasmine 允许我们以不同方式编写异步代码测试的不同方法。

验收标准

为了展示 Jasmine 对异步测试的支持,我们将实现以下验收标准:

股票在取回时,应更新其股价

使用我们至今为止向您展示的技术,您可以在spec文件夹中的StockSpec.js文件中编写以下验收标准:

describe("Stock", function() {
  var stock;
  var originalSharePrice = 0;

  beforeEach(function() {
    stock = new Stock({
      symbol: 'AOUE',
      sharePrice: originalSharePrice
    });
  });

  it("should have a share price", function() {
    expect(stock.sharePrice).toEqual(originalSharePrice);
  });

  describe("when fetched", function() {
 var fetched = false;
 beforeEach(function() {
 stock.fetch();
 });

 it("should update its share price", function() {
 expect(stock.sharePrice).toEqual(20.18);
 });
 });
});

这将导致在src文件夹中的Stock.js文件中实现fetch函数,如下所示:

Stock.prototype.fetch = function() {
  var that = this;
  var url = 'http://localhost:8000/stocks/'+that.symbol;

  $.getJSON(url, function (data) {
    that.sharePrice = data.sharePrice;
  });
};

上述代码中的重要部分是$.getJSON调用,一个期望包含更新股价的 JSON 响应的 AJAX 请求,如下所示:

{
  "sharePrice": 20.18
}

到目前为止,你可以看到我们陷入了困境;为了运行此规范,我们需要一个正在运行的服务器。

设置场景

由于这本书全部关于 JavaScript,我们将创建一个非常简单的Node.js服务器,用于规范测试。Node.js 是一个允许使用 JavaScript 开发网络应用程序的平台,例如 Web 服务器。

在第六章中,我们将看到测试 AJAX 请求而不需要服务器的替代解决方案。在第八章中,我们将看到如何将 Node.js 作为高级构建系统的基础。

安装 Node.js

如果您已经安装了 Node.js,您可以跳到下一节。

提供了适用于 Windows 和 Mac OS X 的安装程序。按照以下步骤安装 Node.js:

  1. 前往 Node.js 网站nodejs.org/

  2. 点击安装按钮。

  3. 下载完成后,运行安装程序并按照步骤操作。

要检查其他安装方法和有关如何在 Linux 发行版上安装 Node.js 的说明,请查看官方文档github.com/joyent/node/wiki/Installing-Node.js-via-package-manager

完成后,你应该可以在命令行上使用nodenpm命令。

编写服务器代码

为了学习如何编写异步规范,我们将创建一个返回一些假数据的服务器。在项目的根目录中创建一个名为server.js的新文件,并将其中的以下代码添加到该文件中:

var express = require('express');
var app = express();

app.get('/stocks/:symbol', function (req, res) {
  res.setHeader('Content-Type', 'application/json');
  res.send({ sharePrice: 20.18 });
});

app.use(express.static(__dirname));

app.listen(8000);

为了处理 HTTP 请求,我们使用 Express,一个 Node.js 网络应用程序框架。通过阅读代码,您可以看到它定义了一个到 /stocks/:symbol 的路由,因此它接受 http://localhost:8000/stocks/AOUE 这样的请求,并返回 JSON 数据。

我们还使用 express.static 模块在 http://localhost:8000/SpecRunner.html 上提供规范运行器。

有一个绕过 SOP 的要求。这是一项政策,出于安全原因,规定 AJAX 请求不允许在与应用程序不同的域上执行。

这个问题是在使用 HTML 固定值时首次在 第三章 测试前端代码 中演示的。

使用 Chrome 浏览器检查器,您可以在使用 file:// 协议打开 SpecRunner.html 文件时在控制台中看到错误(基本上,这就是您到目前为止一直在做的方式):

编写服务器代码

这显示了相同的源策略错误

通过提供运行器,以及所有应用程序和测试代码在相同的基 URL 下,我们防止了这个问题发生,并且能够在任何浏览器上运行规范。

运行服务器

要运行服务器,首先您需要使用 Node 的包管理器安装其依赖项(Express)。在应用程序根文件夹内,运行 npm 命令:

$ npm install express

此命令将下载 Express 并将其放置在项目文件夹内名为 node_modules 的新文件夹中。

现在,您应该能够通过调用以下 node 命令来运行服务器:

$ node server.js

要检查它是否工作,请在您的浏览器中输入 http://localhost:8000/stocks/AOUE,您应该会收到 JSON 响应:

{"sharePrice": "20.18"}

现在我们已经让服务器依赖项工作正常,我们可以回到编写规范。

编写规范

服务器运行后,在 http://localhost:8000/SpecRunner.html 上打开您的浏览器,以查看我们的规范结果。

您可以看到,尽管服务器正在运行,规范看起来似乎是正确的,但它失败了。这是因为 stock.fetch() 是异步的。对 stock.fetch() 的调用会立即返回,允许 Jasmine 在 AJAX 请求完成之前运行预期:

it("should update its share price", function() {
  expect(stock.sharePrice).toEqual(20.18);
});

要修复这个问题,我们需要接受 stock.fetch() 函数的异步性,并指导 Jasmine 在运行预期之前等待其执行。

异步设置和清理

在所显示的示例中,我们在规范的设置阶段(beforeEach 函数)中调用 fetch 函数。

我们需要做的唯一一件事是,为了识别这个设置步骤是异步的,将其函数定义中添加一个 done 参数:

describe("when fetched", function() {
  beforeEach(function(done) {

  });

  it("should update its share price", function() {
    expect(stock.sharePrice).toEqual(20.18);
  });
});

一旦 Jasmine 识别出这个 done 参数,它就会传递一个函数作为异步操作完成后必须调用的值。

因此,我们可以将这个 done 函数作为 fetch 函数的 success 回调传递:

beforeEach(function(done) {
  stock.fetch({
 success: done
 });
});

在实现中,在 AJAX 操作完成后调用它:

Stock.prototype.fetch = function(params) {
  params = params || {};
  var that = this;
  var success = params.success || function () {};
 var url = 'http://localhost:8000/stocks/'+that.symbol;

  $.getJSON(url, function (data) {
    that.sharePrice = data.sharePrice;
 success(that);
  });
};

这就是全部内容;Jasmine 将等待 AJAX 操作完成,然后测试将通过。

当需要时,也可以使用相同的done参数来定义异步的afterEach

异步规格说明

另一种方法是在异步设置中有一个异步的规格说明。为了演示这将如何工作,我们需要重写我们之前的验收标准:

describe("Stock", function() {
  var stock;
  var originalSharePrice = 0;

  beforeEach(function() {
    stock = new Stock({
      symbol: 'AOUE',
      sharePrice: originalSharePrice
    });
  });

  it("should be able to update its share price", function(done) {
    stock.fetch();
    expect(stock.sharePrice).toEqual(20.18);
  });
});

再次强调,我们只需将其函数定义中添加一个done参数,并在测试完成后调用done函数即可:

it("should be able to update its share price", function(done) {
  stock.fetch({
    success: function () {
      expect(stock.sharePrice).toEqual(20.18);
      done();
    }
  });
});

这里的不同之处在于,我们必须将期望它完成的操作移动到在调用done函数之前的success回调中。

超时

当编写异步规格说明时,Jasmine 默认会等待 5 秒钟,直到done回调被调用,如果在此超时之前没有调用,则规格说明将失败。

在这个假设的例子中,服务器是一个简单的存根,返回静态数据,那个超时并没有问题,但有些情况下,默认时间不足以完成异步任务。

虽然不建议有长时间运行的规格说明,但了解可以通过更改 Jasmine 中称为jasmine.DEFAULT_TIMEOUT_INTERVAL的简单配置变量来绕过这种默认行为,这很好。

要在整个套件中使其生效,可以在SpecHelper.js文件中设置它,如下所示:

beforeEach(function() {
  jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;

  jasmine.addMatchers({
    // matchers code
  });
});

jasmine.getFixtures().fixturesPath = 'spec/fixtures';

要使其在一个特定的规格说明中生效,请在beforeEach中更改其值,并在afterEach期间恢复它:

describe("Stock", function() {
 var defaultTimeout;

  beforeEach(function() {
 defaultTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
  });

  afterEach(function() {
 jasmine.DEFAULT_TIMEOUT_INTERVAL = defaultTimeout;
  });

  it("should be able to update its share price", function(done) {

  });
});

摘要

在本章中,您已经了解了如何测试异步代码,这在测试服务器交互(AJAX)时是一个常见的场景。

我还向您介绍了 Node.js 平台,并使用它编写了一个简单的服务器,用作测试工具。

在第六章中,光速单元测试,我们将看到不同的 AJAX 测试解决方案——这些解决方案不需要运行服务器。

在下一章中,我们将学习关于间谍的知识,以及我们如何使用它们来执行行为检查。

第五章。Jasmine 间谍

测试替身是单元测试中的一种模式。它用一个特定于测试场景的等效实现替换了测试依赖的组件。这些实现被称为替身,因为尽管它们的行为可能特定于测试,但它们的行为和 API 与它们模仿的对象相同。

间谍是 Jasmine 测试替身的一种解决方案。在其核心,一个 Jasmine 间谍是一种特殊的函数,它记录了与它发生的所有交互。因此,当返回值或对象状态的改变无法用来确定测试期望是否成功时,它们非常有用。换句话说,Jasmine 间谍在测试成功只能通过行为检查来确定时非常完美。

“裸”间谍

要理解行为检查的概念,让我们回顾一下在第三章中提出的示例,测试前端代码,并测试NewInvestmentView测试套件的观察行为:

 describe("NewInvestmentView", function() {
  var view;

  // setup and other specs ...

  describe("with its inputs correctly filled", function() {

    // setup and other specs ...

    describe("and when an investment is created", function() {
      var callbackSpy;
      var investment;

      beforeEach(function() {
        callbackSpy = jasmine.createSpy('callback');
        view.onCreate(callbackSpy);

        investment = view.create();
      });

      it("should invoke the 'onCreate' callback with the created investment", function() {

 expect(callbackSpy).toHaveBeenCalled();
 expect(callbackSpy).toHaveBeenCalledWith(investment);
      });
    });
  });
});

在规范设置期间,它使用jasmine.createSpy函数创建一个新的 Jasmine 间谍,并为其传递一个名称(callback)。Jasmine 间谍是一种特殊的函数,它跟踪对其的调用和参数。

然后,它使用onCreate函数将这个间谍设置为视图创建事件的观察者,并最终调用create函数来创建一个新的投资。

在后续的期望中,规范使用toHaveBeenCalledtoHaveBeenCalledWith匹配器来检查callbackSpy是否被调用,并且带有正确的参数(investment),从而进行行为检查。

监视对象的函数

间谍本身非常有用,但它的真正力量在于使用对应间谍改变一个对象的原生实现。

考虑以下示例,目的是验证当表单提交时,viewcreate函数必须被调用:

describe("NewInvestmentView", function() {
  var view;

  // setup and other specs ...

  describe("with its inputs correctly filled", function() {

    // setup and other specs ...

    describe("and when the form is submitted", function() {
      beforeEach(function() {
        spyOn(view, 'create');
        view.$element.submit();
      });

      it("should create an investment", function() {
        expect(view.create).toHaveBeenCalled();
      });
    });
  });
});

在这里,我们使用全局 Jasmine 函数spyOn来用间谍替换viewcreate函数。

然后,在规范中稍后,我们使用toHaveBeenCalled Jasmine 匹配器来验证view.create函数是否被调用。

在规范完成后,Jasmine 恢复对象的原始行为。

测试 DOM 事件

在编写前端应用程序时,DOM 事件被广泛使用,有时我们打算编写一个规范来检查事件是否被触发。

事件可能像表单提交或已更改的输入一样,那么我们如何使用间谍来完成这个任务呢?

我们可以向NewInvestmentView测试套件添加一个新的验收标准,以检查当我们点击添加按钮时,其表单是否正在提交:

describe("and when its add button is clicked", function() {
  beforeEach(function() {
    spyOnEvent(view.$element, 'submit');
    view.20.18.find('input[type=submit]').click();
  });

  it("should submit the form", function() {
    expect('submit').toHaveBeenTriggeredOn(view.20.18);
  });
});

要编写这个规范,我们使用 Jasmine jQuery 插件提供的全局函数spyOnEvent

它通过接受view.20.18,这是一个 DOM 元素,以及我们想要间谍监视的submit事件来实现。然后,稍后我们使用 Jasmine jQuery 匹配器toHaveBeenTriggeredOn来检查事件是否在元素上触发。

摘要

在本章中,您已经接触到了测试替身的概念以及如何使用间谍来对您的规格进行行为检查。

在下一章中,我们将探讨如何使用模拟和存根来替换我们规格的真实依赖项,并加快它们的执行速度。

第六章. 光速单元测试

在 第四章 中,我们看到了如何将 AJAX 测试包含在应用程序中,这会增加测试的复杂性。在该章节的示例中,我们创建了一个结果可预测的服务器。它基本上是一个复杂的测试工具。即使我们可以使用真实的服务器实现,它也会增加测试的复杂性;尝试从浏览器更改具有数据库或第三方服务的服务器的状态——这不是一个简单或可扩展的解决方案。

这也对生产力有影响;这些请求需要时间来处理和传输,这损害了单元测试通常提供的快速反馈循环。

你也可以说,这些测试用例同时测试了客户端和服务器代码,因此不能被视为单元测试;相反,它们可以被视为集成测试。

解决所有这些问题的方法是在代码的真实依赖项中使用 模拟存根。因此,而不是向服务器发出请求,我们在浏览器中使用服务器的测试替身。

我们将使用与 第四章 相同的示例,异步测试 – AJAX,并使用不同的技术重写它。

Jasmine 模拟

我们已经看到了一些 Jasmine 监视器的用例。记住,监视器是一个特殊的函数,它记录了它的调用方式。你可以将模拟视为具有行为的监视器。

当我们想要在我们的测试用例中强制执行特定的路径或用一个更简单的实现替换真实实现时,我们会使用模拟。

让我们回顾一下验收标准的示例,“当股票被检索时,应更新其股票价格”,通过使用 Jasmine 模拟重写它。

我们知道股票的 fetch 函数是使用 $.getJSON 函数实现的,如下所示:

Stock.prototype.fetch = function(parameters) {
  $.getJSON(url, function (data) {
    that.sharePrice = data.sharePrice;
    success(that);
  });
};

我们可以使用 spyOn 函数通过以下代码设置对 getJSON 函数的监视:

describe("when fetched", function() {
  beforeEach(function() {
    spyOn($, 'getJSON').and.callFake(function(url, callback) {
      callback({ sharePrice: 20.18 });
    });
    stock.fetch();
  });

  it("should update its share price", function() {
    expect(stock.sharePrice).toEqual(20.18);
  });
});

但这次,我们将使用 and.callFake 函数为我们的监视器设置一个行为(默认情况下,监视器什么都不做并返回 undefined)。我们让监视器调用其 callback 参数,传递一个对象响应({ sharePrice: 20.18 })。

在后续的期望中,我们使用 toEqual 断言来验证股票的 sharePrice 是否已更改。

要运行这个测试用例,你不再需要服务器来处理请求,这是一个好事,但这种方法存在一个问题。如果 fetch 函数被重构为使用 $.ajax 而不是 $.getJSON,那么测试将会失败。一个更好的解决方案是由一个名为 jasmine-ajax 的 Jasmine 插件提供的,即模拟浏览器的 AJAX 基础设施,这样 AJAX 请求的实现就可以自由地以不同的方式完成。

Jasmine Ajax

Jasmine Ajax 是一个官方插件,旨在帮助测试 AJAX 请求。它将浏览器的 AJAX 请求基础设施更改为模拟实现。

这个模拟(或模拟)实现,虽然更简单,但仍然对使用其 API 的任何代码表现得像真实实现。

安装插件

在我们深入研究规范实现之前,首先我们需要将插件添加到项目中。访问 github.com/jasmine/jasmine-ajax/ 并下载当前版本(应与 Jasmine 2.x 版本兼容)。将其放置在 lib 文件夹中。

它还需要添加到 SpecRunner.html 文件中,所以请继续添加另一个脚本:

<script type="text/javascript" src="img/mock-ajax.js"></script>

模拟的 XMLHttpRequest

无论何时使用 jQuery 进行 AJAX 请求,实际上底层都是使用 XMLHttpRequest 对象来执行请求。

XMLHttpRequest 是标准的 JavaScript HTTP API。尽管其名称暗示它使用 XML,但它支持其他类型的内容,如 JSON;出于兼容性原因,名称保持不变。

因此,我们不是模拟 jQuery,而是可以用模拟实现更改 XMLHttpRequest 对象。这正是此插件所做的。

让我们重写之前的规范以使用这个模拟实现:

describe("when fetched", function() {
  beforeEach(function() {
 jasmine.Ajax.install();
 });

  beforeEach(function() {
    stock.fetch();

    jasmine.Ajax.requests.mostRecent().respondWith({
 'status': 200,
 'contentType': 'application/json',
 'responseText': '{ "sharePrice": 20.18 }'
 });
  });

  afterEach(function() {
 jasmine.Ajax.uninstall();
 });

  it("should update its share price", function() {
    expect(stock.sharePrice).toEqual(20.18);
  });
});

深入实现:

  1. 首先,我们告诉插件使用 jasmine.Ajax.install 函数用模拟实现替换 XMLHttpRequest 对象的原实现。

  2. 然后,我们调用 stock.fetch 函数,这将调用 $.getJSON,在底层重新创建 XMLHttpRequest

  3. 最后,我们使用 jasmine.Ajax.requests.mostRecent().respondWith 函数获取最近发出的请求,并用模拟响应来响应它。

我们使用 respondWith 函数,它接受一个具有三个属性的对象:

  1. status 属性用于定义 HTTP 状态码。

  2. contentType(示例中为 JSON)属性。

  3. responseText 属性,它是一个包含请求响应体的文本字符串。

然后,所有的事情都是关于运行预期:

it("should update its share price", function() {
  expect(stock.sharePrice).toEqual(20.18);
});

由于插件更改了全局的 XMLHttpRequest 对象,你必须记得在测试运行后告诉 Jasmine 恢复其原始实现;否则,可能会干扰其他规范(如 Jasmine jQuery 固件模块)中的代码。以下是完成此操作的方法:

afterEach(function() {
  jasmine.Ajax.uninstall();
});

写这个规范还有稍微不同的方法;在这里,请求首先被模拟(带有响应详情),然后稍后执行要测试的代码。

之前的示例已更改为以下内容:

beforeEach(function() {
  jasmine.Ajax.stubRequest('http://localhost:8000/stocks/AOUE').andReturn({
 'status': 200,
 'contentType': 'application/json',
 'responseText': '{ "sharePrice": 20.18 }'
 });

  stock.fetch();
});

可以使用 jasmine.Ajax.stubRequest 函数模拟对特定请求的任何请求。在示例中,它由 URL http://localhost:8000/stocks/AOUE 定义,响应定义如下:

{
  'status': 200,
  'contentType': 'application/json',
  'responseText': '{ "sharePrice": 20.18 }'
}

响应定义遵循之前使用的 respondWith 函数相同的属性。

摘要

在本章中,你学习了异步测试如何损害你通过单元测试获得的快速反馈循环。我展示了你可以如何使用存根(stubs)或模拟(fakes)来使你的规范(specs)运行更快,并且依赖性更少。

我们已经看到了两种不同的方式,你可以使用简单的 Jasmine 存根和更高级的 XMLHttpRequest 模拟来实现 AJAX 请求的测试。

你也对间谍(spies)和存根(stubs)有了更多的了解,应该更习惯于在不同场景中使用它们。

在下一章中,我们将进一步探讨我们应用程序的复杂性,并将进行整体重构,将其转换为一个功能齐全的单页应用程序,使用 React.js 库。

第七章。测试 React 应用程序

作为一名 Web 开发者,您熟悉大多数网站今天是如何构建的。通常有一个 Web 服务器(在 Java、Ruby 或 PHP 等语言中),它处理用户请求并以标记(HTML)的形式响应。

这意味着在每次请求时,Web 服务器通过 URL 解释用户操作并渲染整个页面。

为了提高用户体验,越来越多的功能开始从服务器端推向客户端,JavaScript 不再仅仅是向页面添加行为,而是完全渲染页面。最大的优势是用户操作不再触发整个页面的刷新;JavaScript 代码可以处理整个浏览器文档并根据需要进行修改。

虽然这确实提高了用户体验,但它开始给应用程序代码增加了很多复杂性,导致维护成本增加,最糟糕的是——屏幕不同部分之间不一致的 bug。

为了使这种场景变得合理,许多库和框架被构建出来,但它们都失败了,因为它们没有解决整个问题的根本原因——可变性。

服务器端渲染很容易,因为没有要处理的可变性。给定一个新的应用程序状态,服务器会简单地重新渲染一切。如果我们能在客户端 JavaScript 代码中从这种方法中获得好处怎么办?

这正是React所提出的。您以组件的形式声明性地编写界面代码,并告诉 React 进行渲染。在任何应用程序状态变化时,您可以简单地告诉 React 重新渲染;然后它会计算将 DOM 移动到所需状态的所需更改,并为您应用它们。

在本章中,我们将通过重构到目前为止所编写的代码,将其重构为一个单页应用(SPA),来理解 React 是如何工作的。

项目设置

然而,在我们深入 React 之前,首先需要在我们的项目中做一些小的设置,以便我们能够创建 React 组件。

前往facebook.github.io/react/downloads.html下载 React Starter Kit 版本 0.12.2 或更高版本。

下载后,您可以解压其内容,并将构建文件夹内的所有文件移动到我们的应用程序的 lib 文件夹中。然后,只需将 React 库加载到SpecRunner.html文件中。

<script src="img/react-with-addons.js"></script>
<script src="img/jquery.js"></script>

设置完成后,我们可以继续编写我们的第一个组件。

我们的第一个 React 组件

如本章引言所述,使用 React,您通过组件声明性地编写界面代码。

React 组件的概念与第三章中提出的组件概念类似,即测试前端代码,因此期待在下一部分看到一些相似之处。

考虑到这一点,让我们创建我们非常第一个组件。为了更好地理解 React 组件是什么,我们将使用一个非常简单的验收标准,并像往常一样从规范开始。

让我们实现 "InvestmentListItem 应该渲染"。这很简单,并不真正是 面向功能 的,但是一个很好的例子,让我们开始。

根据 第三章 中学到的内容,测试前端代码,我们可以通过创建一个名为 InvestmentListItemSpec.js 的新文件并保存在 spec 文件夹内的 components 文件夹中来开始编写这个规范:

describe("InvestmentListItem", function() {

  beforeEach(function() {
    // render the React component
  });

it("should render", function() {
 expect(component.$el).toEqual('li.investment-list-item');
 });
});

将新文件添加到 SpecRunner.html 文件中,如前几章中已演示。

在规范中,我们基本上使用 jasmine-jquery 插件来期望我们的组件封装的 DOM 元素等于特定的 CSS 选择器。

我们如何将这个例子改为测试 React 组件?唯一的区别是获取 DOM 节点的 API。而不是使用带有 jQuery 对象的 $element,React 提供了一个名为 getDOMNode() 的函数,它返回它所声称的——一个 DOM 节点。

这样,我们可以使用之前相同的断言,并使测试准备就绪,如下所示:

it("should render", function() {
  expect(component.getDOMNode()).toEqual('li.investment-list-item');
});

这很简单!所以,下一步是创建组件,渲染它,并将其附加到文档上。这也同样简单;请看以下代码片段:

describe("InvestmentListItem", function() {
  var component;

  beforeEach(function() {

 setFixtures('<div id="application-container"></div>');
 var container = document.getElementById('application-container');

 var element = React.createElement(InvestmentListItem);
 component = React.render(element, container);
  });

  it("should render", function() {
    expect(component.getDOMNode()).toEqual('li.investment-list-item');
  });
});

这可能看起来像很多代码,但其中一半只是设置文档元素固定装置的样板代码,这样我们就可以在其中渲染 React 组件:

  1. 首先,我们使用 jasmine-jquery 中的 setFixtures 函数在文档中创建一个具有 application-container ID 的元素。然后,使用 getElementById API 查询此元素并将其保存到 container 变量中。接下来的两个步骤是针对 React 的特定步骤:

    1. 首先,为了使用一个组件,我们必须首先从其类中创建一个元素;这是通过 React.createElement 函数完成的,如下所示:

      var element = React.createElement(InvestmentListItem);
      
    2. 接下来,使用元素实例,我们最终可以通过 React.render 函数告诉 React 渲染它,如下所示:

      component = React.render(element, container);
      
    3. render 函数接受以下两个参数:

      • React 元素

      • 一个用于渲染元素的 DOM 节点

  2. 到目前为止,规范已经完成。你可以运行它并看到它失败,显示以下错误:

    ReferenceError: InvestmentListItem is not defined.
    
  3. 下一步是编写组件代码。所以,让我们填充规范,在 src 文件夹中创建一个新文件,命名为 InvestmentListItem.js,并将其添加到规范运行器中。这个文件应该遵循我们至今为止使用的模块模式。

  4. 然后,使用 React.createClass 方法创建一个新的组件类:

    (function (React) {
      var InvestmentListItem = React.createClass({
    
    render: function () {
          return React.createElement('li', { className: 'investment-list-item' }, 'Investment');
        }
      });
    
      this.InvestmentListItem = InvestmentListItem;
    })(React);
    
  5. 至少,React.createClass 方法期望一个单独的 render 函数,该函数应该返回一个 React 元素的树。

  6. 我们再次使用 React.createElement 方法来创建将成为渲染树根的元素,如下所示:

    React.createElement('li', { className: 'investment-list-item' }, 'Investment')
    

与其在 beforeEach 块中之前的用法相比,这里的区别在于它还传递了一个包含 classNameprops 列表和一个包含文本 Investment 的单个子组件。

我们将更深入地探讨 props 参数的含义,但你可以将其视为与 HTML DOM 元素的属性类似。className prop 将转换为 li 元素的 class HTML 属性。

React.createElement 方法签名接受三个参数:

  • 组件的类型,可以是表示真实 DOM 元素的字符串(如 divh1p)或 React 组件类

  • 包含 props 值的对象

  • 以及一个可变数量的子组件,在这个例子中,就是 Investment 字符串

在渲染这个组件(通过调用 React.render() 方法)时,结果将是:

<li class="investment-list-item">Investment</li>

这是生成它的 JavaScript 代码的直接表示:

React.createElement('li', { className: 'investment-list-item' }, 'Investment');

恭喜!你已经构建了第一个完全测试过的 React 组件。

虚拟 DOM

当你定义一个组件的渲染方法并调用 React.createElement 方法时,你实际上并没有在文档中渲染任何内容(你甚至没有创建 DOM 元素)。

只有通过 React.render 函数,调用这些 React.createElement 调用创建的表示才能有效地转换为真实的 DOM 元素并附加到文档上。

这种由 ReactElements 定义的表示,是 React 所称的虚拟 DOM。而且 ReactElement 不能与 DOM 元素混淆;它实际上是一个轻量级、无状态、不可变、虚拟的 DOM 元素表示。

所以为什么 React 要陷入创建新的 DOM 表示方式的麻烦呢?答案在这里是 性能

随着浏览器的进化,JavaScript 的性能一直在不断提升,而今天的应用瓶颈实际上并不是 JavaScript。你可能听说过,你应该尽量减少对 DOM 的操作,React 允许你通过提供自己的 DOM 版本来实现这一点。

然而,这并不是唯一的原因。React 构建了一个非常强大的 diffing 算法,可以比较虚拟 DOM 的两种不同表示,计算它们之间的差异,并利用这些信息创建应用于真实 DOM 的突变。

它允许我们回到我们曾经使用的服务器端渲染的流程。基本上,在应用状态发生任何变化时,我们可以要求 React 重新渲染一切,然后它会计算所需的最小更改数量,并将这些更改应用到真实的 DOM 上。

它让我们开发者从担心修改 DOM 中解放出来,并赋予我们以声明式方式编写用户界面的能力,同时减少错误并提高生产力。

JSX

如果您有编写前端 JavaScript 应用程序的经验,您可能熟悉一些模板语言。此时,您可能想知道您最喜欢的模板语言(如 Handlebars)可以在 React 中何处使用。答案是您不能。

React 不会在标记和逻辑之间做出区分;在 React 组件中,它们实际上是相同的。

然而,当我们开始构建更复杂的组件时会发生什么?我们在第三章中构建的表单,测试前端代码,将如何转换成 React 组件?

仅为了渲染而不涉及其他逻辑,需要调用一大堆 React.createElement,如下所示:

var NewInvestment = React.createClass({
  render: function () {
    return React.createElement("form", {className: "new-investment"},
      React.createElement("h1", null, "New investment"),
      React.createElement("label", null,
        "Symbol:",
        React.createElement("input", {type: "text", className: "new-investment-stock-symbol", maxLength: "4"})
      ),
      React.createElement("label", null,
        "Shares:",
        React.createElement("input", {type: "number", className: "new-investment-shares"})
      ),
      React.createElement("label", null,
        "Share price:",
        React.createElement("input", {type: "number", className: "new-investment-share-price"})
      ),
      React.createElement("input", {type: "submit", className: "new-investment-submit", value: "Add"})
    );
  }
});

这非常冗长且难以阅读。因此,鉴于 React 组件既是标记也是逻辑,我们能否将其写成 HTML 和 JavaScript 的混合体会更好?下面是如何做的:

var NewInvestment = React.createClass({
  render: function () {
    return <form className="new-investment">
      <h1>New investment</h1>
      <label>
        Symbol:
        <input type="text" className="new-investment-stock-symbol" maxLength="4" />
      </label>
      <label>
        Shares:
        <input type="number" className="new-investment-shares" />
      </label>
      <label>
        Share price:
        <input type="number" className="new-investment-share-price" />
      </label>
      <input type="submit" className="new-investment-submit" value="Add" />
    </form>;
  }
});

那就是 JSX,一种类似于 XML 的 JavaScript 语法扩展,它是为了与 React 一起使用而构建的。

它会转换成 JavaScript,所以根据后一个示例,它将直接编译成前面展示的纯 JavaScript 代码。

转换过程的一个重要特性是它不会改变行号;因此,JSX 中的第 10 行将转换成转换后的 JavaScript 文件中的第 10 行。这有助于在调试代码和进行静态代码分析时。

关于该语言的更多信息,您可以查看官方规范facebook.github.io/jsx/,但就目前而言,我们可以按照下面的示例继续,当我们深入探讨该语言的功能时。

重要的是要知道,在实现 React 组件时使用 JSX 并不是强制要求,但它使整个过程变得更加容易。考虑到这一点,我们目前将继续使用它。

使用 JSX 与 Jasmine

为了让我们能够使用 JSX 与我们的 Jasmine 运行器一起使用,我们需要进行一些更改。

首先,我们需要将想要使用 JSX 语法的文件重命名为 .jsx。虽然这不是强制要求,但它使我们能够轻松地识别出哪些文件正在使用这种特殊语法。

接下来,在 SpecRunner.html 文件中,我们需要更改脚本标签,以表明这些不是常规的 JavaScript 文件,如下所示:

<script src="img/strong>" type="text/jsx"></script>
<script src="img/strong>" type="text/jsx"></script>

不幸的是,我们需要的更改不止这些。浏览器不理解 JSX 语法,因此我们需要加载一个特殊的转换器,它将首先将这些文件转换成常规 JavaScript。

这个转换器包含在 React 入门套件中,所以只需在加载 React 之后立即加载它,如下所示:

<script src="img/react-with-addons.js"></script>
<script src="img/JSXTransformer.js"></script>

在完成此设置后,我们应该能够运行测试,不是吗?不幸的是,还有一步。

如果您尝试在浏览器中打开 SpecRunner.html 文件,您将看到 InvestmentListItem 的测试没有被执行。这是因为转换器通过 AJAX 加载脚本文件,转换它们,最后将它们附加到 DOM 上。在此过程完成之前,Jasmine 已经运行了测试。

我们需要一种方式来通知 Jasmine 等待这些文件加载并转换。

最简单的方法是更改位于 jasmine-2.1.3 文件夹中的 jasmine-2.1.3 文件夹内的 lib 文件夹中的 Jasmine 的 boot.js 文件。

在原始文件中,您需要找到包含 env.execute(); 方法的行并将其注释掉。它可能如下所示:

window.onload = function() {
  if (currentWindowOnload) {
    currentWindowOnload();
  }
  htmlReporter.initialize();

// delays execution so that JSX files can be loaded
 // env.execute();
};

文件中的其他内容应保持不变。在此更改之后,您将看到测试不再运行——一个都没有。

唯一缺少的部分是在 JSX 文件加载后调用此 execute 方法。为此,我们将在 jasmine.2.1.3 文件夹中创建一个名为 boot-exec.js 的新文件,内容如下:

/**
  Custom boot file that actually runs the tests.
  The code below was extracted and commented out from the original boot.js file.
 */
(function() {

  var env = jasmine.getEnv();
  env.execute();

}());

如您所见,它只执行了原始启动文件中先前注释的代码。

要运行这个自定义启动程序非常简单。我们将其添加到 SpecRunner.html 文件的 <head> 标签的最后一行,作为 JSX 类型:


<!-- After all JSX files were loaded and processed, the tests can finally run -->
 <script src="img/boot-exec.js" type="text/jsx"></script>

</head>

JSXTransformer 库确保脚本按照声明的顺序加载。因此,当 boot-exec.js 文件被加载时,源文件和测试文件已经加载完毕。

有了这个,我们的测试运行器现在支持 JSX。

组件属性(props)

Props 是在 React 中从父组件传递数据到子组件的方式。

对于下一个示例,我们希望将 InvestmentListItem 组件修改为以百分比格式渲染 roi 变量的值。

为了实现下一个规范,我们将使用 React 通过 React.addons.TestUtils 对象提供的几个辅助方法,如下所示:

describe("InvestmentListItem", function() {
  var TestUtils = React.addons.TestUtils;

  describe("given an Investment", function() {
    var investment, component;

    beforeEach(function() {
      investment = new Investment({
        stock: new Stock({ symbol: 'peto', sharePrice: 0.25 }),
        shares: 100,
        sharePrice: 0.20
      });

      component = TestUtils.renderIntoDocument(
        <InvestmentListItem investment={investment}/>
      );
    });

    it("should render the return of investment as a percentage", function() {
      var roi = TestUtils.findRenderedDOMComponentWithClass(component, 'roi');
      expect(roi.getDOMNode()).toHaveText('25%');
    });
  });
});

如您所见,我们不再使用来自 jasmine-jquery 匹配器的 setFixture 方法。相反,我们使用 TestUtils 模块来渲染组件。

这里的最大区别是 TestUtils.renderIntoDocument 并没有在文档中渲染,而是在一个分离的节点中渲染。

下一个您会注意到的是,InvestmentListItem 组件有一个属性(实际上称为 prop),我们通过它传递 investment

然后,在规范中,我们使用另一个名为 findRenderedDOMComponentWithClass 的辅助方法来在 component 变量中查找 DOM 元素。

此方法返回 ReactElement。再次强调,我们将使用 getDOMNode 方法获取实际的 DOM 元素,然后使用 jasmine-jquery 匹配器检查其文本值,如下所示:

var roi = TestUtils.findRenderedDOMComponentWithClass(component, 'roi');
expect(roi.getDOMNode()).toHaveText('25%');

在组件中实现此行为实际上非常简单:

(function (React) {
  var InvestmentListItem = React.createClass({
    render: function () {
      var investment = this.props.investment;

      return <li className="investment-list-item">
        <article>
          <span className="roi">{formatPercentage(investment.roi())}</span>
        </article>
      </li>;
    }
  });

  function formatPercentage (number) {
    return (number * 100).toFixed(0) + '%';
  }

  this.InvestmentListItem = InvestmentListItem;
})(React);

我们可以通过 this.props 对象访问传递给组件的任何 props。

扩展原始实现,我们添加了一个具有预期类的 span 元素。

为了使投资回报率具有动态性,JSX 有一个特殊的语法。使用 {},你可以在 XML 中调用任何 JavaScript 代码。我们在这里调用 formatPercentage 函数,并传递 investment.roi() 的值,如下所示:

<span className="roi">{formatPercentage(investment.roi())}</span>

再次,只是为了使这一点更清晰,这个 JSX 转换为 JavaScript 会是:

React.createElement("span", {className: "roi"}, formatPercentage(investment.roi()))

需要知道的是,一个 prop 应该是不可变的。组件改变其自身的 prop 值不是组件的责任。你可以将只有一个 props 的 React 组件视为一个纯函数,因为它总是返回相同的值,前提是给定的参数值相同。

这使得测试变得非常简单,因为没有突变或状态变化来测试组件。

组件事件

UI 应用程序有用户事件;在网页中,它们以 DOM 事件的形式出现。由于 React 将每个 DOM 元素包装成 React 元素,因此处理它们会有所不同,但仍然非常熟悉。

对于下一个示例,假设我们的应用程序将允许用户删除投资。我们可以通过以下验收标准来编写这个需求:

给定一个投资,当点击删除按钮时,InvestmentListItem 应该通知观察者 onClickDelete。

这里的想法与第三章“整合视图与观察者”部分中提到的相同,第三章,测试前端代码

那么,我们如何在组件中设置观察者呢?正如我们之前所看到的,props 是将属性传递给我们的组件的方式,如下所示:

describe("InvestmentListItem", function() {
  var TestUtils = React.addons.TestUtils;

  describe("given an Investment", function() {
    var investment, component, onClickDelete;

    beforeEach(function() {
      investment = new Investment({
        stock: new Stock({ symbol: 'peto', sharePrice: 0.25 }),
        shares: 100,
        sharePrice: 0.20
      });

      onClickDelete = jasmine.createSpy('onClickDelete');

      component = TestUtils.renderIntoDocument(
        <InvestmentListItem investment={investment} onClickDelete={onClickDelete}/>
      );
    });

    it("should notify an observer onClickDelete when the delete button is clicked", function() {
      var deleteButton = TestUtils.findRenderedDOMComponentWithTag(component, 'button');
      TestUtils.Simulate.click(deleteButton);
      expect(onClickDelete).toHaveBeenCalled();
    });

  });
});

正如你所见,我们向下传递了另一个 prop 给 onClickDelete 组件,并将其值设置为 Jasmine 间谍,如下所示:

onClickDelete = jasmine.createSpy('onClickDelete');

component = TestUtils.renderIntoDocument(
  <InvestmentListItem investment={investment} onClickDelete={onClickDelete}
/>
);

然后,我们通过其标签找到了删除按钮,并使用 TestUtils 模块模拟了一个点击,期望之前创建的间谍被调用,如下所示:

var deleteButton = TestUtils.findRenderedDOMComponentWithTag(component, 'button');
TestUtils.Simulate.click(deleteButton);
expect(onClickDelete).toHaveBeenCalled();

TestUtils.Simulate 模块包含用于模拟所有类型 DOM 事件的辅助方法,如下所示:

TestUtils.Simulate.**click**(node);
TestUtils.Simulate.**change**(node, {target: {value: 'Hello, world'}});
TestUtils.Simulate.**keyDown**(node, {key: "Enter"});

然后,我们回到了实现阶段:

(function (React) {
  var InvestmentListItem = React.createClass({
    render: function () {
      var investment = this.props.investment;
      **var onClickDelete = this.props.onClickDelete;**

      return <li className="investment-list-item">
        <article>
          <span className="roi">{formatPercentage(investment.roi())}</span>
          <button className="delete-investment" **onClick={onClickDelete}**>Delete</button>
        </article>
      </li>;
    }
  });

  function formatPercentage (number) {
    return (number * 100).toFixed(0) + '%';
  }

  this.InvestmentListItem = InvestmentListItem;
})(React);

正如你所见,这就像嵌套另一个 button 组件并将 onClickDelete prop 值作为其 onClick prop 传递一样简单。

React 规范化事件,以便在不同浏览器中具有一致的属性,但其命名约定和语法与 HTML 中的内联 JavaScript 代码相似。要获取支持事件的完整列表,你可以查看官方文档facebook.github.io/react/docs/events.html

# 组件状态

到目前为止,我们一直将 React 视为一个无状态的渲染引擎,但正如我们所知,应用程序有状态,尤其是在使用表单时。那么,我们如何实现 NewInvestment 组件,以便它能够保留正在创建的投资的值,并在用户完成表单后通知观察者?

为了帮助我们实现这种行为,我们将使用另一个组件内部 API——它的状态

让我们看看以下验收标准:

假设NewInvestment组件的输入已经正确填写,当表单提交时,它应该通过投资属性通知onCreate观察者:

describe("NewInvestment", function() {
  var TestUtils = React.addons.TestUtils;
  var component, onCreateSpy;

  function findNodeWithClass (className) {
    return TestUtils.findRenderedDOMComponentWithClass(component, className).getDOMNode();
  }

  beforeEach(function() {
    onCreateSpy = jasmine.createSpy('onCreateSpy');
    component = TestUtils.renderIntoDocument(
      <NewInvestment onCreate={onCreateSpy}/>
    );
  });

  describe("with its inputs correctly filled", function() {
    beforeEach(function() {
      var stockSymbol = findNodeWithClass('new-investment-stock-symbol');
      var shares = findNodeWithClass('new-investment-shares');
      var sharePrice = findNodeWithClass('new-investment-share-price');

      TestUtils.Simulate.change(stockSymbol, { target: { value: 'AOUE' }});
      TestUtils.Simulate.change(shares, { target: { value: '100' }});
      TestUtils.Simulate.change(sharePrice, { target: { value: '20' }});
    });

    describe("when its form is submitted", function() {
      beforeEach(function() {
        var form = component.getDOMNode();
        TestUtils.Simulate.submit(form);
      });

      it("should invoke the 'onCreate' callback with the investment attributes", function() {
        var investmentAttributes = { stockSymbol: 'AOUE', shares: '100', sharePrice: '20' };

        expect(onCreateSpy).toHaveBeenCalledWith(investmentAttributes);
      });
    });
  });
});

这个规范基本上使用了我们至今所学到的每一个技巧,所以不深入细节,让我们直接进入组件实现。

任何具有状态的组件必须首先声明其初始状态,通过定义一个getInitialState方法,如下所示:

var NewInvestment = React.createClass({
 getInitialState: function () {
 return {
 stockSymbol: '',
 shares: 0,
 sharePrice: 0
 };

},

  render: function () {
 var state = this.state;

    return <form className="new-investment">
      <h1>New investment</h1>
      <label>
        Symbol:
        <input type="text" ref="stockSymbol" className="new-investment-stock-symbol" value={state.stockSymbol} maxLength="4"/>
      </label>
      <label>
        Shares:
        <input type="number" className="new-investment-shares" value={state.shares}/>
      </label>
      <label>
        Share price:
        <input type="number" className="new-investment-share-price" value={state.sharePrice}/>
      </label>
      <input type="submit" className="new-investment-submit" value="Add"/>
    </form>;
  }
});

如前述代码所示,我们明确定义了表单的初始状态,并在渲染方法中将状态作为value属性传递给输入组件。

如果你在一个浏览器中运行这个示例,你会注意到你无法更改输入的值。你可以聚焦到输入上,但尝试输入不会改变其值,这是因为 React 的工作方式。

与 HTML 不同,React 组件必须在任何时间点表示视图的状态,而不仅仅是初始化时间。如果我们想改变输入的值,我们需要监听输入的onChange事件,并使用这些信息更新状态。状态的改变将触发一个渲染,从而更新屏幕上的值。

为了演示这是如何工作的,让我们在stockSymbol输入处实现这种行为。

首先,我们需要更改渲染方法,向onChange事件添加一个处理程序:

<input type="text" ref="stockSymbol" className="new-investment-stock-symbol" value={state.stockSymbol} maxLength="4" onChange={this._handleStockSymbolChange}/>

一旦事件被触发,它将调用_handleStockSymbolChange方法。其实现应该通过调用this.setState方法并传入输入的新值来更新状态,如下所示:

var NewInvestment = React.createClass({
  getInitialState: function () {
    // ... Method implementation
  },

  render: function () {
    // ... Method implementation
  },

_handleStockSymbolChange: function (event) {
 this.setState({ stockSymbol: event.target.value });
 }
});

事件处理程序是一个在将输入数据传递到状态之前执行简单验证或转换的好地方。

正如你所见,这只是一大堆样板代码,只是为了处理单个输入。由于我们并没有在我们的事件处理程序中实现任何自定义行为,我们可以使用 React 的一个特殊功能,该功能为我们实现了这种“链接状态”。

我们将使用一个名为MixinLinkedStateMixin;但首先,什么是 Mixin?它是一种在组件之间共享常见功能的方法,在这种情况下,是“链接状态”。看看以下代码:

var NewInvestment = React.createClass({

mixins: [React.addons.LinkedStateMixin],

  // ...

  render: function () {
    // ...
    <input type="text" ref="stockSymbol" className="new-investment-stock-symbol" valueLink={this.linkState('stockSymbol')} maxLength="4" />
    // ...
  }
});

LinkedStateMixin通过向组件添加linkState函数来实现,而不是设置输入的value,我们设置一个特殊的属性valueLink,该属性由this.linkState函数返回的链接对象设置。

linkState函数期望的是应该链接到输入值的状态属性的名称。

组件生命周期

如你可能已经注意到的,React 对组件的 API 有很强的观点。但它也对组件的生命周期有很强的观点,允许我们开发者添加钩子以创建自定义行为并在开发组件时执行清理任务。

这也是 React 最大的成功之一,因为正是通过这种标准化,我们可以通过组合创建更大更好的组件;通过这种方式,我们不仅可以使用自己的组件,还可以使用其他人的组件。

为了演示一个用例,我们将实现一个非常简单的行为:在页面加载时,我们希望新的投资表股票符号输入被聚焦,以便用户可以立即开始输入。

但是,在我们开始编写测试之前,我们只需要做一件事。如前所述,TestUtils.renderIntoDocument 并没有在文档中实际渲染任何内容,而是在一个分离的节点上。因此,如果我们用它来渲染我们的组件,我们就无法对输入的焦点进行断言。

因此,我们再次不得不使用 setFixtures 方法来在文档中实际渲染 React 组件,如下所示:

/**
  Uses jasmine-jquery fixtures to actually render in the document.
  React.TestUtils.renderIntoDocument renders in a detached node.

  This was required to test the focus behavior.
 */
function actuallyRender (component) {
  setFixtures('<div id="application-container"></div>');
  var container = document.getElementById('application-container');
  return React.render(component, container);
}

describe("NewInvestment", function() {
  var TestUtils = React.addons.TestUtils;
  var component, stockSymbol;

  function findNodeWithClass (className) {
    return TestUtils.findRenderedDOMComponentWithClass(component, className).getDOMNode();
  }

  beforeEach(function() {
    component = actuallyRender(<NewInvestment onCreate={onCreateSpy}/>);
    stockSymbol = findNodeWithClass('new-investment-stock-symbol');
  });

  it("should have its stock symbol input on focus", function() {
    expect(stockSymbol).toBeFocused();
  });
});

完成这个小改动,并编写完规范后,我们可以回到实现上。

React 提供了一些钩子,我们可以在组件的生命周期中实现自定义代码;它们如下所示:

  • componentWillMount

  • componentDidMount

  • componentWillReceiveProps

  • shouldComponentUpdate

  • componentWillUpdate

  • componentDidUpdate

  • componentWillUnmount

为了实现我们的自定义行为,我们将使用仅在组件渲染并附加到 DOM 元素之后调用的 componentDidMount 钩子。

因此,我们想要做的是在这个钩子内部,以某种方式获取对输入 DOM 元素的访问权限并触发其焦点。我们已经知道如何获取 DOM 节点;它是通过 getDOMNode API。但是,我们如何获取输入的 React 元素?

React 为此问题提供的另一个特性称为 ref。它基本上是一种给组件的子元素命名的方法,以便以后可以访问。

由于我们想要股票符号输入,我们需要给它添加一个 ref 属性,如下所示:

<input type="text" ref="stockSymbol" className="new-investment-stock-symbol" valueLink={this.linkState('stockSymbol')} maxLength="4" />

然后,在 componentDidMount 钩子中,我们可以通过其 ref 名称获取输入,然后获取其 DOM 元素并触发焦点,如下所示:

var NewInvestment = React.createClass({
  // ...

componentDidMount: function () {
 this.refs.stockSymbol.getDOMNode().focus();
 }
,
  // ...
});

其他钩子以相同的方式设置,只需在类定义对象上定义它们作为属性。但每个钩子在不同的情况下被调用,有不同的规则。官方文档是关于它们定义和可能用例的绝佳资源,可以在 facebook.github.io/react/docs/component-specs.html#lifecycle-methods 找到。

组合组件

我们已经就通过组合 React 的默认组件来创建组件的方式讨论了很多关于 composability 的内容。然而,我们还没有展示如何将自定义组件组合成更大的组件。

如你所猜,这应该是一个相当简单的练习,为了演示它是如何工作的,我们将实现一个组件来列出投资,如下所示:

var InvestmentList = React.createClass({
  render: function () {
    var onClickDelete = this.props.onClickDelete;

    var listItems = this.props.investments.map(function (investment) {
      return <InvestmentListItem investment={investment}
                  onClickDelete={onClickDelete.bind(null, investment)}/>;
    });

    return <ul className="investment-list">{listItems}</ul>;
  }
});

这就像使用已可用的InvestmentListItem全局变量作为InvestmentList组件的根元素一样简单。

组件期望investments属性是一个投资数组。然后它通过为数组中的每个投资创建一个InvestmentListItem元素来映射它。

最后,它使用listItems数组作为ul元素的子元素,有效地定义了如何渲染投资列表。

摘要

React 是一个快速发展的库,正受到 JavaScript 社区的广泛关注;随着它不断改进丰富网络应用程序的开发,它引入了一些有趣的模式,并对一些根深蒂固的教条提出了质疑。

本章的目标不是深入探讨这个库,而是概述其主要特性和哲学。它展示了在用 React 编写界面时,可以进行测试驱动开发。

你了解了属性状态以及它们之间的区别:属性不属于组件,如果需要,应由其父组件更改。状态是组件拥有的数据。它可以由组件更改,并且通过这样做,会触发新的渲染。

在你的应用程序中,具有状态的组件越少,就越容易对其进行分析和测试。

是通过 React 有见地的 API 和生命周期,我们可以获得最大限度的可组合性和代码重用优势。

当你开始使用 React 进行应用程序开发时,建议你了解 Facebook 推荐的架构 Flux,可以在facebook.github.io/flux/上找到。**

第八章. 构建自动化

我们看到了如何使用 Jasmine 测试从头开始创建一个应用程序。然而,随着应用程序的增长和文件数量的增加,管理它们之间的依赖关系可能会变得有些困难。

例如,投资模型和股票模型之间存在依赖关系,它们必须按正确的顺序加载才能工作。因此,我们尽我们所能;我们按顺序加载脚本,以便在投资加载后股票可用。下面是如何做到这一点的示例:

<script type="text/javascript" src="img/Stock.js"></"script>
<script type="text/javascript" src="img/Investment.js"></"script>

然而,这很快就会变得繁琐且难以管理。

另一个问题是在加载所有文件时应用程序使用的请求数量;一旦应用程序开始增长,它可能达到数百。

因此,我们在这里遇到了一个悖论;虽然将代码拆分成小模块对于代码可维护性是有好处的,但对于客户端性能来说却是不利的,因为单个文件更受欢迎。

一个完美的世界是同时满足以下两个要求:

  • 在开发过程中,我们有一堆包含不同模块的小文件

  • 在生产环境中,我们有一个包含所有这些模块内容的单个文件

显然,我们需要某种构建过程。使用 JavaScript 实现这些目标有几种不同的方法,但我们将专注于webpack

模块打包器 – webpack

Webpack 是由 Tobias Koppers 创建的一个模块打包器,旨在帮助创建大型和模块化的前端 JavaScript 应用程序。

它与其他解决方案的主要区别在于它支持任何类型的模块系统(AMD 和 CommonJS),语言(CoffeeScript、TypeScript 和 JSX)以及通过加载器甚至资产(图像和模板)。

你没有看错,即使是图像;如果在 React 应用程序中,一切都是组件,在 webpack 项目中,一切都是模块。

它构建了所有资产的依赖关系图,在开发环境中提供服务,并针对生产环境进行优化。

模块定义

JavaScript 是一种基于 ECMA Script 规范的编程语言,直到版本 6 之前,还没有模块的标准定义。这种缺乏正式标准导致了多个竞争性的社区标准(AMD 和 CommonJS)和实现(RequireJS 和 browserify)。

现在,有一个标准可以遵循,但不幸的是,在现代浏览器中并没有对其的支持,所以我们应该使用哪种风格来编写我们的模块?

好消息是,现在可以通过转译器使用 ES6,这给我们带来了未来保障的优势。

一个流行的转译器是Babel(babeljs.io/),我们将通过加载器与 webpack 一起使用。

我们稍后会看到如何与 webpack 一起使用它,但首先重要的是要理解是什么让 ES6 模块成为可能。下面是一个没有依赖关系的简单定义:

function MyModule () {};
export default MyModule;

让我们将其与我们之前声明模块的方式进行比较。下一个示例显示了如果使用第三章(第三章)中提出的约定编写的代码将会是什么样子:

(function () {
  function MyModule() {};
  this.MyModule = MyModule;
}());

最大的区别是缺少 IIFE。ES6 模块默认具有自己的作用域,因此不可能意外地污染全局命名空间。

第二个区别是模块值不再附加到全局对象上,而是作为默认模块值导出:

function MyModule () {};
export default MyModule;

关于模块的依赖项,到目前为止,所有内容都是全局可用的,因此我们将依赖项作为 IIFE(立即执行函数表达式)的参数传递给模块,如下所示:

(function ($) {
  function MyModule() {};
  this.MyModule = MyModule;
}(jQuery));

然而,随着你在项目中开始使用 ES6 模块,将不再有全局变量。那么,你如何将这些依赖项放入模块中?

如果你还记得之前的内容,ES6 示例是通过 export default 语法导出模块值的。所以,给定一个模块有一个值,我们只需要将其作为依赖项请求。让我们将 jQuery 依赖项添加到我们的 ES6 模块中:

import $ from 'jQuery';
function MyModule () {};
export default MyModule;

在这里,$ 代表将加载依赖项的变量名称,而 jQuery 是文件名。

还可以将多个值作为模块的结果导出,并将这些值导入不同的变量中,但就本书的范围而言,默认值就足够了。

ES6 标准向 JavaScript 语言引入了许多不同的结构,这些结构也超出了本书的范围。有关更多信息,请参阅 Babel 的优秀文档babeljs.io/docs/learn-es6/

Webpack 项目设置

Webpack 作为 NPM 包可用,其设置非常简单,将在下一节中演示。

小贴士

理解 NPM 和 Node.js 之间的区别很重要。NPM 既是包管理器也是包格式,而 Node.js 是一个平台,NPM 模块通常在其中运行。

使用 NPM 管理依赖项

我们已经得到了一个 Node.js 项目的雏形,但随着我们在本章中开始使用更多依赖项,我们需要正式定义项目所依赖的所有 NPM 包。

要将项目定义为 NPM 包,同时包含其所有依赖项,我们需要在应用程序的根目录中创建一个特殊文件 package.json。它可以通过单个命令轻松创建:

npm init

它将提示一系列关于项目的问题,所有这些问题都可以保留它们的默认值。最后,你应该有一个包含类似以下内容的文件,具体取决于你的文件夹名称:

{
  "name": "jasmine-testing-project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts":" {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
  Is this ok? (Yes)

下一步是安装所有依赖项,目前只有 express。

npm install --save express

之前的命令不仅会安装第四章中描述的 express,异步测试 - AJAX,还会将其添加到package.json文件的依赖项中。运行npm init命令(如之前所述)后,我们得到以下输出,显示了dependencies属性:

{
  "name": "jasmine-testing-project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
 "express": "⁴.12.0"
 }
}

现在我们已经了解了如何管理我们项目的依赖关系,我们可以将webpackBabel作为开发依赖项安装,以开始捆绑我们的模块,如下所示:

npm install --save-dev babel-loader webpack webpack-dev-server

最后一步是在package.json中添加一个脚本来启动开发服务器:

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "dev": "webpack-dev-server"
}

这允许我们使用简单的命令启动开发服务器:

npm run dev

webpack-dev-server可执行文件的实际位置在./node_modules/.bin文件夹中。因此,npm run dev与以下命令相同:

./node_modules/.bin/webpack-dev-server

这之所以有效,是因为当你运行npm run <scriptName>时,NPM 会将./node_modules/.bin文件夹添加到路径中。

Webpack 配置

接下来,我们需要配置 webpack,使其知道要捆绑哪些文件。这可以通过在项目的根目录下创建一个webpack.config.js文件来实现。其内容应该是:

module.exports = {
  context: __dirname,
  entry: {
    spec: [
      './spec/StockSpec.js',
      './spec/InvestmentSpec.js',
      './spec/components/NewInvestmentSpec.jsx',
      './spec/components/InvestmentListItemSpec.jsx',
      './spec/components/InvestmentListSpec.jsx'
    ]
  },

  output: {
    filename: '[name].js'
  },

  module: {
    loaders: [
      {
        test: /(\.js)|(\.jsx)$/,
        exclude: /node_modules/,
        loader: 'babel-loader'
      }
    ]
  }
};

关于此配置文件有几个要点:

  • context指令告诉 webpack 在__dirname中查找模块,这意味着项目的根目录。

  • entry指令指定了应用程序的入口点。由于我们目前只进行测试,因此有一个名为spec的单个入口点,它引用了所有规范文件。

  • output.filename指令用于告知每个入口点的文件名。在编译过程中,[name]模式将被入口点名称替换。因此,spec.js实际上将包含我们所有的规范代码。

  • module.loaders的最终指令告诉 webpack 如何处理不同类型的文件。在这里,我们使用babel-loader参数来为源文件添加对 ES6 模块和 JSX 语法的支持。exclude指令很重要,以免浪费编译node_modules文件夹中的任何依赖。

完成此设置后,您可以通过访问http://localhost:8080/spec.js(配置文件中定义的文件名)来检查转换后的捆绑包的外观。

到目前为止,webpack 配置已经完成,我们可以开始将 Jasmine 运行器适配以运行规范。

规范运行器

如前所述,我们使用 webpack 来编译和捆绑源文件,因此 Jasmine 规范将变得非常简单:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Jasmine Spec Runner v2.1.3</title>

  <link rel="shortcut icon" type="image/png" href="lib/jasmine-2.1.3/jasmine_favicon.png">
  <link rel="stylesheet" href="lib/jasmine-2.1.3/jasmine.css">

  <script src="img/jasmine.js"></script>
  <script src="img/jasmine-html.js"></script>
  <script src="img/boot.js"></script>

  <script src="img/jquery.js"></script>
  <script src="img/jasmine-jquery.js"></script>

  <script src="img/mock-ajax.js"></script>

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

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

有几点需要注意:

首先,我们不再需要上一章中解释的 JSX 转换器黑客技巧;转换现在由 webpack 和 babel-loader 完成。因此,我们可以很好地使用默认的 Jasmine 引导。

其次,我们选择将测试运行器的依赖项作为全局(Jasmine、Mock Ajax、Jasmine JQuery 和 Spec helper)。将它们设置为全局使我们的测试运行器变得更加简单,并且从模块化的角度来看,我们不会伤害我们的代码。

在此时,尝试在http://localhost:8080/SpecRunner.html运行测试应该会因为缺少引用而产生很多失败。这是因为我们仍然需要将我们的 spec 和源文件转换为 ES6 模块。

测试一个模块

要运行所有测试,需要将所有源文件和 spec 文件转换为 ES6 模块。在 spec 中,这意味着添加所有源模块作为依赖项:

import Stock from '../src/Stock';
describe("Stock", function() {
  // the original spec code
});

在源文件中,这意味着声明所有依赖项以及导出其默认值,如下所示:

import React from 'react';
var InvestmentListItem = React.createClass({
  // original code
});
export default InvestmentListItem;

一旦所有代码都转换为,在启动开发服务器并将浏览器再次指向运行器 URL 时,测试应该会工作。

测试运行器:Karma

记得我们在介绍中提到过,我们可以执行 Jasmine 而不需要浏览器窗口?为了做到这一点,我们将使用PhantomJS,一个可脚本化的无头 WebKit 浏览器(与 Safari 浏览器相同的渲染引擎)和Karma,一个测试运行器。

设置非常简单;使用 NPM,我们再次安装一些依赖项:

npm install –save-dev karma karma-jasmine karma-webpack karma-phantomjs-launcher es5-shim

这里唯一的奇怪依赖是es5-shim,它用于为 PhantomJS 提供一些它仍然缺少的 ES5 功能,而 React 需要。

下一步是创建一个配置文件,命名为karma.conf.js,位于项目的根目录:

module.exports = function(config) {
  config.set({
    basePath: '.',

    frameworks: ['jasmine'],
    browsers: ['PhantomJS'],

    files: [
      // shim to workaroud PhantomJS 1.x lack of 'bind' support
      // see: https://github.com/ariya/phantomjs/issues/10522
      'node_modules/es5-shim/es5-shim.js',
      'lib/jquery.js',
      'lib/jasmine-jquery.js',
      'lib/mock-ajax.js',
      'spec/SpecHelper.js',
      'spec/**/*Spec.*'
    ],

    preprocessors: {
      'spec/**/*Spec.*': ['webpack']
    },

    webpack: require('./webpack.config.js'),
    webpackServer: { noInfo: true },
    singleRun: true
  });
};

在其中,我们设置了 Jasmine 框架和 PhantomJS 浏览器:

frameworks: ['jasmine'],
browsers: ['PhantomJS'],

通过加载es5-shim来修复 PhantomJS 上的浏览器兼容性问题,如下代码所示:

// shim to workaroud PhantomJS 1.x lack of 'bind' support
// see: https://github.com/ariya/phantomjs/issues/10522
'node_modules/es5-shim/es5-shim.js',

加载测试运行器依赖项,这些依赖项之前在SpecRunner.html文件中是全局的,如下代码所示:

'lib/jquery.js',
'lib/jasmine-jquery.js',
'lib/mock-ajax.js',
'spec/SpecHelper.js',

最后,加载所有 spec,如下所示:

'spec/**/*Spec.*',

到目前为止,你可以删除SpecRunner.html文件,webpack.config.js文件中的 spec 条目,以及lib/jasmine-2.1.3文件夹。

通过调用 Karma 来运行测试,它将在控制台打印测试结果,如下所示:

./node_modules/karma/bin/karma start karma.conf.js
> investment-tracker@0.0.1 test /Users/paulo/Dropbox/jasmine_book/second_edition/book/chapter_8/code/webpack-karma
> ./node_modules/karma/bin/karma start karma.conf.js
INFO [karma]: Karma v0.12.31 server started at http://localhost:9876/
INFO [launcher]: Starting browser PhantomJS
INFO [PhantomJS 1.9.8 (Mac OS X)]: Connected on socket cGbcpcpaDgX14wdyzLZh with id 37309028
PhantomJS 1.9.8 (Mac OS X): Executed 36 of 36 SUCCESS (0.21 secs / 0.247 secs)

为了简化测试的运行,我们可以更改package.json项目文件并描述其测试脚本:

"scripts": {
  "test": "./node_modules/karma/bin/karma start karma.conf.js",
  "dev": "webpack-dev-server"
},

你可以通过简单地调用以下命令来运行测试:

npm test

快速反馈循环

自动化测试的核心是快速反馈循环,所以想象一下,在文件更改后,测试能在控制台运行,而应用在浏览器上刷新,这会是可能的吗?答案是肯定的!

监视并运行测试

通过在启动 Karma 时使用一个简单的参数,我们可以实现测试的极乐境界,如下所示:

./node_modules/karma/bin/karma start karma.conf.js --auto-watch --no-single-run

亲自尝试;运行此命令,更改一个文件,看看测试是否自动运行——就像魔法一样。

再次,我们不想记住这些复杂的命令,所以让我们在package.json文件中添加另一个脚本:

"scripts": {
  "test": "./node_modules/karma/bin/karma start karma.conf.js",
  "watch-test": "./node_modules/karma/bin/karma start karma.conf.js --auto-watch --no-single-run",
  "dev": "webpack-dev-server"
},

我们可以通过以下命令来运行它:

npm run watch-test

监视并更新浏览器

为了达到开发上的极乐境界,我们只需一个参数即可。

在启动开发服务器时,将以下内容添加到package.json文件中:

./node_modules/.bin/webpack-dev-server --inline –hot

再次尝试在你的浏览器上运行;在文本编辑器中更改一个文件,浏览器应该刷新。

你还被鼓励更新package.json文件,以便运行npm run dev时获得“实时重新加载”的好处。

优化生产环境

我们模块打包器目标的最后一步是生成一个压缩并准备好生产的文件。

大部分的配置已经完成,只需再进行几个步骤。

第一步是为应用程序设置一个入口点,然后创建一个启动所有内容的索引文件,index.js,需要放置在src文件夹内,内容如下:

import React from 'react';
import Application from './Application.jsx';

var mountNode = document.getElementById('application-container''');
React.render(React.createElement(Application, {}), mountNode);

我们在书中没有详细讨论此文件的实现,所以请确保检查附带的源文件以更好地理解其工作原理。

在 webpack 配置文件中,我们需要添加一个输出路径来指示捆绑文件将放置的位置,以及我们刚刚创建的新入口文件,如下所示:

module.exports = {
  context: __dirname,
  entry: {
 index: './src/index.js'
 },

  output: {
    path: 'dist',
    filename: '[name]-[hash].js'
  },

  module: {
    loaders: [
      {
        test: /(\.js)|(\.jsx)$/,
        exclude: /node_modules/,
        loader: 'babel-loader'
      }
    ]
  }
};

然后,剩下的就是在我们package.json文件中创建一个构建任务:

"scripts": {
    "test": "./node_modules/karma/bin/karma start karma.conf.js",
    "watch-test": "./node_modules/karma/bin/karma start karma.conf.js --auto-watch --no-single-run",
    "build": "webpack -p",
    "dev": "webpack-dev-server --inline --hot"
  },

运行它,并检查构建文件是否已放入dist文件夹,如下所示:

npm run build

静态代码分析:JSHint

如第一章所述,JavaScript 不是一种编译型语言,但运行代码(如自动化测试的情况)并不是检查错误的唯一方式。

一类工具能够读取源文件,解释它们,并在不实际运行源文件的情况下查找常见的错误或不良实践。

一个非常流行的工具是JSHint——一个可以通过 NPM 安装的简单二进制文件,如下所示:

npm install --save-dev jshint jsxhint

你可以看到我们还在安装JSXHint,这是另一个用于执行 JSX 文件静态分析的工具。它基本上是在执行 JSX 转换的同时,围绕原始 JSHint 的一个包装器。

如果你记得上一章的内容,JSXTransformer 不会改变行号,所以 JavaScript 文件中给定行号的警告将在原始 JSX 文件中的相同行号处。

执行它们非常简单,如下所示:

./node_modules/.bin/jshint .
./node_modules/.bin/jsxhint .

然而,在运行测试时让它们运行也是一个好主意:

"scripts": {
    "start": "node bin/server.js",
    "test": "./node_modules/.bin/jshint . && ./node_modules/.bin/jsxhint . && ./node_modules/karma/bin/karma start karma.conf.js",
    "watch-test": "./node_modules/karma/bin/karma start karma.conf.js --auto-watch --no-single-run",
    "build": "webpack -p",
    "dev": "webpack-dev-server --inline --hot"
  },

最后一步是配置我们希望 JSHint 和 JSXHint 捕获的错误。再次,我们在项目的根目录中创建另一个配置文件,这次叫做.jshintrc

{
  "esnext": true,
  "undef": true,
  "unused": true,
  "indent": 2,
  "noempty": true,
  "browser": true,
  "node": true,
  "globals": {
    "jasmine": false,
    "spyOn": false,
    "describe": false,
    "beforeEach": false,
    "afterEach": false,
    "expect": false,
    "it": false,
    "xit": false,
    "setFixtures": false
  }
}

这是一个选项标志列表,要么启用要么禁用,其中最重要的是以下内容:

  • esnext:此标志告诉我们我们正在使用 ES6 版本

  • unused:此标志会在任何未使用的声明变量上断言

  • undef:此选项标志会在任何未声明的变量被使用时断言

此外,还有一个由测试使用的globals变量列表,用于防止由于undef标志导致的错误。

前往 JSHint 网站 jshint.com/docs/options/ 查看选项的完整列表。

唯一缺少的步骤是防止在别人的代码中运行 linter(Jasmine、React 等等)。这可以通过简单地创建一个包含它应该忽略的文件夹的文件来实现。这个名为 .jshintignore 的文件应该包含:

  • node_modules

  • lib

现在运行静态分析和所有测试就像这样简单:

npm test

持续集成 – Travis-CI

我们在项目周围创建了很多自动化,这对团队中引入新开发者非常有用;运行测试只需两个命令:

npm install
npm test

然而,这并不是唯一的好处;我们可以在持续集成环境中通过这两个命令运行测试。

为了演示一个可能的设置,我们将使用 Travis-CI (travis-ci.org),这是一个开源项目的免费解决方案。

在我们开始之前,你需要有一个 GitHub (github.com/) 账户,并且项目已经托管在那里。我预计你已经熟悉 git (www.git-scm.com/) 和 GitHub。

一旦你准备好了,我们就可以开始 Travis-CI 的设置了。

将项目添加到 Travis-CI

在我们能够将 Travis-CI 支持添加到项目中之前,首先我们需要将项目添加到 Travis-CI。

前往 Travis-CI 网站 travis-ci.org,然后在右上角点击 Sign in with GitHub

输入你的 GitHub 凭据,一旦你登录,它应该会显示包含你所有仓库的列表:

如果你的仓库没有显示出来,你可以点击右上角的 Sync Now 按钮让 Travis-CI 更新列表。

一旦你的仓库出现,通过点击开关来启用它。这将在你 GitHub 项目上设置钩子,这样 Travis-CI 就会在仓库中任何更改推送时收到通知。

项目设置

设置 Travis-CI 项目非常简单。由于我们的构建过程和测试都已脚本化,我们只需要告诉 Travis-CI 它应该使用什么运行时。

Travis-CI 知道 Node.js 项目依赖是通过 npm install 安装的,测试是通过 npm test 运行的,因此没有额外的步骤来运行我们的测试。

在项目根目录下创建一个名为 .travis.yml 的新文件,并将 Travis 的语言配置为 Node.js:

language: node_js

就这些了。

使用 Travis-CI 的步骤相当直接,将这些相同的概念应用到其他持续集成环境中,如 Jenkins (jenkins-ci.org/),应该相当简单。

摘要

在本章中,我希望已经向你展示了自动化的力量以及我们如何可以使用脚本使我们的生活变得更轻松。你学习了 webpack 及其如何被用来管理模块之间的依赖关系,并帮助你生成生产代码(打包和压缩)。

静态代码分析在帮助我们甚至在代码运行之前发现错误方面的力量。

你还看到了如何无头运行你的规范,甚至可以自动运行,让你可以一直专注于代码编辑器。

最后,我们看到了使用持续集成环境是多么简单,以及我们如何可以使用这个强大的概念来确保我们的项目始终处于测试状态。

posted @ 2025-09-29 10:35  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报