Jasmine-JavaScript-测试-全-

Jasmine JavaScript 测试(全)

原文:zh.annas-archive.org/md5/298440D531543CD7EE2CF1AAAB25EE4F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是关于成为更好的 JavaScript 开发人员。因此,在这些章节中,您不仅将了解如何在 Jasmine 的“习惯用法”中编写测试,还将了解在 JavaScript 语言中编写软件的最佳实践。这是关于承认 JavaScript 作为应用程序开发的真正平台,并利用其所有潜力。这也涉及到工具和自动化,以及如何使您的生活更轻松和更高效。

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

《Jasmine JavaScript 测试,第二版》是一个实用指南,用于为 Web 应用程序编写和自动化 JavaScript 测试。它使用诸如 Jasmine、Node.js 和 webpack 等技术。

在这些章节中,通过开发一个简单的股票市场投资跟踪应用程序来解释了测试驱动开发的概念。它从测试的基础知识开始,通过开发基本的领域类(如股票和投资),经过可维护的浏览器代码的概念,并最终进行了完整的重构,构建了一个基于 ECMA Script 6 模块和自动构建的 React.js 应用程序。

本书涵盖的内容

第一章,“使用 Jasmine 入门”,介绍了测试 JavaScript 应用程序背后的动机。它介绍了 BDD 的概念以及它如何帮助您编写更好的测试。它还演示了下载 Jasmine 并开始编写您的第一个测试有多么容易。

第二章,“您的第一个规范”,帮助您了解以测试驱动开发思维方式的背后思维过程。您将编写您的第一个由测试驱动的 JavaScript 功能。您还将了解 Jasmine 的基本功能以及如何组织您的测试。还演示了 Jasmine 匹配器的工作原理,以及如何创建自己的匹配器来改进测试代码的可读性。

第三章,“测试前端代码”,涵盖了编写可维护的浏览器代码的一些模式。您将了解如何以组件的形式思考,以及如何使用模块模式更好地组织您的源文件。您还将了解 HTML fixtures 的概念,以及如何使用它来测试您的 JavaScript 代码,而无需让服务器呈现 HTML。您还将了解一个名为“jasmine-jquery”的 Jasmine 插件,以及它如何帮助您使用 jQuery 编写更好的测试。

第四章,“异步测试 - AJAX”,讨论了测试 AJAX 请求中的挑战,以及如何使用 Jasmine 测试任何异步代码。您将了解 Node.js 以及如何创建一个非常简单的 HTTP 服务器,以用作测试的 fixture。

第五章,“Jasmine 间谍”,介绍了测试替身的概念以及如何使用间谍进行行为检查。

第六章,“光速单元测试”,帮助您了解 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**

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种形式出现在文本中:“单击下一步按钮将您移至下一个屏幕。”

注意

警告或重要说明会以这样的方式出现在框中。

提示

提示和技巧会以这种方式出现。

第一章:使用 Jasmine 入门

成为 JavaScript 开发人员是一个令人兴奋的时刻;技术已经成熟,Web 浏览器更加标准化,每天都有新的东西可以玩。JavaScript 已经成为一种成熟的语言,而 Web 是当今真正开放的平台。我们已经看到单页 Web 应用的兴起,模型视图控制器MVC)框架的大量使用,如 Backbone.js 和 AngularJS,使用 Node.js 在服务器上使用 JavaScript,甚至使用诸如 PhoneGap 等技术完全使用 HTML、JavaScript 和 CSS 创建的移动应用程序。

从处理 HTML 表单的谦虚开始,到今天的大型应用程序,JavaScript 语言已经走了很远的路,随之而来的是一系列成熟的工具,以确保你在使用它时能够达到与其他语言相同的质量水平。

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

JavaScript - 不好的部分

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

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

此外,你还会遇到语言本身的问题。Brendan Eich 在 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 规范的第 5 版引入了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)的重新思考。

在传统的单元测试实践中,开发人员在如何开始测试过程、要测试什么、测试的规模有多大,甚至如何调用测试等方面都没有明确的指导。

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

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

假设有一个播放器,歌曲被暂停时,然后它应该指示歌曲当前是暂停状态。

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

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

  • :这定义了发生的事件

  • 然后:这确保了一个结果

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

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

    });
  });
});

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

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

下载 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 代码、源文件和测试文件。出于约定目的,我们将简称这个文件为runner

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

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

runner 引用了源文件:

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

runner 引用了一个特殊的SpecHelper.js文件,其中包含在规范之间共享的代码:

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

runner 还引用了规范文件:

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

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问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 函数,用于定义测试上下文。当作为规范中的第一个调用时,它会创建一个新的测试套件(一组测试用例)。它接受两个参数,如下所示:

  • 测试套件的名称——在本例中为“投资”

  • 一个包含所有规范的function

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

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

  **});**
});

它还接受两个参数,如下所示:

  • 规范的标题——在本例中为应该是股票

  • 一个包含规范代码的function

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

<!-- include spec files here... -->
**<script type="text/javascript" src="spec/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文件中创建一个investment变量,并使用Investment实例,如下所示:

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

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

Jasmine 基础和 BDD 思维

这里的规范要求一个 Investment 类

您可以看到错误已经改为Investment 未定义。现在要求Investment函数。因此,在src文件夹中创建一个新的Investment.js文件,并将其添加到 runner 中,如下所示:

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

要定义Investment,请在src文件夹中的Investment.js文件中编写以下构造函数:

function Investment () {};

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

Jasmine 基础和 BDD 思维

这显示了一个缺少 stock 的错误

再一次,我们将代码输入到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,并将其添加到 runner 中。由于Stock函数将成为Investment的依赖项,所以我们应该在Investment.js之前添加它:

<!-- include source files here... -->
**<script type="text/javascript" src="src/Stock.js"></script>**
<script type="text/javascript" src="src/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 思维

这显示了一个通过的 Investment 规范

这个练习是精心进行的,以展示开发人员在进行测试驱动开发时如何满足规范的要求。

提示

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

设置和拆卸

还有三个要实现的验收标准。列表中的下一个是:

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

写它应该和之前的规范一样简单。在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();

这与验收标准之间建立了更好的关系。

因此,在这里,“should be a good investment”变成了“expect investment to be a good investment”。

实现它非常简单。您可以通过调用jasmine.addMatchers函数来实现这一点,最好是在设置步骤(beforeEach)中。

尽管您可以将这个新的匹配器定义放在InvestmentSpec.js文件中,但 Jasmine 已经有一个默认的位置来添加自定义匹配器,即SpecHelper.js文件,位于spec文件夹内。如果您使用独立发行版,它已经带有一个示例自定义匹配器;删除它,让我们从头开始。

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 报告的错误消息是什么?

我们可以通过故意破坏Investment.js文件中src文件夹中的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();

InvestmentSpec.js文件的spec文件夹中添加新的和嵌套的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 源代码),它检查对象的所有属性和数组的所有元素是否相同,但在这里它只是简单使用了严格相等运算符===)。

如果您不熟悉严格相等运算符,它与equals 运算符==)的主要区别在于,如果比较的值不是相同类型,后者会执行类型强制转换。

提示

严格相等运算符始终将不同类型的值之间的比较视为 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匹配器。

toBeTruthy 和 toBeFalsy 匹配器

除了其原始布尔类型之外,JavaScript 语言中的所有其他内容也都具有固有的布尔值,通常被称为“truthy”或“falsy”。

幸运的是,在 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匹配器。

toBeUndefined、toBeNull 和 toBeNaN 内置匹配器

这些匹配器非常直观,应该用于检查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/)进行比较。

toBeLessThan 和 toBeGreaterThan 内置匹配器

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匹配器,可用于验证是否抛出了异常。

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

以下是它的工作示例:

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 匹配器,并知道它们在描述规范意图方面有多么强大。您甚至学会了创建自己的匹配器。

到目前为止,您应该已经熟悉了创建新规范并推动新应用程序的开发。

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

第三章:测试前端代码

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

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

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

为了实现用户界面,我们将使用 jQuery,这是一个众所周知的 JavaScript 库,它通过一个干净简单的 API 抽象了浏览器的 DOM,可以在不同的浏览器上运行。

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

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

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

到目前为止,我们已经创建了单独的抽象来处理投资及其相关的股票。现在,是时候开发这个应用程序的用户界面并取得良好的结果了,这完全取决于组织和良好的实践。

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

以组件(视图)的方式思考

我们已经讨论了困扰大部分网络的单片 JavaScript 代码库,这些代码库是不可能进行测试的。不陷入这个陷阱的最好方法是通过编写应用程序驱动的测试。

考虑一下我们的投资跟踪应用程序的模拟界面:

以组件(视图)的方式思考

这显示了投资跟踪应用程序的模拟界面

我们将如何实施它?很容易看出,这个应用程序有两个不同的责任:

  • 一个责任是添加一个投资

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

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

因此,在界面的顶层,有两个基本组件:

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

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

模块模式

因此,我们了解了如何分解代码,但是如何组织它呢?到目前为止,我们为每个新功能创建了一个文件。这是一个很好的做法,我们将看到如何改进它。

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

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

  }

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

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

它被称为 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的概念。

测试 fixtures 提供了测试运行的基本状态。它可以是类的实例化,对象的定义,或者一段 HTML。换句话说,为了测试处理表单提交的 JavaScript 代码,我们需要在运行测试时有表单可用。包含表单的 HTML 代码就是 HTML fixture。

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

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

然后,在拆卸期间将其删除,如下所示:

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

否则,规范将在文档中附加大量垃圾,并且可能会干扰其他规范的结果。

提示

重要的是要知道规范应该是独立的,并且可以以任何特定顺序运行。因此,作为一个规则,完全独立地处理规范。

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

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

将代码更改为以下内容:

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

这样,下次规范运行时,它会自动用自己的 fixture 覆盖上一个 fixture。

但是,随着 fixtures 变得更加复杂,这很快就会升级为一个难以理解的混乱:

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 fixture模块所做的。

我们可以将 HTML 代码放在外部文件中,并通过简单调用loadFixtures来加载它到文档中,传递 fixture 文件路径,如下所示:

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, …]):而不是覆盖 fixture 容器的内容,这会将其附加上

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

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

Jasmine jQuery fixture 模块缓存每个文件,因此我们可以多次加载相同的 fixture 而不会对测试套件的速度造成任何惩罚。

它使用 AJAX 加载 fixtures,有时,测试可能希望修改 JavaScript 或 jQuery AJAX 的内部工作方式,就像我们将在第六章中看到的那样,轻速单元测试,这可能会破坏 fixture 的加载。解决这个问题的方法是使用preloadFixtures()函数将所需的 fixtures 预加载到缓存中。

preloadFixtures(fixtureUrl[, fixtureUrl, …])函数在不将其附加到文档中的情况下加载一个或多个文件到缓存中。

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

解决这个问题的方法是通过 HTTP 服务器提供规范运行器,如第四章中所述,异步测试 - 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 fixture 插件开始编写规范的开发。

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

<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 fixture,因为它否则将由服务器呈现,而 JavaScript 代码只是附加到它并添加行为。

因为我们没有将这个 fixture 保存在插件的默认路径下,所以我们需要在SpecHelper.js文件的末尾添加一个新的配置,如下所示:

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

NewInvestmentSpec.js文件中,添加一个调用来加载 fixture:

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

最后,在添加Stock.jsInvestment.js文件之后,将规范和源添加到 runner 中,如下所示:

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

基本的 View 编码规则

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

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

  • 将 View 与观察者集成

所以,让我们看看它们如何单独工作。

视图应该封装一个 DOM 元素

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

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模式传递给 View 允许它在文档上的不同元素上实例化多次。

拥有明确关联的另一个优势是知道这个视图不会改变文档中的其他任何东西,我们将在下面看到。

视图是与 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的新实例,将两个视图作为参数传递。

稍后,在描述创建新的投资的行为时,我们可以看到对newView.create函数的调用来创建一个新的投资。

稍后,它检查listView对象是否添加了一个新项目,通过检查listView.count()是否等于1

但是集成是如何发生的呢?我们可以通过查看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 元素的期望。

使用这些自定义匹配器的最大优势,正如所示,是它们生成更好的错误消息。因此,尽管我们可以在不使用任何这些匹配器的情况下编写所有规范,但如果我们使用了这些匹配器,当发生错误时,它们会为我们提供更有用的信息。

为了更好地理解这个优势,我们可以回顾一下应该公开具有其 DOM 元素的属性规范的例子。在那里,它使用了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 fixture 模块加载的 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">');
});

toContainElement jQuery 匹配器

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

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

toHaveValue jQuery 匹配器

仅适用于输入,此代码验证预期值与元素的值属性是否匹配:

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

toHaveAttr jQuery 匹配器

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

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

toBeFocused jQuery 匹配器

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

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

toBeDisabled jQuery 匹配器

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

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

更多匹配器

该扩展有许多其他可用的匹配器;请确保查看项目文档 github.com/velesin/jasmine-jquery#jquery-matchers

摘要

在本章中,您学会了如何通过测试驱动应用程序开发可以变得更加容易。您看到了如何使用模块模式更好地组织项目代码,以及 View 模式如何帮助创建更易于维护的浏览器代码。

您学会了如何使用 HTML fixture,使您的规范更加易读和易懂。我还向您展示了如何通过自定义 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 Web 应用程序框架。通过阅读代码,您可以看到它定义了一个到/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();**
    }
  });
});

这里的区别在于,我们必须将期望移到success回调中,在调用done函数之前。

超时

在编写异步规范时,默认情况下,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 的spy是一种特殊类型的函数,记录与其发生的所有交互。因此,当返回值或对象状态的变化不能用于确定测试期望是否成功时,它们非常有用。换句话说,当测试成功只能通过行为检查来确定时,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 间谍是一种特殊类型的函数,用于跟踪对其进行的调用和参数。

然后,它将这个间谍设置为 View 的 create 事件的观察者,使用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 测试会增加测试的复杂性。在该章节的示例中,我们创建了一个结果可预测的服务器。基本上是一个复杂的测试装置。即使我们可以使用真实的服务器实现,它也会增加测试的复杂性;尝试从浏览器更改具有数据库或第三方服务的服务器的状态并不是一种容易或可扩展的解决方案。

还有对生产力的影响;这些请求需要时间来处理和传输,这会影响通常提供的快速反馈循环。

您还可以说这些规范测试了客户端和服务器代码,因此不能被视为单元测试;相反,它们可以被视为集成测试。

解决所有这些问题的一个方法是使用存根虚假来代替代码的真实依赖关系。因此,我们不是向服务器发出请求,而是在浏览器内部使用服务器的测试替身。

我们将使用第四章中的相同示例,异步测试 - 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 插件jasmine-ajax提供,是代替浏览器的 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="lib/mock-ajax.js"></script>

一个虚假的 XMLHttpRequest

每当你使用 jQuery 进行 AJAX 请求时,在幕后实际上是使用XMLHttpRequest对象来执行请求。

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

因此,我们可以改变XMLHttpRequest对象的假实现,而不是存根 jQuery。这正是这个插件所做的。

让我们重写先前的规范以使用这个虚假实现:

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 fixtures 模块)中的代码。以下是您可以实现这一点的方法:

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函数相同的属性。

摘要

在本章中,您了解了异步测试如何影响您可以通过单元测试获得的快速反馈循环。我展示了如何使用存根或假实现使您的规范更快地运行并减少依赖关系。

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

您还更加熟悉间谍和存根,并应该更加舒适地在不同场景中使用它们。

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

第七章:测试 React 应用程序

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

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

为了改善用户体验,越来越多的功能开始从服务器端推送到客户端,并且 JavaScript 不再仅仅是为页面添加行为,而是完全渲染页面。最大的优势是用户操作不再触发整个页面刷新;JavaScript 代码可以处理整个浏览器文档并相应地进行变异。

尽管这确实改善了用户体验,但它开始给应用程序代码增加了很多复杂性,导致维护成本增加,最糟糕的是——在屏幕不同部分之间存在不一致的错误形式。

为了使这种情况变得理智,建立了许多库和框架,但它们都失败了,因为它们没有解决整个问题的根本原因——可变性。

服务器端渲染很容易,因为没有变异要处理。给定一个新的应用程序状态,服务器将简单地重新渲染所有内容。如果我们能从这种方法中在客户端 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="lib/react-with-addons.js"></script>**
<script src="lib/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。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-containerID 的元素。然后,使用getElementById API,我们查询此元素并将其保存在container变量中。接下来的两个步骤是特定于 React 的步骤:

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

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

  • 要渲染元素的 DOM 节点

  1. 到目前为止,规范已经完成。您可以运行它并查看它失败,显示以下错误:
ReferenceError: InvestmentListItem is not defined.
  1. 下一步是编写组件。因此,让我们满足规范,在src文件夹中创建一个新文件,命名为InvestmentListItem.js,并将其添加到规范运行程序。此文件应遵循我们到目前为止一直在使用的模块模式。

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

(function (React) {
  var InvestmentListItem = **React.createClass**({

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

  this.InvestmentListItem = InvestmentListItem;
})(React);
  1. 至少,React.createClass方法期望一个应该返回 React 元素树的render函数。

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

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

与在beforeEach块中以前的用法的不同之处在于,这里还传递了一个props列表(带有className)和包含文本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.createElement调用创建的表示才能有效地转换为真实的 DOM 元素并附加到文档中。

ReactElements定义的这种表示是 React 称之为虚拟 DOM。ReactElement不应与 DOM 元素混淆;它实际上是 DOM 元素的轻量、无状态、不可变的虚拟表示。

那么为什么 React 要费力地创建一种新的表示 DOM 的方式呢?答案在于性能

随着浏览器的发展,JavaScript 的性能不断提高,如今的应用程序瓶颈实际上并不是 JavaScript。您可能听说过应该尽量少地触及 DOM,而 React 允许您通过让您与其自己的 DOM 版本交互来做到这一点。

然而,这并不是唯一的原因。React 构建了一个非常强大的差异算法,可以比较虚拟 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

为了让我们能够在 Jasmine 运行器中使用 JSX,我们需要做一些更改。

首先,我们需要将要使用 JSX 语法的文件重命名为.jsx。虽然这不是必需的,但它可以让我们轻松地识别出文件是否使用了这种特殊语法。

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

<script src="src/components/InvestmentListItem**.jsx**" **type="text/jsx"**></script>
<script src="spec/components/InvestmentListItemSpec**.jsx**" **type="text/jsx"**></script>

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

这个转换器已经捆绑在 React 起始套件中,所以只需在加载 React 后立即加载它,如下所示:

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

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

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

我们需要一种方法来告诉 Jasmine 等待这些文件加载和转换。

最简单的方法是更改jasmine-2.1.3文件夹中lib文件夹内的jasmineboot.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();

}());

正如你所看到的,它只是执行原始引导文件中以前注释的代码。

运行这个自定义引导非常简单。我们将它作为 JSX 类型添加到SpecRunner.html<head>标签的最后一行:


**<!-- After all JSX files were loaded and processed, the tests can finally run -->**
 **<script src="lib/jasmine-2.1.3/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 代码。我们在传递investment.roi()值时调用formatPercentage函数,如下所示:

<span className="roi">{formatPercentage(investment.roi())}</span>

再次强调一下,这个 JSX 转换成 JavaScript 将是:

React.createElement("span", {className: "roi"}, formatPercentage(investment.roi()))

重要的是要知道,prop 应该是不可变的。改变自己的 prop 值不是组件的责任。你可以将只有 props 的 React 组件视为纯函数,因为它总是在给定相同参数值的情况下返回相同的结果值。

这使得测试非常简单,因为没有变异或更改状态来测试组件。

组件事件

UI 应用程序有用户事件;在 Web 中,它们以 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 spy,如下所示:

**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属性值作为其onClick属性传递一样简单。

React 标准化事件,以便它们在不同浏览器中具有一致的属性,但其命名约定和语法类似于 HTML 中的内联 JavaScript 代码。要获取支持的事件的全面列表,可以在官方文档中查看facebook.github.io/react/docs/events.html

组件状态

到目前为止,我们已经将 React 视为一个无状态的渲染引擎,但是我们知道,应用程序有状态,特别是在使用表单时。那么,我们应该如何实现NewInvestment组件,以便它保持正在创建的投资的值,然后在用户完成表单后通知观察者?

为了帮助我们实现这种行为,我们将使用另一个组件内部 API——它的state

让我们看一下以下验收标准:

鉴于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 功能来为我们实现这个“链接状态”。

我们将使用一个名为LinkedStateMixinMixin;但首先,什么是 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,我们使用由函数this.linkState返回的链接对象设置一个名为valueLink的特殊属性。

linkState函数期望state的属性名称,它应该将其链接到输入的值。

组件生命周期

你可能已经注意到,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

为了实现我们的自定义行为,我们将使用componentDidMount钩子,该钩子仅在组件被呈现并附加到 DOM 元素后调用一次。

因此,我们想要在这个钩子内部以某种方式访问输入 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 的默认组件来创建组件的可组合性。然而,我们还没有展示如何将自定义组件组合到更大的组件中。

你可能已经猜到,这应该是一个非常简单的练习,为了演示这个工作原理,我们将实现一个列出投资的组件,如下所示:

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 社区的广泛关注;它引入了一些有趣的模式,并质疑了一些既定的教条,不断改进了丰富的 Web 应用程序的开发。

本章的目标不是深入了解这个库,而是概述其主要特性和理念。它证明了在使用 React 编写界面时可以进行测试驱动开发。

你学到了propstate以及它们的区别:prop不是组件所拥有的,如果需要,应该由其父组件进行更改。state是组件拥有的数据。它可以被组件更改,这样就会触发新的渲染。

在你的应用程序中,拥有状态的组件越少,就越容易理解和测试。

通过 React 的有主见的 API 和生命周期,我们可以最大程度地实现组合性和代码重用的好处。

当你开始使用 React 进行应用程序开发时,建议你了解 Flux,这是 Facebook 推荐的构建应用程序的架构,网址是facebook.github.io/flux/

第八章:构建自动化

我们看到如何使用 Jasmine 从头开始创建应用程序。然而,随着应用程序的增长和文件数量的增加,管理它们之间的依赖关系可能会变得有点困难。

例如,我们在 Investment 和 Stock 模型之间有一个依赖关系,它们必须按正确的顺序加载才能工作。因此,我们尽力而为;我们按照脚本的加载顺序进行排序,以便在加载 Investment 后 Stock 可用。下面是我们的做法:

<script type="text/javascript" src="src/Stock.js"></"script>
<script type="text/javascript" src="src/Investment.js"></"script>

然而,这很快就会变得繁琐和难以管理。

另一个问题是应用程序用于加载所有文件的请求数量;一旦应用程序开始增长,这个数量可能会增加到数百个。

因此,我们在这里有一个悖论;尽管将其分解为小模块有利于代码的可维护性,但对于客户端性能来说却是不利的,单个文件更加可取。

在同一时间满足以下两个要求将是完美的:

  • 在开发中,我们有一堆包含不同模块的小文件

  • 在生产中,我们有一个包含所有这些模块内容的单个文件

显然,我们需要一些构建过程。有许多不同的方法可以用 JavaScript 实现这些目标,但我们将专注于webpack

模块捆绑器 - webpack

Webpack 是由 Tobias Koppers 创建的模块捆绑器,用于帮助创建大型和模块化的前端 JavaScript 应用程序。

它与其他解决方案的主要区别在于它支持任何类型的模块系统(AMD 和 CommonJS)、语言(CoffeeScript、TypeScript 和 JSX)甚至通过加载器支持资产(图像和模板)。

你没看错,甚至包括图片;如果在 React 应用中,一切都是组件,在 webpack 项目中,一切都是模块。

它构建了所有资产的依赖图,在开发环境中为它们提供服务,并在生产环境中对它们进行优化。

模块定义

JavaScript 是一种基于 ECMA 脚本规范的语言,直到第 6 版,仍没有对模块的标准定义。这种缺乏正式标准导致了许多竞争的社区标准(AMD 和 CommonJS)和实现(RequireJS 和 browserify)。

现在,有一个标准可供遵循,但不幸的是,现代浏览器不支持它,那么我们应该使用哪种样式来编写我们的模块呢?

好消息是,通过转译器,我们可以今天就使用 ES6,这给了我们未来的优势。

一个流行的转译器是Babelbabeljs.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="lib/jasmine-2.1.3/jasmine.js"></script>
  <script src="lib/jasmine-2.1.3/jasmine-html.js"></script>
  <script src="lib/jasmine-2.1.3/boot.js"></script>

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

  <script src="lib/mock-ajax.js"></script>

  <script src="spec/SpecHelper.js"></script>

  **<script src="spec.js"></script>**
</head>
<body>
</body>
</html>

有一些要点:

首先,我们不再需要在前一章中解释的 JSX 转换器 hack;转换现在由 webpack 和 babel-loader 完成。因此,我们可以很好地使用默认的 Jasmine boot。

其次,我们选择将测试运行器依赖项保留为全局(Jasmine,Mock Ajax,Jasmine JQuery 和 Spec helper)。将它们保留为全局对于我们的测试运行器来说会简单得多,并且就模块化而言,我们不会伤害我们的代码。

此刻,尝试在http://localhost:8080/SpecRunner.html上运行测试应该会因缺少引用而产生许多失败。这是因为我们仍然需要将我们的规范和源转换为 ES6 模块。

测试一个模块

要能够运行所有测试,需要将所有源文件和规范文件转换为 ES6 模块。在规范中,这意味着将所有源模块添加为依赖项:

**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 功能的支持,而 PhantomJS 仍然缺少这些功能,React 需要。

下一步是在项目的根文件夹中创建一个名为karma.conf.js的配置文件,用于 Karma。

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.*',

到目前为止,您可以删除SpecRunner.html文件,webpack.config.js文件中的规范条目以及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 文件的静态分析的工具。它基本上是原始 JSHint 的包装器,同时执行 JSX 转换。

如果你还记得上一章,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,然后点击右上角的使用 GitHub 登录

输入您的 GitHub 凭据,一旦您登录,它应该显示您所有存储库的列表:

如果您的存储库没有显示出来,您可以点击立即同步按钮,让 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 @ 2024-05-22 12:06  绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报