NodeJS-UI-测试-全-

NodeJS UI 测试(全)

原文:zh.annas-archive.org/md5/9825E0A7D182DABE37113602D3670DB2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自动化用户界面测试一直是编程的圣杯。现在,使用 Zombie.js 和 Mocha,您可以快速创建和运行测试,使得即使是最小的更改也可以轻松测试。增强您对代码的信心,并在开发过程中最小化使用真实浏览器的次数。

使用 Node.js 进行 UI 测试是一本关于如何自动测试您的 Web 应用程序,使其坚如磐石且无 bug 的快速而全面的指南。您将学习如何模拟复杂的用户行为并验证应用程序的正确行为。

您将在 Node.js 中创建一个使用复杂用户交互和 AJAX 的 Web 应用程序;在本书结束时,您将能够从命令行完全测试它。然后,您将开始使用 Mocha 作为框架和 Zombie.js 作为无头浏览器为该应用程序创建用户界面测试。

您还将逐模块创建完整的测试套件,测试简单和复杂的用户交互。

本书涵盖内容

第一章 开始使用 Zombie.js,帮助您了解 Zombie.js 的工作原理以及可以使用它测试哪些类型的应用程序。

第二章 创建简单的 Web 应用,解释了如何使用 Node.js、CouchDB 和 Flatiron.js 创建一个简单的 Web 应用。

第三章 安装 Zombie.js 和 Mocha,教您如何使用 Zombie.js 和 Mocha 为 Web 应用程序创建测试环境的基本结构。

第四章 理解 Mocha,帮助您了解如何使用 Mocha 创建和运行异步测试。

第五章 操作 Zombie 浏览器,解释了如何使用 Zombie.js 创建一个模拟浏览器,可以加载 HTML 文档并对其执行操作。

第六章 测试交互,解释了如何在文档中触发事件以及如何测试文档操作的结果。

第七章 调试,教会您如何使用 Zombie 浏览器对象和其他一些技术来检查应用程序的内部状态。

第八章 测试 AJAX,不包含在本书中,但可以通过以下链接免费下载:

www.packtpub.com/sites/default/files/downloads/0526_8_testingajax.pdf

本书所需内容

要使用本书,您需要一台运行现代主流操作系统(如 Windows、Mac 或 Linux)的个人电脑。

本书适合谁

本书适用于使用并在一定程度上了解 JavaScript 的程序员,尤其是具有事件驱动编程经验的人。例如,如果您曾在网页上使用 JavaScript 设置事件回调和进行 AJAX 调用,您将会更容易上手。另外,一些使用 Node.js 的经验也会减轻学习曲线,但不是绝对要求。

约定

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

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄均显示如下:“要从 Node 中访问 CouchDB 数据库,您将使用一个名为nano的库。”

代码块设置如下:

browser.visit('http://localhost:8080/form', function() {
  browser
    .fill('Name', 'Pedro Teixeira')
    .select('Born', '1975')
    .check('Agree with terms and conditions')
    .pressButton('Submit', function() {
      assert.equal(browser.location.pathname, '/success');
      assert.equal(browser.text('#message'),
        'Thank you for submitting this form!');
    });
});

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

  "scripts": {
 "test": "mocha test/users.js",
    "start": "node app.js"
  },...

任何命令行输入或输出均按以下格式编写:

$ npm install
...
mocha@1.4.2 node_modules/mocha
...

zombie@1.4.1 node_modules/zombie
...

新术语重要单词会以粗体显示。屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的方式出现在文本中:"点击下一步按钮会将您移动到下一个屏幕"。

注意

警告或重要提示会以这样的方式出现在一个框中。

提示

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

第一章:使用 Zombie.js 入门

"Zombie.js 是一个轻量级的框架,用于在模拟环境中测试客户端 JavaScript 代码。无需浏览器。"

这个定义来自Zombie.js文档,网址为zombie.labnotes.org

为您的 Web 应用程序自动化测试对于拥有高质量的产品至关重要,但正确执行可能是一种痛苦的经历。这就是为什么大多数时候项目的这一部分从未得到实施。开发人员要么限制自己只测试底层业务逻辑和控制流,要么,如果他们真的想测试用户界面,必须采用复杂的设置,以某种方式连接到真实的浏览器并使用远程脚本对其进行命令。

Zombie.js 为这种情景提供了一个快速简便的替代方案,使您可以仅通过使用 JavaScript 轻松快速地为您的 Web 应用程序创建自动化测试。

本章涵盖的主题有:

  • 软件测试的简要历史

  • 理解服务器端 DOM

  • Zombie.js 的内部工作原理

在本章结束时,您应该了解 Zombie.js 的工作原理以及可以使用它进行测试的应用程序类型。

软件和用户界面测试的简要历史

软件测试是收集有关某种产品或服务质量的信息的必要活动。在传统的软件开发周期中,这项活动被委托给一个唯一工作是在软件中找问题的团队。如果正在向国内终端用户销售通用产品,或者公司正在购买许可的操作系统,则需要进行这种类型的测试。

在大多数定制软件中,测试团队负责手动测试软件,但通常客户必须进行验收测试,以确保软件的行为符合预期。

每当这些团队中的某人在软件中发现新问题时,开发团队就必须修复软件并将其重新放入测试循环中。这意味着每次发现错误时,交付最终版本的软件所需的成本和时间都会增加。此外,问题在开发过程的后期被发现,将会对产品的最终成本产生更大的影响。

此外,软件交付方式在过去几年发生了变化;网络使我们能够轻松交付软件及其升级,缩短了新功能开发和投入使用之间的时间。但一旦交付了产品的第一个版本并有一些客户在使用,你可能会面临一个困境;较少的更新可能意味着产品很快就会过时。另一方面,对软件进行许多更改增加了出现问题的可能性,使您的软件变得有缺陷,这可能会让客户流失。

关于如何缓解交付有缺陷产品的风险并增加按时交付新功能的机会,以及整体产品达到一定的质量标准,有许多版本和迭代的开发过程,但是所有参与软件构建的人都必须同意,越早发现错误越好。

这意味着您应该尽早发现问题,最好是在开发周期中。不幸的是,每次软件更改时都通过手工完全测试软件将会很昂贵。解决方案是自动化测试,以最大化测试覆盖率(应用程序代码的百分比和可能的输入变化)并最小化运行每个测试所需的时间。如果您的测试只需几秒钟就能运行,您就可以负担得起每次对代码库进行单个更改时运行测试。

进入自动化时代

测试自动化已经存在了一些年头,甚至在 Web 出现之前就有了。一旦图形用户界面GUI)开始变得流行,允许你录制、构建和运行自动化测试的工具开始出现。由于有许多语言和 GUI 库用于构建应用程序,许多涵盖其中一些的工具开始出现。通常它们允许你录制一个测试会话,然后可以自动重现。在这个会话中,你可以自动化指针点击事物(按钮、复选框、窗口上的位置等),选择值(例如从选择框中选择),输入键盘操作并测试结果。

所有这些工具操作起来都相当复杂,而且最糟糕的是,大多数都是特定技术的。

但是,如果你正在构建一个使用 HTML 和 JavaScript 的基于 Web 的应用程序,你有更好的选择。其中最著名的可能是 Selenium,它允许你录制、更改和运行针对所有主要浏览器的测试脚本。

你可以使用 Selenium 来运行测试,但是你至少需要一个浏览器让 Selenium 附加到其中,以便加载和运行测试。如果你尽可能多地使用浏览器来运行测试,你将能够保证你的应用在所有浏览器上都能正确运行。但是由于 Selenium 插入到浏览器并控制它,在尽可能多的浏览器上运行相当复杂的应用的所有测试可能需要一些时间,而你最不希望的就是尽可能少地运行测试。

单元测试与集成测试

通常,你可以将自动化测试分为两类,即单元测试和集成测试。

  • 单元测试:这些测试是选择应用程序的一个小子集(例如一个类或特定对象)并测试该类或对象向应用程序的其余部分提供的接口。通过这种方式,你可以隔离一个特定的组件,并确保它的行为符合预期,以便应用程序中的其他组件可以安全地使用它。

  • 集成测试:这些测试是将单独的组件组合在一起并作为一个工作组进行测试。在这些测试中,你与用户界面进行交互和操作,用户界面反过来与应用程序的基础块进行交互。你使用 Zombie.js 进行的测试属于这一类。

Zombie.js 是什么

Zombie.js 允许你在没有真实网络浏览器的情况下运行这些测试。相反,它使用一个模拟的浏览器,在其中存储 HTML 代码并运行你可能在 HTML 页面中有的 JavaScript。这意味着不需要显示 HTML 页面,节省了本来会被渲染的宝贵时间。

然后你可以使用 Zombie.js 来模拟浏览器加载页面,并且一旦页面加载完成,执行某些操作并观察结果。你可以使用 JavaScript 来完成所有这些,而不需要在客户端代码和测试脚本之间切换语言。

理解服务器端的 DOM

Zombie.js 运行在 Node.js(nodejs.org)之上,这是一个可以轻松使用 JavaScript 构建网络服务器的平台。它运行在谷歌快速的 V8 JavaScript 引擎之上,这也是谷歌 Chrome 浏览器的动力来源。

注意

在撰写本文时,V8 实现了 JavaScript ECMA 3 标准和部分 ECMA 5 标准。并非所有浏览器都平等地实现了所有版本的 JavaScript 标准的所有功能。这意味着即使你的测试在 Zombie.js 中通过了,也不意味着它们会在所有目标浏览器中通过。

在 Node.js 之上,有一个名为 JSDOM 的第三方模块(npmjs.org/package/jsdom),它允许你解析 HTML 文档并在该文档的表示之上使用 API;这使你能够查询和操作它。提供的 API 是标准的文档对象模型DOM)。

所有浏览器都实现了 DOM 标准的一个子集,这是由万维网联盟W3C)内的一个工作组作为一组推荐来规定的。它们有三个推荐级别。JSDOM 实现了所有三个。

Web 应用程序直接或间接(通过使用诸如 jQuery 之类的工具)使用浏览器提供的 DOM API 来查询和操作文档,从而使您能够创建具有复杂行为的浏览器应用程序。这意味着通过使用 JSDOM,您自动支持大多数现代浏览器支持的任何 JavaScript 库。

提示

下载示例代码

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

Zombie.js 是您的无头浏览器

在 Node.js 和 JSDOM 之上是 Zombie.js。Zombie.js 提供类似浏览器的功能和一个可用于测试的 API。例如,Zombie.js 的典型用法是打开浏览器,请求加载某个 URL,填写表单上的一些值并提交,然后查询生成的文档,看看是否有成功消息。

为了更具体,这里是一个简单的 Zombie.js 测试代码的示例:

browser.visit('http://localhost:8080/form', function() {
  browser
    .fill('Name', 'Pedro Teixeira')
    .select('Born', '1975')
    .check('Agree with terms and conditions')
    .pressButton('Submit', function() {
      assert.equal(browser.location.pathname, '/success');
      assert.equal(browser.text('#message'),
        'Thank you for submitting this form!');
    });
});

在这里,您正在典型地使用 Zombie.js:加载包含表单的 HTML 页面;填写并提交该表单;然后验证结果是否成功。

注意

Zombie.js 不仅可以用于测试您的 Web 应用程序,还可以用于需要像浏览器一样行为的应用程序,例如 HTML 抓取器、爬虫和各种 HTML 机器人。

如果您要使用 Zombie.js 进行任何这些活动,请做一个良好的网络公民,并在道德上使用它。

摘要

创建自动化测试是任何软件应用程序开发过程的重要部分。在使用 HTML、JavaScript 和 CSS 创建 Web 应用程序时,您可以使用 Zombie.js 创建一组测试;这些测试加载、查询、操作并为任何给定的网页提供输入。

鉴于 Zombie.js 模拟了浏览器,并且不依赖于 HTML 页面的实际渲染,因此测试运行速度比如果您使用真实浏览器进行测试要快得多。因此,您可以在对应用程序进行任何小的更改时运行这些测试。

Zombie.js 在 Node.js 之上运行,使用 JSDOM 在任何 HTML 文档之上提供 DOM API,并使用简单的 API 模拟类似浏览器的功能,您可以使用 JavaScript 创建您的测试。

第二章:创建一个简单的 Web 应用程序

当您到达本章末尾时,您应该能够使用 Node.js、CouchDB 和 Flatiron 创建一个简单的 Web 应用程序。

本章涵盖的主题包括:

  • 设置 Node 和 Flatiron

  • 创建和处理用户表单

定义我们的 Web 应用程序的要求

在我们深入研究 Zombie.js 世界之前,我们需要为我们的测试创建一个目标,即提供待办事项列表的 Web 应用程序。这是这样一个应用程序的顶级要求集:

  • 用户可以注册该服务,需要提供电子邮件地址作为用户名和密码。通过提供用户名和密码,用户可以创建一个经过身份验证的会话,该会话将在进一步的交互中识别他。

  • 用户可以创建一个待办事项。

  • 用户可以查看待办事项列表。

  • 用户可以删除待办事项。

为了实现这个应用程序,我们将使用 Node.js,这是一个用 JavaScript 构建网络应用程序的平台,Zombie.js 也使用它。我们还将使用 Flatiron,这是一组组件,将帮助您在 Node.js 之上构建 Web 应用程序。

注意

为了保持简单,我们正在使用 Node.js 构建我们的应用程序。但是,Zombie.js 适用于测试使用任何框架构建的应用程序,这些框架利用动态 HTTP 服务器。

还要记住,构建这个 Web 应用程序的目标不是向您展示如何构建 Web 应用程序,而是在已知和简单的域上提供一个可用的应用程序,以便我们可以将其用作我们测试的主题。

在接下来的章节中,您将学习如何安装 Node.js 和 Flatiron,以及如何创建您的待办应用程序服务器。

设置 Node.js 和 Flatiron

如果您没有安装最新版本的 Node.js,您将需要安装它。您将需要 Node.js 出于几个原因。我们的 Web 应用程序将使用 Flatiron,它在 Node.js 之上运行。您还需要使用Node Package ManagerNPM),它与 Node 捆绑在一起。最后,您将需要 Node.js 来安装和运行 Zombie.js 测试。

安装 Node.js

  1. 要安装 Node.js,请前往 nodejs.org 网站。安装 Node.js

  2. 然后点击下载按钮,这将打开以下页面:安装 Node.js

  3. 如果您正在运行 Windows 或 Macintosh 系统,请单击相应的安装程序图标。这将下载并启动图形安装程序。

从源代码安装 Node

如果您没有运行其中一个系统,并且您在类 Unix 系统上,您可以按照以下步骤从源代码安装 Node.js:

  1. 单击源代码图标,将开始下载源代码 tarball。下载完成后,使用终端展开它:
$ tar xvfz node-v0.8.7.tar.gz

导航到创建的目录:

$ cd node-v0.8.7
  1. 配置它:
$ ./configure
  1. 构建它:
$ make
  1. 最后安装它:
$ make install

如果您没有足够的权限将节点二进制文件复制到最终目标位置,您将需要在命令前加上sudo

$ sudo make install
  1. 现在您应该已经在系统上安装了 Node.js。尝试运行它:
$ node -v
v0.8.7
  1. 现在让我们尝试打开 Node 命令行并输入一些内容:
$ node
> console.log('Hello World!');
  1. 如果您现在按Enter键,您应该会得到以下输出:
...
> Hello World!
  1. 通过安装 Node.js,您还安装了它的忠实伴侣 NPM,Node Package Manager。您可以尝试从终端调用它:
$ npm -v
1.1.48

安装 Flatiron 并启动您的应用程序

现在您需要安装 Flatiron 框架,这样您就可以开始构建您的应用程序。

  1. 使用 NPM 按照以下方式下载和安装 Flatiron:
$ npm install -g flatiron

注意

再次,如果您没有足够的权限安装 Flatiron,请在最后一个命令前加上sudo

这将全局安装 Flatiron,使flatiron命令行实用程序可用。

  1. 现在您应该进入一个将拥有应用程序代码的目录。然后,您可以通过执行以下命令为您的 Web 应用程序创建基本的脚手架:
$ flatiron create todo
  1. 在提示您输入作者的姓名、应用程序描述和主页(可选)后,它将创建一个名为todo的目录,其中包含您的应用程序代码的基础。使用以下命令进入该目录:
$ cd todo

在那里,您将找到两个文件和三个文件夹:

$ tree
.
├── app.js
├── config
│   └── config.json
├── lib
├── package.json
└── test

其中一个文件package.json包含应用程序清单,其中,除其他字段外,还包含应用程序依赖的软件包。现在,您将从该文件中删除devDependencies字段。

您还需要为名为plates的软件包添加一个依赖项,该软件包将用于动态更改 HTML 模板。

此外,您将为一些不需要任何修改的静态文件提供服务。为此,您将使用一个名为node-static的包,您还需要将其添加到应用程序清单的依赖项列表中。

到目前为止,您的package.json应该看起来像这样:

{
  "description": "To-do App",
  "version": "0.0.0",
  "private": true,
  "dependencies": {
    "union": "0.3.0",
    "flatiron": "0.2.8",
    "plates": "0.4.x",
    "node-static": "0.6.0"
  },
  "scripts": {
    "test": "vows --spec",
    "start": "node app.js"
  },
  "name": "todo",
  "author": "Pedro",
  "homepage": ""
}
  1. 接下来,通过以下方式安装这些依赖项:
$ npm install

这将在本地的node_modules目录中安装所有依赖项,并应该输出类似以下内容:

union@0.3.0 node_modules/union
├── qs@0.4.2
└── pkginfo@0.2.3

flatiron@0.2.8 node_modules/flatiron
├── pkginfo@0.2.3
├── director@1.1.0
├── optimist@0.3.4 (wordwrap@0.0.2)
├── broadway@0.2.5 (eventemitter2@0.4.9, cliff@0.1.8, utile@0.1.2, nconf@0.6.4, winston@0.6.2)
└── prompt@0.2.6 (revalidator@0.1.2, read@1.0.4, utile@0.1.3, winston@0.6.2)

plates@0.4.6 node_modules/plates

node-static@0.6.0 node_modules/node-static

注意

您不必担心这一点,因为 Node 将能够自动获取这些依赖项。

  1. 现在您可以尝试启动您的应用程序:
$ node app.js

如果您打开浏览器并将其指向http://localhost:3000,您将得到以下响应:

{"hello":"world"}

创建您的待办事项应用程序

现在,您已经有一个 Flatiron“hello world”示例正在运行,您需要扩展它,以便我们的待办事项应用程序成形。为此,您需要创建和更改一些文件。如果您迷失了方向,您可以随时参考本章的源代码。另外,供您参考,本章末尾包含了项目文件的完整列表。

设置数据库

与任何真实应用程序一样,您将需要一种可靠的方式来持久保存数据。在这里,我们将使用 CouchDB,这是一个开源的面向文档的数据库。您可以选择在本地安装 CouchDB,也可以使用互联网上的服务,如 Iris Couch。

如果您选择在本地开发机器上安装 CouchDB,您可以前往couchdb.apache.org/,点击下载并按照说明进行操作。

如果您更喜欢简单地通过互联网使用 CouchDB,您可以前往www.iriscouch.com/,点击立即注册按钮并填写注册表格。您应该在几秒钟内拥有一个运行的 CouchDB 实例。

设置数据库

注意

截至目前,Iris Couch 是一个免费为小型数据库提供低流量服务的服务,这使其非常适合原型设计这样的应用程序。

从 Node 访问 CouchDB

要从 Node 访问 CouchDB 数据库,我们将使用一个名为nano的库,您将把它添加到package.json文件的依赖项部分:

{
  "description": "To-do App",
  "version": "0.0.0",
  "private": true,
  "dependencies": {
    "union": "0.3.0",
    "flatiron": "0.2.8",
    "plates": "0.4.6",
    "node-static": "0.6.0",
 "nano": "3.3.0"
  },
  "scripts": {
    "test": "vows --spec",
    "start": "node app.js"
  },
  "name": "todo",
  "author": "Pedro",
  "homepage": ""
}

现在,您可以通过在应用程序的根目录运行以下命令来安装此缺少的依赖项:

$ npm install
nano@3.3.0 node_modules/nano
├── errs@0.2.3
├── request@2.9.203.8.0 (request@2.2.9request@2.2.9)

这将在node_modules文件夹中安装nano,使其在构建此应用程序时可用。

要实际连接到数据库,您需要定义 CouchDB 服务器的 URL。如果您在本地运行 CouchDB,则 URL 应类似于ht tp://127.0.0.1:5984。如果您在 Iris Couch 或类似的服务中运行 CouchDB,则您的 URL 将类似于https://mytodoappcouchdb.iriscouch.com

在任何这些情况下,如果您需要使用用户名和密码进行访问,您应该将它们编码在 URL 中,http://username:password@mytodoappco uchdb.iriscouch.com

现在应该将此 URL 输入到config/config.json文件的配置文件中,couchdb键下:

{
  "couchdb": "http://localhost:5984"
}

接下来,通过在lib/couchdb.js下提供一个简单的模块来封装对数据库的访问:

var nano = require('nano'),
    config = require('../config/config.json');

module.exports = nano(config.couchdb);

此模块将用于获取 CouchDB 服务器对象,而不是在整个代码中多次重复confignano的操作。

应用程序布局

像许多网站现在所做的那样,我们将使用 Twitter Bootstrap 框架来帮助我们使网站看起来和感觉起来简洁而又可观。为此,您将前往 Bootstrap 网站twitter.github.com/bootstrap/,并单击下载 Bootstrap按钮:

应用程序布局

您将收到一个 zip 文件,您应该将其扩展到本地的public文件夹中,最终得到这些文件:

$ tree public/
public/
├── css
│   ├── bootstrap-responsive.css
│   ├── bootstrap-responsive.min.css
│   ├── bootstrap.css
│   └── bootstrap.min.css
├── img
│   ├── glyphicons-halflings-white.png
│   └── glyphicons-halflings.png
└── js
    ├── bootstrap.js
    └── bootstrap.min.js

您还需要将 jQuery 添加到混合中,因为 Bootstrap 依赖于它。从jquery.com下载 jQuery,并将其命名为public/js/jquery.min.js

开发前端

现在我们安装了 Bootstrap 和 jQuery,是时候创建我们应用程序的前端了。

首先,我们将设置布局 HTML 模板,该模板定义了所有页面的外部结构。为了托管所有模板,我们将有一个名为templates的目录,其中包含以下内容templates/layout.html

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title id="title"></title>
    <link href="/css/bootstrap.min.css" rel="stylesheet" />
  </head>
  <body>

    <section role="main" class="container">

      <div id="messages"></div>

      <div id="main-body"></div>

    </section>

    <script src="img/jquery.min.js"></script> 
    <script src="img/bootstrap.min.js"></script>

  </body>
</html>

此模板加载 CSS 和脚本,并包含消息和主要部分的占位符。

我们还需要一个小模块,该模块获取主要内容和一些其他选项,并将它们应用于此模板。我们将其放在templates/layout.js中:

var Plates = require('plates'),
    fs     = require('fs');

var templates = {
  layout : fs.readFileSync(__dirname + '/layout.html', 'utf8'),
  alert  : fs.readFileSync(__dirname + '/alert.html', 'utf8')
};

module.exports = function(main, title, options) {

  if (! options) {
    options = {};
  }

  var data = {
    "main-body": main,
    "title": title,
    'messages': ''
  };

  ['error', 'info'].forEach(function(messageType) {
    if (options[messageType]) {
      data.messages += Plates.bind(templates.alert,
        {message: options[messageType]});
    }
  });

  return Plates.bind(templates.layout, data);
};

在 Node.js 中,模块只是一个旨在被其他模块使用的 JavaScript 文件。模块内的所有变量都是私有的;如果模块作者希望向外部世界公开值或函数,它会修改或设置module.exports中的特殊变量。

在我们的情况下,这个模块导出一个函数,该函数获取主页面内容的标记,页面标题和一些选项,如信息或错误消息,并将其应用于布局模板。

我们还需要将以下标记文件放在templates/alert.html下:

<div class="alert">
  <a class="close" data-dismiss="alert">×</a>
  <p class="message"></p>
</div>

现在我们准备开始实现一些要求。

用户注册

这个应用程序将为用户提供一个个人待办事项列表。在他们可以访问它之前,他们需要在系统中注册。为此,您需要定义一些 URL,用户将使用这些 URL 来获取我们的用户注册表单并提交它。

现在您将更改app.js文件。此文件包含一组初始化过程,包括此块:

app.router.get('/', function () {
  this.res.json({ 'hello': 'world' })
});

这个块正在将所有具有/URL 的 HTTP 请求路由,并且 HTTP 方法是GET到给定的函数。然后,对于具有这两个特征的每个请求,将调用此函数,在这种情况下,您正在回复{"hello":"world"},用户将在浏览器上看到打印出来。

现在我们需要删除这个路由,并添加一些路由,允许用户注册自己。

为此,创建一个名为routes的文件夹,您将在其中放置所有路由模块。第一个是routes/users.js,将包含以下代码:

var fs      = require('fs'),
    couchdb = require('../lib/couchdb'),
    dbName  = 'users',
    db      = couchdb.use(dbName),
    Plates  = require('plates'),
    layout  = require('../templates/layout');

var templates = {
  'new' : fs.readFileSync(__dirname +
    '/../templates/users/new.html', 'utf8'),
  'show': fs.readFileSync(__dirname +
    '/../templates/users/show.html', 'utf8')
};

function insert(doc, key, callback) {
  var tried = 0, lastError;

  (function doInsert() {
    tried ++;
    if (tried >= 2) {
      return callback(lastError);
    }

    db.insert(doc, key, function(err) {
      if (err) {
        lastError = err;
        if (err.status_code === 404) {
          couchdb.db.create(dbName, function(err) {
            if (err) {
              return callback(err);
            }
            doInsert();
          });
        } else {
          return callback(err);
        }
      }
      callback.apply({}, arguments);
    });
  }());
}

function render(user) {
  var map = Plates.Map();
  map.where('id').is('email').use('email').as('value');
  map.where('id').is('password').use('password').as('value');
  return Plates.bind(templates['new'], user || {}, map);
}

module.exports = function() {
  this.get('/new', function() {
    this.res.writeHead(200, {'Content-Type': 'text/html'});
    this.res.end(layout(render(), 'New User'));
  });

  this.post('/', function() {

    var res = this.res,
        user = this.req.body;

    if (! user.email || ! user.password) {
      return this.res.end(layout(templates['new'],
        'New User', {error: 'Incomplete User Data'}));
    }

    insert(user, this.req.body.email, function(err) {
      if (err) {
        if (err.status_code === 409) {
          return res.end(layout(render(user), 'New User', {
            error: 'We already have a user with that email address.'}));
        }
        console.error(err.trace);
        res.writeHead(500, {'Content-Type': 'text/html'});
        return res.end(err.message);
      }
      res.writeHead(200, {'Content-Type': 'text/html'});
      res.end(layout(templates['show'], 'Registration Complete'));
    });
  });

};

这个新模块导出一个函数,将绑定两个新路由GET /newPOST /。这些路由稍后将被附加到/users命名空间,这意味着当服务器接收到GET请求/users/newPOST请求/users时,它们将被激活。

GET /new路由上,我们将呈现一个包含用户表单的模板。将其放在templates/users/new.html下:

<h1>New User</h1>
<form action="/users" method="POST">
  <p>
    <label for="email">E-mail</label>
    <input type="email" name="email" value="" id="email" />
  </p>
  <p>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" value="" required/>
  </p>
  <input type="submit" value="Submit" />
</form>

我们还需要创建一个感谢您注册模板,您需要将其放在templates/users/show.html中:

<h1>Thank you!</h1>
<p>Thank you for registering. You can now <a href="/session/new">log in here</a></p>

POST /路由处理程序中,我们将进行一些简单的验证,并通过调用名为insert的函数将用户文档插入 CouchDB 数据库。此函数尝试插入用户文档,并利用一些巧妙的错误处理。如果错误是“404 Not Found”,这意味着users数据库尚未创建,我们将利用这个机会创建它,并自动重复用户文档插入。

您还捕获了 409 冲突的 HTTP 状态码,如果我们尝试插入已存在的键的文档,CouchDB 将返回此状态码。由于我们使用用户电子邮件作为文档键,因此我们通知用户该用户名已经存在。

注意

在这里,除了其他简化之外,您将用户密码以明文存储在数据库中。这显然是不推荐的,但由于本书的核心不是如何创建 Web 应用程序,因此这个实现细节与您的目标无关。

现在,我们需要通过更新并在app.js文件中的app.start(3000)之前添加一行来将这些新路由附加到/users/ URL 命名空间:

var flatiron = require('flatiron'),
    path = require('path'),
    nstatic = require('node-static'),
    app = flatiron.app;

app.config.file({ file: path.join(__dirname, 'config', 'config.json') });

var file = new nstatic.Server(__dirname + '/public/');

app.use(flatiron.plugins.http, {
  before: [
    function(req, res) {
      var found = app.router.dispatch(req, res);
      if (! found) {
        file.serve(req, res);
      }
    }
  ]
});

app.router.path('/users', require('./routes/users'));

app.start(3000);

现在,您可以通过在命令行中输入以下命令来启动应用程序:

$ node app

这将启动服务器。然后打开 Web 浏览器,访问http://localhost:3000/users/new。您将获得一个用户表单:

用户注册

提交电子邮件和密码,您将获得一个确认屏幕:

用户注册

此屏幕将向您显示一个链接,指向尚不存在的/session/new URL。

现在,您已经准备好实现登录屏幕。

登录和会话管理

为了能够保持会话,您的 HTTP 服务器需要能够执行两件事:解析 cookie 和存储会话数据。为此,我们使用两个模块,即flatware-cookie-parserflatware-session,您应该将它们添加到package.json清单中:

{
  "description": "To-do App",
  "version": "0.0.0",
  "private": true,
  "dependencies": {
    "union": "0.3.0",
    "flatiron": "0.2.8",
    "plates": "0.4.x",
    "node-static": "0.6.0",
    "nano": "3.3.0",
 "flatware-cookie-parser": "0.1.x",
 "flatware-session": "0.1.x"
  },
  "scripts": {
    "test": "vows --spec",
    "start": "node app.js"
  },
  "name": "todo",
  "author": "Pedro",
  "homepage": ""
}

现在,安装缺少的依赖项:

$ npm install
flatware-cookie-parser@0.1.0 node_modules/flatware-cookie-parser

flatware-session@0.1.0 node_modules/flatware-session

接下来,在文件app.js中向您的服务器添加这些中间件组件:

var flatiron = require('flatiron'),
    path = require('path'),
    nstatic = require('node-static'),
    app = flatiron.app;

app.config.file({ file: path.join(__dirname, 'config', 'config.json') });

var file = new nstatic.Server(__dirname + '/public/');

app.use(flatiron.plugins.http, {
  before: [
 require('flatware-cookie-parser')(),
 require('flatware-session')(),
    function(req, res) {
      var found = app.router.dispatch(req, res);
      if (! found) {
        file.serve(req, res);
      }
    }
  ]
});

app.router.path('/users', require('./routes/users'));
app.router.path('/session', require('./routes/session'));

app.start(3000);

我们还需要创建一个routes/session.js模块来处理新的会话路由:

var plates  = require('plates'),
    fs      = require('fs'),
    couchdb = require('../lib/couchdb'),
    dbName  = 'users',
    db      = couchdb.use(dbName),
    Plates  = require('plates'),
    layout  = require('../templates/layout');

var templates = {
  'new' : fs.readFileSync(__dirname +
    '/../templates/session/new.html', 'utf8')
};

module.exports = function() {

  this.get('/new', function() {
    this.res.writeHead(200, {'Content-Type': 'text/html'});
    this.res.end(layout(templates['new'], 'Log In'));
  });

  this.post('/', function() {

    var res   = this.res,
        req   = this.req,
        login = this.req.body;

    if (! login.email || ! login.password) {
      return res.end(layout(templates['new'], 'Log In',
        {error: 'Incomplete Login Data'}));
    }

    db.get(login.email, function(err, user) {
      if (err) {
        if (err.status_code === 404) {
          // User was not found
          return res.end(layout(templates['new'], 'Log In',
            {error: 'No such user'}));
        }
        console.error(err.trace);
        res.writeHead(500, {'Content-Type': 'text/html'});
        return res.end(err.message);
      }

      if (user.password !== login.password) {
        res.writeHead(403, {'Content-Type': 'text/html'});
        return res.end(layout(templates['new'], 'Log In',
            {error: 'Invalid password'}));
      }

      // store session
      req.session.user = user;

      // redirect user to TODO list
      res.writeHead(302, {Location: '/todos'});
      res.end();
    });

  });  

};

现在,我们需要在templates/session/new.html下添加一个视图模板,其中包含登录表单:

<h1>Log in</h1>
<form action="/session" method="POST">
  <p>
    <label for="email">E-mail</label>
    <input type="email" name="email" value="" id="email"/>
  </p>
  <p>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" value="" required/>
  </p>
  <input type="submit" value="Log In" />
</form>

接下来,如果服务器仍在运行,请停止服务器(按下Ctrl + C),然后重新启动它:

$ node app.js

将浏览器指向http://localhost:3000/session/new,并插入您已经注册的用户的电子邮件和密码:

登录和会话管理

如果登录成功,您将被重定向到/todos URL,服务器尚未响应。

接下来,我们将使待办事项列表起作用。

待办事项列表

为了显示待办事项列表,我们将使用表格。通过使用 jQuery UI,可以很容易地对待办事项进行排序。启用此功能的简单方法是使用 jQuery UI。仅需此功能,您无需完整的 jQuery UI 库,可以通过将浏览器指向http://jqueryui.com/download,取消交互元素中除Sortable选项之外的所有选项,并单击Download按钮来下载自定义构建的 jQuery UI 库。解压缩生成的文件,并将jquery-ui-1.8.23.custom.min.js文件复制到public/js中。

待办事项列表

我们需要在templates.htmllayout.html文件中引用此脚本:

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title id="title"></title>
    <link href="/css/bootstrap.min.css" rel="stylesheet" />
  </head>
  <body>

    <section role="main" class="container">

      <div id="messages"></div>

      <div id="main-body"></div>

    </section>

    <script src="img/jquery.min.js"></script> 
 <script src="img/jquery-ui-1.8.23.custom.min.js"></script> 
    <script src="img/bootstrap.min.js"></script>
 <script src="img/todos.js"></script>
  </body>
</html>

您还应该在public/js/todos.js下添加一个文件,其中包含一些前端交互代码。

现在,我们需要通过首先在app.js文件中包含新的路由来响应/todos URL:

var flatiron = require('flatiron'),
    path = require('path'),
    nstatic = require('node-static'),
    app = flatiron.app;

app.config.file({ file: path.join(__dirname, 'config', 'config.json') });

var file = new nstatic.Server(__dirname + '/public/');

app.use(flatiron.plugins.http, {
  before: [
    require('flatware-cookie-parser')(),
    require('flatware-session')(),
    function(req, res) {
      var found = app.router.dispatch(req, res);
      if (! found) {
        file.serve(req, res);
      }
    }
  ]
});

app.router.path('/users', require('./routes/users'));
app.router.path('/session', require('./routes/session'));
app.router.path('/todos', require('./routes/todos'));

app.start(3000);

然后,我们需要将新的待办事项路由模块放在routes/todos.js下:

var fs      = require('fs'),
    couchdb = require('../lib/couchdb'),
    dbName  = 'todos',
    db      = couchdb.use(dbName),
    Plates  = require('plates'),
    layout  = require('../templates/layout'),
    loggedIn = require('../middleware/logged_in')();

var templates = {
  index : fs.readFileSync(__dirname +
    '/../templates/todos/index.html', 'utf8'),
  'new' : fs.readFileSync(__dirname +
    '/../templates/todos/new.html', 'utf8')
};

function insert(email, todo, callback) {
  var tries = 0,
      lastError;

  (function doInsert() {
    tries ++;
    if (tries >= 3) return callback(lastError);

    db.get(email, function(err, todos) {
      if (err && err.status_code !== 404) return callback(err);

      if (! todos) todos = {todos: []};
      todos.todos.unshift(todo);

      db.insert(todos, email, function(err) {
        if (err) {
          if (err.status_code === 404) {
            lastError = err;
            // database does not exist, need to create it
            couchdb.db.create(dbName, function(err) {
              if (err) {
                return callback(err);
              }
              doInsert();
            });
            return;
          }
          return callback(err);
        }
        return callback();
      });
    });
  })();

}

module.exports = function() {

  this.get('/', [loggedIn, function() {

    var res = this.res;

    db.get(this.req.session.user.email, function(err, todos) {

      if (err && err.status_code !== 404) {
        res.writeHead(500);
        return res.end(err.stack);
      }

      if (! todos) todos = {todos: []};
      todos = todos.todos;

      todos.forEach(function(todo, idx) {
        if (todo) todo.pos = idx + 1;
      });

      var map = Plates.Map();
      map.className('todo').to('todo');
      map.className('pos').to('pos');
      map.className('what').to('what');
      map.where('name').is('pos').use('pos').as('value');

      var main = Plates.bind(templates.index, {todo: todos}, map);
      res.writeHead(200, {'Content-Type': 'text/html'});
      res.end(layout(main, 'To-Dos'));

    });

  }]);

  this.get('/new', [loggedIn, function() {

    this.res.writeHead(200, {'Content-Type': 'text/html'});
    this.res.end(layout(templates['new'], 'New To-Do'));
  }]);

  this.post('/', [loggedIn, function() {

    var req  = this.req,
        res  = this.res,
        todo = this.req.body
    ;

    if (! todo.what) {
      res.writeHead(200, {'Content-Type': 'text/html'});
      return res.end(layout(templates['new'], 'New To-Do',
        {error: 'Please fill in the To-Do description'}));
    }

    todo.created_at = Date.now();

    insert(req.session.user.email, todo, function(err) {

      if (err) {
        res.writeHead(500);
        return res.end(err.stack);
      }

      res.writeHead(303, {Location: '/todos'});
      res.end();
    });

  }]);

  this.post('/sort', [loggedIn, function() {

    var res = this.res,
        order = this.req.body.order && this.req.body.order.split(','),
        newOrder = []
        ;

    db.get(this.req.session.user.email, function(err, todosDoc) {
      if (err) {
        res.writeHead(500);
        return res.end(err.stack);
      }

      var todos = todosDoc.todos;

      if (order.length !== todos.length) {
        res.writeHead(409);
        return res.end('Conflict');
      }

      order.forEach(function(order) {
        newOrder.push(todos[parseInt(order, 10) - 1]);
      });

      todosDoc.todos = newOrder;

      db.insert(todosDoc, function(err) {
        if (err) {
          res.writeHead(500);
          return res.end(err.stack);
        }
        res.writeHead(200);
        res.end();
      });

    });
  }]);

  this.post('/delete', [loggedIn, function() {

    var req = this.req,
        res = this.res,
        pos = parseInt(req.body.pos, 10)
        ;

    db.get(this.req.session.user.email, function(err, todosDoc) {
      if (err) {
        res.writeHead(500);
        return res.end(err.stack);
      }

      var todos = todosDoc.todos;
      todosDoc.todos = todos.slice(0, pos - 1).concat(todos.slice(pos));

      db.insert(todosDoc, function(err) {
        if (err) {
          res.writeHead(500);
          return res.end(err.stack);
        }
        res.writeHead(303, {Location: '/todos'});
        res.end();
      });

    });

  }]);

};

该模块响应待办事项索引(GET /todos),获取并呈现已登录用户的所有待办事项。将以下模板放在templates/todos/index.html下:

<h1>Your To-Dos</h1>

<a class="btn" href="/todos/new">New To-Do</a>

<table class="table">
  <thead>
    <tr>
      <th>#</th>
      <th>What</th>
      <th></th>
    </tr>
  </thead>
  <tbody id="todo-list">
    <tr class="todo">
      <td class="pos"></td>
      <td class="what"></td>
      <td class="remove">
        <form action="/todos/delete" method="POST">
          <input type="hidden" name="pos" value="" />
          <input type="submit" name="Delete" value="Delete" />
        </form>
      </td>
    </tr>
  </tbody>
</table>

另一个新路由是GET /todos/new,向用户呈现创建新待办事项的表单。此路由使用放置在templates/todos/new.html中的新模板:

<h1>New To-Do</h1>
<form action="/todos" method="POST">
  <p>
    <label for="email">What</label>
    <textarea name="what" id="what" required></textarea>
  </p>
  <input type="submit" value="Create" />
</form>

POST /todos路由通过调用本地的insert函数创建新的待办事项,该函数处理了数据库不存在时的错误,并在需要时创建数据库并稍后重试insert函数。

索引模板取决于public/js/todos.js下放置的客户端脚本的存在:

$(function() {
  $('#todo-list').sortable({
    update: function() {
      var order = [];
      $('.todo').each(function(idx, row) {
        order.push($(row).find('.pos').text());
      });

      $.post('/todos/sort', {order: order.join(',')}, function() {
        $('.todo').each(function(idx, row) {
          $(row).find('.pos').text(idx + 1);
        });
      });

    } 
  });
});

此文件激活并处理拖放项目,通过向/todos/sort URL 发出 AJAX 调用,传递待办事项的新顺序。

todos.js路由模块还处理了每个项目上的删除按钮,它通过加载用户的待办事项,删除给定位置的项目并将项目存储回去来处理。

注意

到目前为止,您可能已经注意到我们将给定用户的所有待办事项存储在todos数据库中的一个文档中。如果所有用户保持待办事项的数量相对较低,这种技术是简单且有效的。无论如何,这些细节对我们的目的并不重要。

为使其工作,我们需要在middleware/logged_in.js下提供一个路由中间件。这个中间件组件负责保护一些路由,并在用户未登录时将用户重定向到登录屏幕,而不是执行该路由:

function LoggedIn() {
  return function(next) {
    if (! this.req.session || ! this.req.session.user) {
      this.res.writeHead(303, {Location: '/session/new'});
      return this.res.end();
    }
    next();
  };
}

module.exports = LoggedIn;

最后,如果服务器仍在运行,请停止它(按下Ctrl + C),然后再次启动它:

$ node app.js

将浏览器指向http://localhost:3000/session/new,并输入您已经注册的用户的电子邮件和密码。然后,您将被重定向到用户的待办事项列表,该列表将开始为空。

待办事项列表

现在您可以单击新建待办事项按钮,获取以下表单:

待办事项列表

插入一些文本,然后单击创建按钮。待办事项将被插入到数据库中,并且更新后的待办事项列表将被呈现:

待办事项列表

您可以插入任意数量的待办事项。一旦您满意了,您可以尝试通过拖放表格行来重新排序它们。

待办事项列表

您还可以单击删除按钮来删除特定的待办事项。

文件摘要

以下是组成此应用程序的文件列表:

$ tree
.
├── app.js
├── config
│   └── config.json
├── lib
│   └── couchdb.js
├── middleware
│   └── logged_in.js
├── package.json
├── public
│   ├── css
│   │   ├── bootstrap-responsive.css
│   │   ├── bootstrap-responsive.min.css
│   │   ├── bootstrap.css
│   │   └── bootstrap.min.css
│   ├── img
│   │   ├── glyphicons-halflings-white.png
│   │   └── glyphicons-halflings.png
│   └── js
│       ├── bootstrap.js
│       ├── bootstrap.min.js
│       ├── jquery-ui-1.8.23.custom.min.js
│       ├── jquery.min.js
│       └── todos.js
├── routes
│   ├── session.js
│   ├── todos.js
│   └── users.js
├── templates
│   ├── alert.html
│   ├── layout.html
│   ├── layout.js
│   ├── session
│   │   └── new.html
│   ├── todos
│   │   ├── index.html
│   │   └── new.html
│   └── users
│       ├── new.html
│       └── show.html
└── test

13 directories, 27 files

摘要

在本章中,您学会了如何使用 Node.js、Flatiron.js 和其他一些组件创建一个简单的 Web 应用程序。

这个应用程序将成为我们将来章节中用户界面测试的目标。

第三章:安装 Zombie.js 和 Mocha

在本章结束时,您应该能够为使用 Zombie.js 和 Mocha 的应用程序设置测试环境的基本结构。

本章涵盖的主题有:

  • 在应用程序清单中设置 Zombie.js 和 Mocha 包

  • 设置测试环境

  • 运行你的第一个测试

更改应用程序清单

现在,您将扩展上一章开始构建的待办事项应用程序,并开始为其提供自我测试的能力。

在应用程序的根目录中,有一个名为package.json的文件,您已经修改过,引入了一些应用程序依赖的模块。现在,您需要添加一个新的部分,指定在开发和测试阶段对其他模块的依赖关系。这个部分名为devDependencies,只有在NODE_ENV环境变量没有设置为production时,NPM 才会安装它。这是一个很好的地方,可以介绍那些需要在运行测试时存在的模块的依赖关系。

首先,您需要添加mochazombie模块:

{
  "description": "To-do App",
  "version": "0.0.0",
  "private": true, 
  "dependencies": {
    "union": "0.3.0",
    "flatiron": "0.2.8",
    "plates": "0.4.x",
    "node-static": "0.6.0",
    "nano": "3.3.0",
    "flatware-cookie-parser": "0.1.x",
    "flatware-session": "0.1.x"
  },
 "devDependencies": {
 "mocha": "1.4.x",
 "zombie": "1.4.x"
 },
  "scripts": {
    "test": "vows --spec",
    "start": "node app.js"
  },
  "name": "todo",
  "author": "Pedro",
  "homepage": ""
}

然后,您需要使用 NPM 安装这些缺失的依赖项:

$ npm install
...
mocha@1.4.2 node_modules/mocha
...

zombie@1.4.1 node_modules/zombie
...

这将在node_modules文件夹中安装这两个模块及其内部依赖项,使它们随时可用于您的应用程序。

设置测试环境

现在,您需要设置一个测试脚本。首先,您将测试用户注册流程。

但在此之前,为了能够在测试中启动我们的服务器,我们需要对app.js文件进行轻微修改:

var flatiron = require('flatiron'),
    path = require('path'),
    nstatic = require('node-static'),
    app = flatiron.app;

app.config.file({ file: path.join(__dirname, 'config', 'config.json') });

var file = new nstatic.Server(__dirname + '/public/');

app.use(flatiron.plugins.http, {
  before: [
    require('flatware-method-override')(),
    require('flatware-cookie-parser')(),
    require('flatware-session')(),
    function(req, res) {
      var found = app.router.dispatch(req, res);
      if (! found) {
        file.serve(req, res);
      }
    }
  ]
});

app.router.path('/users', require('./routes/users'));
app.router.path('/session', require('./routes/session'));
app.router.path('/todos', require('./routes/todos'));

module.exports = app;

if (process.mainModule === module) {
 app.start(3000);
}

我们的测试将使用它们自己的服务器,所以在这种情况下,我们不需要app.js来为我们运行服务器。最后几行代码导出了应用程序,并且只有在主模块(使用node命令行调用的模块)是app.js时才启动服务器。由于测试将有一个不同的主模块,所以在运行测试时服务器不会启动。

现在,作为第一个例子,我们将测试获取用户注册表单。我们将把所有与用户路由相关的测试都集中在test/users.js文件中。这个文件可以从以下内容开始:

var assert  = require('assert'),
    Browser = require('zombie'),
    app     = require('../app')
    ;

before(function(done) {
  app.start(3000, done);
});

after(function(done) {
  app.server.close(done);
});

describe('Users', function() {

  describe('Signup Form', function() {

    it('should load the signup form', function(done) {
      var browser = new Browser();
      browser.visit("http://localhost:3000/users/new", function() {
        assert.ok(browser.success, 'page loaded');
        done();
      });
    });

  });
});

在前面的代码中,我们在顶部包含了assert模块(用于验证应用程序是否按预期运行)、zombie模块(赋值给Browser变量)和app模块。app模块获取了 Flatiron 应用程序对象,因此您可以启动和停止相应的服务器。

接下来,我们声明,在运行任何测试之前,应该启动应用程序,并且在所有测试完成后,应该关闭服务器。

接下来是一系列嵌套的describe调用。这些调用用于为每个测试提供上下文,允许您稍后区分在每个测试之前和之后将发生的设置和拆卸函数。

然后是一个it语句,您在其中实现测试。这个语句接受两个参数,即正在测试的主题的描述和在开始测试时将被调用的函数。这个函数得到一个回调函数done,在测试完成时调用。这种安排使得异步测试成为可能且可靠。每个测试只有在相应的done函数被调用后才结束,这可能是在一系列异步 I/O 调用之后。

然后我们开始创建一个浏览器,并加载用户注册表单的 URL,使用assert.ok函数来验证页面是否成功加载。assert模块是 Node.js 的核心模块,提供基本的断言测试。在测试代码中,我们放置一些断言来验证一些值是否符合我们的预期。如果任何断言失败,assert会抛出一个错误,测试运行器会捕获到这个错误,表示测试失败。

除了基本的assert.ok函数之外,如果值不为 true(即通过x == true测试),它将抛出错误,该模块还提供了一组辅助函数,以提供更复杂的比较,如assert.deepEqual等。有关assert模块的更多信息,您可以阅读nodejs.org/api/assert.html上的 API 文档。

现在我们需要通过替换package.json中 Flatiron 提供的默认值来指定测试命令脚本:

  "scripts": {
 "test": "mocha test/users.js",
    "start": "node app.js"
  },...

这指定了当告诉 NPM 运行测试时,NPM 应该执行什么操作。要运行测试,请在命令行上输入以下命令:

$ npm test

输出应该是成功的:

...
> mocha test/users.js

  .

  ✔ 1 test complete (284ms)

摘要

要安装 Mocha 和 Zombie,你需要将它们作为开发依赖项包含在应用程序清单中,然后使用 NPM 安装它们。

一旦这些模块安装好,你可以在名为test的目录中为应用程序的每个逻辑组件创建测试文件。每个文件应包含一系列测试,每个测试都应嵌套在describe语句中。

您还应该修改应用程序清单,以指定测试脚本,以便可以使用 NPM 运行测试。

在接下来的章节中,我们将不断完善这个测试,并引入一些新的测试,以覆盖我们应用程序的更多使用情况。

第四章:理解 Mocha

在上一章中,我们安装并介绍了 Mocha。Mocha 是一个 JavaScript 测试框架,可以在 Node.js 内部或浏览器内部运行。你可以使用它来定义和运行自己的测试。Mocha 会报告测试的结果:哪些测试运行正常,哪些测试失败以及失败发生在哪里。Mocha 依次运行每个测试,等待一个测试完成或超时后再运行下一个。

尽管 Mocha 设计为能够在任何现代浏览器上运行,但我们将仅通过 Node.js 通过命令行来运行它。Mocha 还有其他功能,这将在本章中解释。有关 Mocha 功能的更完整参考,请访问 Mocha 的官方文档网站,visionmedia.github.com/mocha/ 了解更多信息。

本章涵盖的主题包括:

  • 描述功能并使用断言

  • 理解 Mocha 如何执行异步测试

通过本章结束时,你应该能够使用 Mocha 执行异步测试,并理解 Mocha 如何控制测试流程。

组织你的测试

有两种策略可以用来组织你的测试。第一种是以某种方式将它们分成单独的文件,每个文件代表应用程序的一个功能或逻辑单元。另一种策略是,可以与第一种策略一起使用,即按功能进行分组。

为应用程序的每个功能单元单独创建一个文件是分离测试关注点的好方法。你应该分析应用程序的结构,并将其分成具有最小重叠量的不同关注点。例如,你的应用程序可能需要处理用户注册 - 这可能是一个功能组。另一个功能组可能是用户登录。如果你的应用程序涉及待办事项列表,你可能希望有一个单独的文件包含该部分的测试。

通过为每个功能组单独创建文件,你可以在处理特定组时独立调用你的测试。这种技术还允许你保持每个文件的行数较低,这在导航和维护测试时很有帮助。

描述功能:在定义测试时,你还可以按功能对应用程序功能进行分组。例如,在描述待办事项列表功能时,你可以进一步将这些功能分开如下:

  • 创建待办事项

  • 删除待办事项

  • 显示待办事项列表

  • 更改待办事项列表项目的顺序

在我们的测试脚本中,我们将描述先前提到的可测试的待办事项功能。

待办事项测试文件的布局可以如下:

describe('To-do items', function() {

  describe('creating', function() {
    // to-do item creating tests here...
  });

  describe('removing', function() {
    // removing a to-do item tests here...
  });

  describe('showing', function() {
    // to-do item list showing tests here...
  });

  describe('ordering', function() {
    // to-do item ordering tests here...
  });

});

你可以嵌套任意多个describe语句,尽可能细化测试的范围,但作为一个经验法则,你应该使用两个描述级别:一个用于功能组(例如,待办事项),另一个级别用于每个功能。在每个功能定义内,你可以放置所有相关的测试。

使用 before 和 after 钩子

对于任何一组测试,你可以设置某些代码在所有测试之前或之后运行。这对于设置数据库、清理一些状态或一般设置或拆除一些你需要以便运行测试本身的状态非常有用。

在下一个示例中,名为runBefore的函数在任何描述的测试之前运行:

describe('some feature', function() {

 before(function runBefore() {
    console.log('running before function...');  });

  it('should do A', function() {
    console.log('test A');
  });

  it('should do B', function() {
    console.log('test B');
  });
});

将此文件代码保存为名为test.js的文件,并在本地安装 Mocha:

$ npm install mocha

运行测试:

$ node_modules/.bin/mocha test.js

它应该给出以下输出:

  running before function...
test A
.test B
.

  ✔ 2 tests complete (6ms)

类似地,你还可以指定一个函数,在所有测试完成后执行:

describe('some feature', function() {

  after(function runAfter() {
    console.log('running after function...');  });

  it('should do A', function() {
    console.log('test A');
  });

  it('should do B', function() {
    console.log('test B');
  });
});

运行此代码会产生以下输出,正如你所期望的那样:

  test A
.test B
.running after function...

  ✔ 2 tests complete (6ms)

还可以定义一个函数,在每个测试块之前(或之后)调用,分别使用beforeEachafterEach关键字。beforeEach关键字的示例用法如下:

describe('some feature', function() {

  beforeEach(function runBeforeEach() {
    console.log('running beforeEach function...');  });

  it('should do A', function() {
    console.log('test A');
  });

  it('should do B', function() {
    console.log('test B');
  });
});

如果运行此测试,输出将为:

  running beforeEach function...
test A
.running beforeEach function...
test B
.

  ✔ 2 tests complete (6ms)

当然,afterEach代码在每次测试执行后调用该函数。

使用异步钩子

在任何测试之前运行的这些函数都可以是异步的。如果一个函数是异步的,只需接受一个回调参数,就像这样:

describe('some feature', function() {
  function runBeforeEach(done) {
    console.log('running afterEach function...');
    setTimeout(done, 1000);
  }
  beforeEach(runBeforeEach);

  it('should do A', function() {
    console.log('test A');
  });

  it('should do B', function() {
    console.log('test B');
  });
});

运行此测试代码时,您会注意到每次测试运行前有一秒的延迟,如果没有提供回调参数,这一点是不会被观察到的。

钩子如何与测试组交互

正如我们所见,在描述范围内,您可以有相应的beforeafterbeforeEachafterEach钩子。如果您有一个嵌套的describe范围,该范围也可以有钩子。除了当前范围上的钩子之外,Mocha 还将调用所有父范围上的钩子。考虑一下这段代码,我们在其中声明了一个两级嵌套:

describe('feature A', function() {

  before(function() {
    console.log('before A');
  });

  after(function() {
    console.log('after A');
  });

  beforeEach(function() {
    console.log('beforeEach A');
  });

  afterEach(function() {
    console.log('afterEach A');
  });

  describe('feature A.1', function() {
    before(function() {
      console.log('before A.1');
    });

    after(function() {
      console.log('after A.1');
    });

    beforeEach(function() {
      console.log('beforeEach A.1');
    });

    afterEach(function() {
      console.log('afterEach A.1');
    });

    it('should do A.1.1', function() {
      console.log('A.1.1');
    });

    it('should do A.1.2', function() {
      console.log('A.1.2');
    });

  });

});

运行上述代码时,输出为:

  before A
before A.1
beforeEach A
beforeEach A.1
A.1.1
.afterEach A.1
afterEach A
beforeEach A
beforeEach A.1
A.1.2
.afterEach A.1
afterEach A
after A.1
after A

  ✔ 4 tests complete (16ms)

使用断言

现在您有一个用于测试代码的地方,您需要一种验证代码是否按预期运行的方法。为此,您需要一个断言测试库。

有许多断言测试库适用于许多编程风格,但在这里,我们将使用 Node.js 已经捆绑的一个,即assert模块。它包含了您需要描述每个测试的期望的最小一组实用函数。在每个测试文件的顶部,您需要使用require引入断言库:

var assert = require('assert');

注意

您可以断言任何表达式的“真实性”。“真实”和“虚假”是 JavaScript(以及其他语言)中的概念,其中类型强制转换允许某些值等同于布尔值 true 或 false。一些例子如下:

var a = true;
assert.ok(a, 'a should be truthy');

虚假值为:

  • false

  • null

  • undefined

  • 空字符串

  • 0(数字零)

  • NaN

所有其他值都为真。

您还可以使用assert.equal来进行相等的测试:

var a = 'ABC';
assert.equal(a, 'ABC');

您还可以使用assert.notEqual来进行不相等的测试:

var a = 'ABC';
assert.notEqual(a, 'ABCDEF');

这最后两个测试等同于 JavaScript 的==(宽松相等)运算符,这意味着它们适用于布尔值、字符串、undefinednull,但不适用于对象和数组。例如,这个断言将失败:

assert.equal({a:1}, {a:1});

它将失败,因为在 JavaScript 中,没有本地方法来比较两个对象的等价性,从而使以下表达式为假:

{a: 1} == {a:1}

要比较对象(包括数组),您应该使用assert.deepEqual

assert.deepEqual({a:1}, {a:1});
assert.deepEqual([0,1], [0,1]);

这个函数递归地比较对象,找出它们是否有某种不同。这个函数也可以用于深度嵌套的对象,正如其名称所暗示的那样:

assert.deepEqual({a:[0,1], b: {c:2}}, {a:[0,1], b: {c:2}});

您还可以测试深层不相等:

assert.notDeepEqual({a:[0,1], b: {c:2}}, {a:[0,1], b: {c:2, d: 3}});

更改断言消息

当断言失败时,将抛出一个包含消息的错误,其中打印了预期值和实际值:

> var a = false;
> assert.ok(a)
AssertionError: false == true
    at repl:1:9
    at REPLServer.self.eval (repl.js:111:21)
    at Interface.<anonymous> (repl.js:250:12)
    at Interface.EventEmitter.emit (events.js:88:17)
    at Interface._onLine (readline.js:199:10)
    at Interface._line (readline.js:517:8)
    at Interface._ttyWrite (readline.js:735:14)
    at ReadStream.onkeypress (readline.js:98:10)
    at ReadStream.EventEmitter.emit (events.js:115:20)
    at emitKey (readline.js:1057:12)

如果愿意,可以用另一种更具上下文的消息类型替换默认消息类型。通过将消息作为任何断言函数的最后一个参数传入来实现这一点:

var result = 'ABC';
assert.equal(result, 'DEF', 'the result of operation X should be DEF');

执行异步测试

Mocha 按顺序运行所有测试,每个测试可以是同步的或异步的。对于同步测试,测试回调函数不应接受任何参数,就像前面的例子一样。但由于 Node.js 不会阻塞 I/O 操作,我们需要对每个测试执行 I/O 操作(至少向服务器发出一个 HTTP 请求),因此我们的测试需要是异步的。

要使测试变成异步,测试函数应该接受一个回调函数,就像这样:

it('tests something asynchronous', function(done) {
  doSomethingAsynchronous(function(err) {
    assert.ok(! err);
 done();
  });
});

done回调函数还接受一个错误作为第一个参数,这意味着您可以直接调用done,而不是抛出错误:

it('tests something asynchronous', function(done) {
  doSomethingAsynchronous(function(err) {
    done(err);
  });
});

如果不需要测试异步函数的返回值,可以直接传递done函数,就像这样:

it('tests something asynchronous', function(done) {
 doSomethingAsynchronous(done);
});

超时:默认情况下,Mocha 为每个异步测试保留 2 秒。您可以通过向 Mocha 传递-t参数来全局更改这个时间:

$ node_modules/.bin/mocha test.js -t 4s

在这里,您可以使用以s为后缀的秒数,如所示,或者您可以简单地传递毫秒数:

$ node_modules/.bin/mocha test.js -t 4000

您还可以通过使用this.timeout(ms)来指定任何测试的超时,就像这样:

it('tests something asynchronous', function(done) {
  this.timeout(500); // 500 milliseconds
  doSomethingAsynchronous(done);
});

总结

Mocha 是一个运行您的测试的框架。您应该根据您想要覆盖的功能区域将测试拆分为几个文件,然后描述每个功能并为每个功能定义必要的测试。

对于这些测试组中的每一个,您可以选择指定要使用beforebeforeEachafterafterEach来调用的回调函数。这些回调函数是指定设置和拆卸函数的地方。这些拆卸或设置函数中的每一个都可以是同步的或异步的。此外,这些测试本身也可以通过简单地将回调传递给测试来使其异步运行,一旦测试完成,回调就会被调用。

对于异步测试,Mocha 保留了默认的 2 秒超时,您可以在全局范围或每个测试的基础上进行覆盖。

在接下来的章节中,我们将看到如何开始使用 Zombie.js 来模拟和操纵浏览器。

第五章:操纵僵尸浏览器

现在我们有了待办 HTTP 应用程序,并且了解了 Mocha 测试框架的工作原理,我们准备开始使用 Zombie.js 创建测试。

如前所述,Zombie.js 允许您创建一个模拟的浏览器环境并对其进行操作。这些操作是用户在浏览器中通常做的事情,比如访问 URL,点击链接,填写和提交表单等。

本章涵盖以下内容:

  • 访问 URL

  • 填写和提交表单

  • 检查浏览器中的错误

  • 验证文档内容

  • 理解 CSS 选择器语法

本章向您展示了如何设置一个与您的 Web 应用程序交互的 Zombie.js 浏览器。

访问 URL:首先,我们将从上次离开的地方继续进行应用测试。整个应用涉及用户,但在这部分中,我们主要将关注Users路由涉及的功能-渲染注册表单和实际在数据库中创建用户记录。

如前所述,我们离开了这个单一的测试文件:

var assert  = require('assert'),
    Browser = require('zombie'),
    app     = require('../app')
    ;

describe('Users', function() {

  before(function(done) {
    app.start(3000, done);
  });

  after(function(done) {
    app.server.close(done);
  });

  describe('Signup Form', function() {

    it('should load the signup form', function(done) {
      var browser = new Browser();
      browser.visit("http://localhost:3000/users/new", function() {
        assert.ok(browser.success, 'page loaded');
        done();
      });
    });

  });
});

这个测试只是加载了用户注册表单,并测试浏览器是否认为它是成功的。让我们通过这个测试来完全理解发生了什么。

首先,我们通过实例化一个新的浏览器对象来创建一个新的浏览器:

var browser = new Browser();

这样创建了一个 Zombie.js 浏览器,它代表一个独立的浏览器进程,主要工作是在请求之间保持状态:URL 历史记录,cookies 和本地存储。

浏览器还有一个主窗口,你可以使用browser.visit()在其中加载一个 URL,就像这样:

browser.visit("http://localhost:3000/users/new");

这使得浏览器执行一个 HTTP GET请求来从该 URL 加载 HTML 页面。由于 Node.js 和 Zombie.js 进行异步 I/O 处理,这只会使 Zombie.js 开始加载页面。然后 Zombie.js 尝试获取 URL,解析 HTML 文档,并通过加载引用的 JavaScript 文件来解析所有依赖项。

一旦所有这些都完成了,我们可以通过将回调函数传递给browser.wait()方法来得到通知,就像这样:

browser.visit("http://localhost:3000/users/new");
browser.wait(function() {
  console.log('browser page loaded');
});

我们不是使用browser.wait函数,而是直接将回调传递给browser.visit()调用,就像这样:

browser.visit("http://localhost:3000/users/new",
  function(err, browser) {
    if (err) throw err;
    assert.ok(browser.success, 'page loaded');
    done();
  }
);

在这里,您传递一个回调函数,一旦出现错误或浏览器准备好,就会被调用。如果发生错误,它将作为第一个参数返回-我们检查是否存在错误,并在存在时抛出它,以便测试失败。

第二个参数包含浏览器对象,与我们已经有的浏览器对象相同。这意味着我们可以完全省略第二个参数,并使用之前的浏览器引用,就像这样:

browser.visit("http://localhost:3000/users/new",
  function(err) {
    if (err) throw err;
    assert.ok(browser.success, 'page loaded');
    done();
  }
);

如果是同一个浏览器对象,你可能会问为什么要传递那个对象。它是为了支持这种调用形式:

var Browser = require('zombie');

Browser.visit(("http://localhost:3000/users/new",
  function(err, browser) {
    if (err) throw err;
    assert.ok(browser.success, 'page loaded');
    done();
  }
);

请注意,这里我们正在使用大写的伪类Browser对象;我们没有实例化browser。相反,我们将这个工作留给Browser模块来做,并将它作为回调函数的第二个参数传递给我们。

注意

从现在开始,我们将更喜欢这种简洁的形式,而不是这里显示的其他形式。

浏览器何时准备好?

当我们要求浏览器访问一个 URL 时,它在完成时会回调我们,但是正如网页开发者所知,很难准确知道何时可以认为页面加载完全完成

浏览器对象有自己的事件循环,处理异步事件,如加载资源、事件、超时和间隔。页面加载和解析完成后,所有依赖项都会异步加载和解析-就像在真实浏览器中一样-使用这个事件循环。

其中一些依赖项可能包含将被加载、解析和评估的 JavaScript 文件。此外,HTML 文档可能包含一些额外的内联脚本,将被执行。如果其中任何脚本有一个等待文档准备就绪的回调,这些回调将在您的browser.visit()回调触发测试回调之前执行。这意味着,例如,如果您有在文档准备就绪时触发的 jQuery 代码,它将在您的回调之前运行。对于任何后续的 AJAX 回调也是如此。

要查看此操作,请尝试在templates/layout.html文件的关闭</body>标记之前立即添加以下代码:

    <script>
      $(function() {
        $.get('/users/new', function() {
          console.log('LOADED NEW');
        });
      });
    </script>

然后更改test/users.js中的测试代码,以便在访问回调被触发时记录日志:

it('should load the signup form', function(done) {
  Browser.visit("http://localhost:3000/users/new", function(err, browser) {
    if (err) throw err;
    console.log('VISIT IS DONE');
    assert.ok(browser.success, 'page loaded');
    done();
  });
});

为了分析这一点,我们将以调试模式运行我们的测试。在此模式下,Zombie.js 输出一些有用的信息,包括浏览器正在执行的 HTTP 请求活动。要启用此模式,请设置DEBUG环境变量,像这样:

$ DEBUG=true node_modules/.bin/mocha test/users.js

现在您应该获得以下调试输出:

Zombie: GET http://localhost:3000/users/new => 200
Zombie: GET http://localhost:3000/js/jquery.min.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui-1.8.23.custom.min.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
Zombie: GET http://localhost:3000/users/new => 200
LOADED NEW
VISIT IS DONE
.

  ✔ 1 test complete (315ms)

注意

如果您是 Windows 用户,则最后一个命令将无法工作。在运行 Mocha 命令之前,您需要设置DEBUG环境变量:

$ SET DEBUG=true

您还需要将正斜杠(/)替换为反斜杠(\):

$ node_modules\.bin\mocha test\users.js

正如您所看到的,LOADED NEW字符串在VISIT IS DONE字符串之前打印,这意味着浏览器在访问回调触发之前执行并完成了 AJAX 请求。您现在可能希望返回代码并删除这些额外的控制台日志。

访问 URL 时的选项

您还可以向浏览器传递一些选项,以修改它加载页面的一些操作和条件。这些选项以对象的形式传递给Browser.visit()调用的参数,就在回调之前,像这样:

Browser.visit(<url>, <options>, <callback>);

以下是我们将详细讨论的最有用的选项:

  • 调试

  • 标题

  • maxWait

调试

正如我们所看到的,通过设置DEBUG环境变量,您可以从 Zombie.js 获得一些输出。通过将debug选项设置为true,也可以激活此功能,像这样:

Browser.visit(url, {debug: true}, callback);

标题

您可以定义一组标头,以便在每个源自此访问的 HTTP 请求上发送。默认情况下,Zombie.js 发送这些标头值:

  • 用户代理:Mozilla/5.0,Chrome/10.0.613.0,Safari/534.15,或 Zombie.js/1.4.1

  • 接受编码:身份

  • 主机:localhost:3000

  • 连接:保持连接

user-agent标头定义了一个虚假的用户代理,有些类似于 Mozilla,Chrome 和 Safari 浏览器,但您可以在此设置中更改它,稍后会看到。

accept-encoding标头指定结果文档不应进行编码。

host标头是 HTTP 1.1 的必需项,指定了此请求所引用的主机名。

connection: keep-alive标头指定在请求完成后应保持与服务器的连接。这是一个内部选项,允许 Node 在许多 HTTP 连接中重用客户端套接字,这将略微加快您的测试速度。

要添加额外的标头值,如果您的应用程序需要任何标头值,请像这样指定它们:

var options = {
  headers: {
    'x-test': 'Test 123',
    'x-test-2': 'Test 234'
  }
};
Browser.visit(url, options, callback);

请注意,这些值在加载依赖项时也将发送给每个请求,例如在 HTML 文档中引用的后续 CSS 和 JavaScript 文件。

maxWait

默认情况下,调用Browser.visit时,Zombie.js 加载页面,解析页面,加载依赖项,并在浏览器中运行任何待处理的 JavaScript 代码。如果这需要超过 5 秒,将引发错误并使您的测试失败。如果由于任何原因,5 秒不足以完成所有这些操作,则可以通过像这样更改maxWait选项来增加限制:

Browser.visit(url, {maxWait: '10s'}, callback);

您可以将值指定为字符串,如10ms100ms7.5s等。

检查元素的存在

Browser.visit()回调被触发时,我们检查错误。我们还检查页面是否成功加载,如果 HTTP 响应状态码在 200 到 299 之间。这些 2XX 响应代码对应于ok请求状态,并且是服务器告知用户代理一切顺利进行的方式的一部分。

尽管收到了一个ok响应,我们不应该轻信服务器的话。我们可能已经收到了响应状态码和一个 HTML 文档,但不能确定我们是否得到了包含用户注册表标记的预期文档。

在我们的情况下,我们可能希望验证文档是否包含一个包含New User字符串的标题元素,并且新用户表单元素是否存在。以下是完整测试的代码:

it('should load the signup form', function(done) {
  Browser.visit("http://localhost:3000/users/new", function(err, browser) {
    if (err) throw err;
    assert.ok(browser.success, 'page loaded');
 assert.equal(browser.text('h1'), 'New User');

 var form = browser.query('form');
 assert(form, 'form exists');
 assert.equal(form.method, 'POST', 'uses POST method');
 assert.equal(form.action, '/users', 'posts to /users');

 assert(browser.query('input[type=email]#email', form),
 'has email input');
 assert(browser.query('input[type=password]#password', form),
 'has password input');
 assert(browser.query('input[type=submit]', form),
 'has submit button');

    done();
  });
});

测试中的新行已经突出显示。现在让我们逐个查看它们。

assert.equal(browser.text('h1'), 'New User');

在这里,browser.text(<selector>)被用来提取h1标签的文本内容(如果至少有一个存在)。

注意

如果选择器匹配多个 HTML 元素(如果在文档中有多个h1标签),browser.text(<selector>)将返回所有匹配节点的连接文本。

在这里,选择器只是标记名称,但您可以使用任何 Sizzle 有效的选择器。这些类似于 CSS3 选择器,也用于 jQuery。如果您对此不熟悉,不用担心,我们将在未来看到更多这方面的例子。

var form = browser.query('form');
assert(form, 'form exists');

注意

浏览器(以及所有浏览器)将当前文档的表示存储在一个可访问的结构中,称为文档对象模型DOM)。文档中的 HTML 标记由浏览器解析,并构建 DOM 树。可以使用 JavaScript 以编程方式遍历此 DOM。

在这里,我们使用browser.query(<selector>)方法来提取第一个表单元素。这个元素是一个 DOM 节点,就像您在浏览器中找到的那样,并且符合 DOM 规范。目前,我们只是测试它是否存在。之后,我们将检查一些属性是否正确:

assert.equal(form.method, 'POST', 'uses POST method');
assert.equal(form.action, '/users', 'posts to /users');

在这里,我们正在验证表单方法是否为POST,以及当用户提交时,它是否实际上发布到/users URL。

接下来,我们验证是否存在创建用户所需的表单元素:

assert(browser.query('input[type=email]#email', form),
  'has email input');
assert(browser.query('input[type=password]#password', form),
  'has password input');
assert(browser.query('input[type=submit]', form),
  'has submit button');

我们使用browser.query(<selector>, <context>)形式来检索第一个匹配的节点,但这次,我们将搜索限制在<context>的子集中,这在我们的情况下是我们的form节点。我们还在这里使用更复杂的选择器,将标记名称选择器(form)与 ID 选择器#id和属性选择器[type=email]结合使用。例如,第一个选择器input[type=email]#email选择具有类型email属性和值email的 ID 的输入。这样,我们断言这样的元素存在,因为如果不存在,browser.query()调用将返回undefined,破坏断言调用。

填写表单

一旦加载了包含用户订阅表单的页面,您就可以填写表单并将其提交回服务器。为此,我们将使用一个新的测试用例:

it("should submit", function(done) {
  Browser.visit("http://localhost:3000/users/new", function(err, browser) {
    if (err) throw err;

    browser
      .fill('E-mail', 'me@email.com')
      .fill('Password', 'mypassword')
      .pressButton('Submit', function(err) {
        if (err) throw err;
        assert.equal(browser.text('h1'), 'Thank you!');
        assert(browser.query('a[href="/session/new"]'),
          'has login link');
        done();
      });

  });
});

在这里,我们重新访问用户创建表单,一旦表单加载完成,我们就使用browser.fill(<field>, <value>)方法填写电子邮件和密码填写。在这个表单中,browser.fill()接受几种类型的参数作为字段标识符。在这里,我们使用了字段之前的标签文本。如果查看空的用户创建表单的源代码,它将是:

<form action="/users" method="POST">
  <p>
    <label for="email">E-mail</label>
    <input type="email" name="email" value="" id="email">
  </p>
  <p>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" value="" required="">
  </p>
  <input type="submit" value="Submit">
</form>

我们在这里使用的两个标签标签都有一个for属性,指示它所关联的标签的id属性。这是 Zombie.js 用来匹配browser.fill()中的字段的方法。或者,我们还可以指定字段名称或 CSS 选择器,使以下填充指令等同于我们所拥有的:

    browser
      .fill('#email', 'me@email.com')
      .fill('#password', 'mypassword')

然后,您可以在 shell 控制台上运行测试:

$ ./node_modules/.bin/mocha test/users.js

只要 CouchDB 服务器可访问,这些测试就应该通过:

  ..

  ✔ 2 tests complete (577ms)

但是,如果再次运行测试,它们应该失败。现在试试看:

  ..

  ✖ 1 of 2 tests failed:

  1) Users Signup Form should submit:
     Error: Server returned status code 409
...

这是因为我们不允许使用相同电子邮件地址的两个用户,浏览器会产生 409 响应代码作为这种用户创建请求的结果。您可以在每次测试之前手动从数据库中删除用户文档,但为了完全解决这个问题,我们需要自动化这个过程。

首先,我们将介绍固定装置的概念。这是我们将为用户定义用户名和密码的地方,这将在其他测试中使用。然后,您需要创建一个文件,在test/fixtures.json下,目前包含以下数据:

{
  "user" : {
    "email": "me@email.com",
    "password": "mypassword"
  }
}

然后,users测试文件将通过在顶部放置require来消耗此 JSON 文件:

var fixtures = require('./fixtures');

然后,您还需要访问数据库,为此我们使用与路由监听器使用相同的库:

var couchdb = require('../lib/couchdb'),
    dbName  = 'users',
    db      = couchdb.use(dbName);

现在我们需要在Signup Form测试描述范围内添加一个 before hook:

before(function(done) {
  db.get(fixtures.user.email, function(err, doc) {
    if (err && err.status_code === 404) return done();
    if (err) throw err;
    db.destroy(doc._id, doc._rev, done);
  });
});

这将确保我们的数据库中没有这样的用户记录。

现在我们正在使用固定装置,让我们从测试代码中删除那些硬编码的用户名和密码字符串:

it("should submit", function(done) {

  Browser.visit("http://localhost:3000/users/new", function(err, browser) {
    if (err) throw err;

    browser
      .fill('E-mail', fixtures.user.email)
      .fill('Password', fixtures.user.password)
      .pressButton('Submit', function(err) {
        if (err) throw err;
        assert.equal(browser.text('h1'), 'Thank you!');
        assert(browser.query('a[href="/session/new"]'),
          'has login link');
        done();
      });

  });
});

这将是整个组装的用户测试文件:

var assert  = require('assert'),
    Browser = require('zombie'),
    app     = require('../app'),
    couchdb = require('../lib/couchdb'),
    dbName  = 'users',
    db      = couchdb.use(dbName),
    fixtures = require('./fixtures');

describe('Users', function() {

  before(function(done) {
    app.start(3000, done);
  });

  after(function(done) {
    app.server.close(done);
  });

  describe('Signup Form', function() {

    before(function(done) {
      db.get(fixtures.user.email, function(err, doc) {
        if (err && err.status_code === 404) return done();
        if (err) throw err;
        db.destroy(doc._id, doc._rev, done);
      });
    });

    it('should load the signup form', function(done) {
      Browser.visit("http://localhost:3000/users/new", function(err, browser) {
        if (err) throw err;
        assert.ok(browser.success, 'page loaded');
        assert.equal(browser.text('h1'), 'New User');

        var form = browser.query('form');

        assert(form, 'form exists');
        assert.equal(form.method, 'POST', 'uses POST method');
        assert.equal(form.action, '/users', 'posts to /users');

        assert(browser.query('input[type=email]#email', form),
          'has email input');
        assert(browser.query('input[type=password]#password', form),
          'has password input');
        assert(browser.query('input[type=submit]', form),
          'has submit button');

        done();
      });
    });

    it("should submit", function(done) {

      Browser.visit("http://localhost:3000/users/new", function(err, browser) {
        if (err) throw err;

        browser
          .fill('E-mail', fixtures.user.email)
          .fill('Password', fixtures.user.password)
          .pressButton('Submit', function(err) {
            if (err) throw err;
            assert.equal(browser.text('h1'), 'Thank you!');
            assert(browser.query('a[href="/session/new"]'),
              'has login link');
            done();
          });

      });
    });

  });
});

当重复运行此测试时,现在应该总是会收到成功消息。

测试登录表单

现在我们已经测试了用户创建流程,让我们测试一下该用户是否可以登录。

按照我们一直使用的测试文件模式,您需要创建一个文件,在test/session.js下,内容如下:

  1. 首先,导入缺少的依赖项:
var assert  = require('assert'),
    Browser = require('zombie'),
    app     = require('../app'),
    couchdb = require('../lib/couchdb'),
    dbName  = 'users',
    db      = couchdb.use(dbName),
    fixtures = require('./fixtures');

describe('Session', function() {

  before(function(done) {
    app.start(3000, done);
  });

  after(function(done) {
    app.server.close(done);
  });

这就结束了开幕式!

  1. 现在我们准备开始描述登录表单:
  describe('Log in form', function() {

    before(function(done) {
      db.get(fixtures.user.email, function(err, doc) {
        if (err && err.status_code === 404) {
 return db.insert(fixtures.user, fixtures.user.email, done);
 }
        if (err) throw err;
        done();
      });
    });

before钩子将创建测试用户文档(如果不存在)(而不是在存在时删除)。

  1. 接下来,我们将测试登录表单是否加载并包含相关元素:

    it('should load', function(done) {
      Browser.visit("http://localhost:3000/session/new",
        function(err, browser) {
          if (err) throw err;
          assert.ok(browser.success, 'page loaded');
          assert.equal(browser.text('h1'), 'Log in');

          var form = browser.query('form');

          assert(form, 'form exists');
          assert.equal(form.method, 'POST', 'uses POST method');
          assert.equal(form.action, '/session', 'posts to /session');

          assert(browser.query('input[type=email]#email', form),
            'has email input');
          assert(browser.query('input[type=password]#password', form),
            'has password input');
          assert(browser.query('input[type=submit]', form),
            'has submit button');

          done();
        });
    });

这里与用户代码的唯一区别是标题字符串应为登录,而不是新用户。这是因为我们目前使用了这样一个最小的用户创建表单。

  1. 接下来,我们正在测试登录表单是否实际有效:
    it("should allow you to log in", function(done) {

      Browser.visit("http://localhost:3000/session/new",
        function(err, browser) {
          if (err) throw err;

          browser
            .fill('E-mail', fixtures.user.email)
            .fill('Password', fixtures.user.password)
            .pressButton('Log In', function(err) {
              if (err) throw err;

              assert.equal(browser.location.pathname, '/todos',
                'should be redirected to /todos');
              done();
            });

        });
    });

  });
});

在这里,我们正在加载并填写电子邮件和密码字段,然后单击登录按钮。单击按钮后,登录表单将被发布,会话将被启动,并且用户将被重定向到待办事项页面。

  1. 现在从命令行运行此测试文件:
$ ./node_modules/.bin/mocha test/session.js
  ․․

  ✔ 2 tests complete (750ms)
  1. 此测试包括用户输入正确用户名和密码的情况,但如果不是这种情况会发生什么?让我们为此创建一个测试用例:
it("should not allow you to log in with wrong password", function(done) {

  Browser.visit("http://localhost:3000/session/new",
    function(err, browser) {
      if (err) throw err;

      browser
        .fill('E-mail', fixtures.user.email)
        .fill('Password', fixtures.user.password +
          'thisisnotmypassword')
        .pressButton('Log In', function(err) {
          assert(err, 'expected an error');
          assert.equal(browser.statusCode, 403, 
            'replied with 403 status code');
          assert.equal(browser.location.pathname, '/session');
          assert.equal(browser.text('#messages .alert .message'),
            'Invalid password');
          done();
        });
    }
  );
});

在这里,我们正在加载并填写登录表单,但这次我们提供了错误的密码。单击登录按钮后,服务器应返回403 状态码,这将触发传递给我们回调函数的错误。然后,我们需要通过检查browser.statusCode属性来检查返回状态码,确保它是预期的 403 禁止代码。然后,我们还要验证用户是否没有被重定向到/todo URL,并且响应文档是否包含一个警报消息,说无效密码

测试待办事项列表

现在我们已经完成了用户注册和会话启动,我们准备测试我们的应用程序的核心,即管理待办事项。我们将首先将应用程序测试的这一部分分离到一个自己的文件中,即test/todos.js,它可能以以下样板开始:

var assert   = require('assert'),
    Browser  = require('zombie'),
    app      = require('../app'),
    couchdb  = require('../lib/couchdb'),
    dbName   = 'todos',
    db       = couchdb.use(dbName),
    fixtures = require('./fixtures'),
    login    = require('./login');

describe('Todos', function() {

  before(function(done) {
    app.start(3000, done);
  });

  after(function(done) {
    app.server.close(done);
  });

  beforeEach(function(done) {
    db.get(fixtures.user.email, function(err, doc) {
      if (err && err.status_code === 404) return done();
      if (err) throw err;
      db.destroy(doc._id, doc._rev, done);
    });
  });
});

在这里,我们有其他模块的类似样板代码,不同之处在于现在我们处理的是名为todos而不是users的数据库。另一个不同之处是我们希望每次测试都从一个干净的待办事项列表开始,因此我们添加了一个beforeEach钩子,用于删除测试用户的所有待办事项。

我们现在准备开始制定一些测试,但至少有一个繁琐的重复任务可以在早期避免:登录。我们应该假设每个测试都可以单独重现,并且测试的顺序并不重要——每个测试应该依赖于一个浏览器实例,模拟每个测试一个独立的用户会话。此外,由于所有待办事项操作都限定在用户和用户会话中必须初始化,我们需要将其抽象成自己的模块,放在test/login.js中:

var Browser = require('zombie'),
    fixtures = require('./fixtures'),
    assert = require('assert'),
    couchdb = require('../lib/couchdb'),
    dbName  = 'users',
    db      = couchdb.use(dbName);

function ensureUserExists(next) {
  db.get(fixtures.user.email, function(err, user) {
    if (err && err.status_code === 404) {
      db.insert(fixtures.user, fixtures.user.email, next);
    }
    if (err) throw err;
    next();
  });
}

module.exports = function(next) {
  return function(done) {

    ensureUserExists(function(err) {
      if (err) throw err;
      Browser.visit("http://localhost:3000/session/new",
        function(err, browser) {
          if (err) throw err;

          browser
            .fill('E-mail', fixtures.user.email)
            .fill('Password', fixtures.user.password)
            .pressButton('Log In', function(err) {
              if (err) throw err;
              assert.equal(browser.location.pathname, '/todos');
              next(browser, done);
            });

        });
    });
  };
};

该模块确保在加载、填写和提交用户登录表单之前存在一个测试用户。之后,它将控制权交给next函数。

测试待办事项列表页面

现在我们准备在todos范围内添加更多的描述范围。其中一个范围是待办事项列表,其中将包含以下代码:

  describe('Todo list', function() {

    it('should have core elements', login(function(browser, done) {
      assert.equal(browser.text('h1'), 'Your To-Dos');
      assert(browser.query('a[href="/todos/new"]'),
        'should have a link to create a new Todo');
      assert.equal(browser.text('a[href="/todos/new"]'), 'New To-Do');
      done();
    }));

    it('should start with an empty list', login(function(browser, done) {
      assert.equal(browser.queryAll('#todo-list tr').length, 0,
        'To-do list length should be 0');
      done();
    }));

    it('should not load when the user is not logged in', function(done) {
      Browser.visit('http://localhost:3000/todos', function(err, browser) {
        if (err) throw err;
        assert.equal(browser.location.pathname, '/session/new',
          'should be redirected to login screen');
        done();
      });
    });

  });

在这里,我们可以看到我们正在使用我们的login模块来抽象出会话初始化过程,确保我们的回调函数只有在用户登录后才会被调用。这里有三个测试。

在我们的第一个测试中,名为应该具有核心元素,我们只是加载空的待办事项列表,并断言我们已经放置了一些元素,例如包含Your To-dos文本的标题和创建新待办事项的链接。

在以下测试中,名为应该以空列表开始,我们只是测试待办事项列表是否包含零个元素。

在此范围的最后一个测试中,名为当用户未登录时不应加载,我们断言该列表对尚未初始化会话的用户是不可访问的,确保如果我们尝试加载待办事项列表URL,他会被重定向到/session/new

测试待办事项创建

现在,我们需要测试待办事项是否真的可以创建。为此,请按照以下步骤进行:

  1. 我们需要一个新的描述范围,我们将其命名为待办事项创建表单,这将是Todos的另一个子范围:
  describe('Todo creation form', function() {
  1. 现在我们可以测试一下,看看未登录的用户是否可以使用待办事项创建表单:
    it('should not load when the user is not logged in', function(done) {
      Browser.visit('http://localhost:3000/todos/new', function(err, browser) {
        if (err) throw err;
        assert.equal(browser.location.pathname, '/session/new',
          'should be redirected to login screen');
        done();
      });
    });

在这里,我们正在验证,如果尝试在未登录的情况下加载待办事项创建表单,用户是否会被重定向到登录界面。

  1. 如果用户已登录,我们将检查页面是否加载了一些预期的元素,例如标题和用于创建新待办事项的表单元素:
    it('should load with title and form', login(function(browser, done) {
      browser.visit('http://localhost:3000/todos/new', function(err) {
        if (err) throw err;
        assert.equal(browser.text('h1'), 'New To-Do');

        var form = browser.query('form');
        assert(form, 'should have a form');
        assert.equal(form.method, 'POST', 'form should use post');
        assert.equal(form.action, '/todos', 'form should post to /todos');

        assert(browser.query('textarea[name=what]', form),
          'should have a what textarea input');
        assert(browser.query('input[type=submit]', form),
          'should have an input submit type');

        done();
      });
    }));

在这里,我们正在验证表单是否存在,它是否具有必要的属性来向/todos URL 发出POST请求,以及表单是否具有文本区输入和按钮。

  1. 现在,我们还可以测试是否可以通过填写相应的表单并提交来成功创建待办事项:
    it('should allow to create a todo', login(function(browser, done) {
      browser.visit('http://localhost:3000/todos/new', function(err) {
        if (err) throw err;

        browser
          .fill('What', 'Laundry')
          .pressButton('Create', function(err) {
            if (err) throw err;

            assert.equal(browser.location.pathname, '/todos',
              'should be redirected to /todos after creation');

            var list = browser.queryAll('#todo-list tr.todo');
            assert.equal(list.length, 1, 'To-do list length should be 1');
            var todo = list[0];
            assert.equal(browser.text('td.pos', todo), 1);
            assert.equal(browser.text('td.what', todo), 'Laundry');

            done();

          });
      });
    }));

在这里,我们最终要测试表单是否允许我们发布新项目,以及项目是否已创建。我们通过加载和填写待办事项创建表单来进行测试;验证我们已被重定向到待办事项列表页面;以及该页面是否包含我们刚刚创建的单个待办事项。

测试待办事项删除

现在我们已经测试了待办事项的插入,我们可以测试是否可以从列表中删除这些项目。我们将把这些测试放在一个名为待办事项删除表单的描述范围内,在其中我们将测试两件事:当只有一个待办事项存在时删除一个待办事项,以及当存在多个待办事项时删除一个待办事项。

注意

我们将这两个测试分开进行,因为先理解单个项目的测试,然后再进行更复杂的测试,以及分开测试我们是否在编程中常见的一次性错误。

以下是从一个项目列表中删除的代码:

describe('Todo removal form', function() {

  describe('When one todo item exists', function() {

 beforeEach(function(done) {
 // insert one todo item
 db.insert(fixtures.todo, fixtures.user.email, done);
 });

    it("should allow you to remove", login(function(browser, done) {

      browser.visit('http://localhost:3000/todos', function(err, browser) {
        if (err) throw err;

        assert.equal(browser.queryAll('#todo-list tr.todo').length, 1);

        browser.pressButton('#todo-list tr.todo .remove form input[type=submit]',
          function(err) {
            if (err) throw err;
            assert.equal(browser.location.pathname, '/todos');
            // assert that all todos have been removed
            assert.equal(browser.queryAll('#todo-list tr').length, 0);
            done();
          }
        );

      });
    }));

  });

在运行测试之前,有一个beforeEach钩子,它会在测试用户的todo数据库中插入一个待办事项。这只是从fixtures.todo中取出的一个待办事项,这是我们需要添加到test/fixtures.json文件的属性:

{
  "user" : {
    "email": "me@email.com",
    "password": "mypassword"
  },
 "todo": {
 "todos": [
 {
 "what": "Do the laundry",
 "created_at": 1346542066308
 }
 ]
 },
  "todos": {
    "todos": [
      {
        "what": "Do the laundry",
        "created_at": 1346542066308
      },
      {
        "what": "Call mom",
        "created_at": 1346542066308
      },
      {
        "what": "Go to gym",
        "created_at": 1346542066308
      }

    ]
  }

}

您可能会注意到,我们在这里利用机会添加一些额外的固定装置,这将有助于未来的测试。

继续分析测试代码,我们看到测试获取待办事项列表,然后验证待办事项的数量实际上是一个:

assert.equal(browser.queryAll('#todo-list tr.todo').length, 1);

然后它继续尝试按下那个待办事项的移除按钮:

browser.pressButton('#todo-list tr.todo .remove form input[type=submit]', …

选择器假设表格上有一个待办事项,我们之前已经验证过了。

注意

如果浏览器无法从给定的 CSS 选择器中找到按钮或提交元素,它将抛出错误,结束当前测试。

然后,在按下按钮并提交移除表单后,我们验证没有发生错误,浏览器被重定向回/todos URL,并且现在呈现的列表为空:

assert.equal(browser.queryAll('#todo-list tr').length, 0);

现在我们已经测试了从一个一项列表中移除一项的工作情况,让我们创建一个更进化的测试,断言我们可以从三项列表中移除特定的项目:

describe('When more than one todo item exists', function() {

  beforeEach(function(done) {
    // insert one todo item
    db.insert(fixtures.todos, fixtures.user.email, done);
  });

  it("should allow you to remove one todo item", login(
    function(browser, done) {

      browser.visit('http://localhost:3000/todos', function(err, browser) {
        if (err) throw err;

        var expectedList = [
          fixtures.todos.todos[0],
          fixtures.todos.todos[1],
          fixtures.todos.todos[2]
        ];

        var list = browser.queryAll('#todo-list tr');
        assert.equal(list.length, 3);

        list.forEach(function(todoRow, index) {
          assert.equal(browser.text('.pos', todoRow), index + 1);
          assert.equal(browser.text('.what', todoRow),
            expectedList[index].what);
        });

            browser.pressButton(
              '#todo-list tr:nth-child(2) .remove input[type=submit]',
              function(err) {
                if (err) throw err;

                assert.equal(browser.location.pathname, '/todos');

                // assert that the middle todo item has been removed
                var list = browser.queryAll('#todo-list tr');
                assert.equal(list.length, 2);

                // remove the middle element from the expected list
                expectedList.splice(1,1);

                // test that the rendered list is the expected list
                list.forEach(function(todoRow, index) {
                  assert.equal(browser.text('.pos', todoRow), index + 1);
                  assert.equal(browser.text('.what', todoRow),
                    expectedList[index].what);
                });

                done();
              }
            );

      });
    }
  ));

});

这个描述范围将与先前的描述范围处于同一级别,还会在todo数据库中插入一个文档,但这次文档包含了一个包含三个待办事项的列表,取自fixtures.todos属性(而不是先前使用的单数fixtures.todo属性)。

测试从访问todo列表页面开始,并构建预期待办事项列表,存储在名为expectedList的变量中。然后我们检索在 HTML 文档中找到的所有待办事项,并验证内容是否符合预期:

list.forEach(function(todoRow, index) {
  assert.equal(browser.text('.pos', todoRow), index + 1);
  assert.equal(browser.text('.what', todoRow),
    expectedList[index].what);
});

一旦我们验证了所有预期的待办事项都已经就位并且顺序正确,我们继续通过以下代码点击列表中第二个项目的按钮:

browser.pressButton(
  '#todo-list tr:nth-child(2) .remove input[type=submit]', ...

在这里,我们使用特殊的 CSS 选择器nth-child来选择第二个待办事项的行,然后获取其中用于移除提交按钮的代码,并最终按下它。

一旦按钮被按下,表单就会被提交,浏览器会回调,我们验证没有错误,我们被重定向回/todos URL,并且它包含了预期的列表。我们通过从先前使用的expectedList数组中移除第二个元素来做到这一点,并验证这正是当前页面显示的内容:

var list = browser.queryAll('#todo-list tr');
assert.equal(list.length, 2);
expectedList.splice(1,1);

// test that the rendered list is the expected list
list.forEach(function(todoRow, index) {
  assert.equal(browser.text('.pos', todoRow), index + 1);
  assert.equal(browser.text('.what', todoRow),
    expectedList[index].what);
});

把所有东西放在一起

您可以手动逐个运行测试,但应该能够一次运行它们全部。为此,您只需要从 shell 命令行中调用:

$ ./node_modules/.bin/mocha test/users.js test/session.js test/todos.js

现在我们需要更改package.json,以便您可以告诉node package manager (npm)如何运行测试:

{
  "description": "To-do App",
  "version": "0.0.0",
  "private": true,
  "dependencies": {
    "union": "0.3.0",
    "flatiron": "0.2.8",
    "plates": "0.4.x",
    "node-static": "0.6.0",
    "nano": "3.3.0",
    "flatware-cookie-parser": "0.1.x",
    "flatware-session": "0.1.x"
  },
  "devDependencies": {
    "mocha": "1.4.x",
    "zombie": "1.4.x"
  },
  "scripts": {
    "test": "mocha test/users.js test/session.js test/todos.js",
    "start": "node app.js"
  },
  "name": "todo",
  "author": "Pedro",
  "homepage": ""
}

现在您可以使用以下命令运行您的测试:

$ npm test
  .............

  ✔ 13 tests complete (3758ms)

摘要

Zombie.js 允许我们访问 URL,加载 HTML 文档,并使用 CSS 选择器检索 HTML 元素。它还允许我们轻松填写表单并提交它们,点击按钮并跟随链接,验证返回状态代码,并使用简洁方便的 API 以相同的方式分析响应文档。

第六章:测试交互

到目前为止,我们已经测试了在表单上填写文本字段,但还有其他更复杂的输入字段,您可以指示 Zombie 浏览器填写。

例如,您可能想要选择单选按钮元素,或从下拉列表框中选择一个项目,或者您可能想要从日期输入字段中选择特定日期。

与表单字段和其他元素交互时,您的应用程序可能会操纵文档,例如显示或隐藏某些元素。在本章结束时,您将了解如何使用 Zombie.js 验证使用 JavaScript 操纵文档的效果。

本章涵盖的主题有:

  • 如何触发其他表单对象的更改

  • 如何测试 DOM 操作

操作单选按钮

要测试单选按钮的使用,我们需要在应用程序的表单中添加一些单选按钮。我们将在待办事项创建表单中引入一个单选按钮,以指示是否应该安排闹钟。根据所选值,应该出现一个字段,允许我们设置待办事项的闹钟日期和时间。

  1. 首先,我们需要更改templates/todos/new.html中的待办事项创建模板:
<h1>New To-Do</h1>
<form id="new-todo-form" action="/todos" method="POST">

  <p>
    <label for="what">What</label>
    <textarea name="what" id="what" required></textarea>
  </p>

  <p>

    <label class="radio" for="alarm-false">
      <input type="radio" name="alarm" value="false" id="alarm-false" checked="checked" /> No Alarm
    </label>

    <label class="radio" for="alarm-true">
      <input type="radio" name="alarm" value="true" id="alarm-true" /> Use Alarm
    </label>

  </p>

  <div id="alarm-date-time" style="display:none">
    <label class="date" for="alarm-date">
      <input type="text" name="alarm-date" id="alarm-date" /> Date (YYYY/MM/DD)
    </label>
    <label class="time" for="alarm-time">
      <input type="text" name="alarm-time" id="alarm-time" /> Time (hh:mm)
    </label>
  </div>

  <input type="submit" value="Create" />
</form>
  1. 这将向用户呈现待办事项创建表单中的一对新单选按钮:操作单选按钮

  2. 现在我们还需要引入一些样式。在public/css/todo.css下创建一个自定义样式表:

#alarm-date-time {
  position: relative;
  margin: 15px 0;
  padding: 39px 19px 14px;
  border: 1px solid #DDD;
  -webkit-border-radius: 4px;
  -moz-border-radius: 4px;
  border-radius: 4px;
  width: auto;
}

#alarm-date-time::after {
  content: "Alarm Date and time";
  position: absolute;
  top: -1px;
  left: -1px;
  padding: 3px 7px;
  font-size: 12px;
  font-weight: bold;
  background-color: whiteSmoke;
  border: 1px solid #DDD;
  color: #9DA0A4;
  -webkit-border-radius: 4px 0 4px 0;
  -moz-border-radius: 4px 0 4px 0;
  border-radius: 4px 0 4px 0;
}
  1. 我们需要在templates/layout.html中的布局文件中引用以前的 CSS 文件:
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title id="title"></title>
    <link href="/css/bootstrap.min.css" rel="stylesheet" >
 <link href="/css/todo.css" rel="stylesheet" >
  </head>
  <body>

    <section role="main" class="container">

      <div id="messages"></div>

      <div id="main-body"></div>

    </section>

    <script src="img/jquery.min.js"></script> 
    <script src="img/jquery-ui-1.8.23.custom.min.js"></script> 
    <script src="img/bootstrap.min.js"></script>
    <script src="img/todos.js"></script>
  </body>
</html>
  1. 接下来,当用户选择闹钟单选按钮时,我们需要使日期和时间表单字段出现。为此,我们需要在public/js/todos.js文件中引入一个事件监听器:
$(function() {
  $('#todo-list').sortable({
    update: function() {
      var order = [];
      $('.todo').each(function(idx, row) {
        order.push($(row).find('.pos').text());
      });

      $.post('/todos/sort', {order: order.join(',')}, function() {
        $('.todo').each(function(idx, row) {
          $(row).find('.pos').text(idx + 1);
        });
      });

    }
  });

 function hideOrShowDateTime() {
 var ringAlarm = $('input[name=alarm]:checked',
 '#new-todo-form').val() === 'true';

 if (ringAlarm) {
 $('#alarm-date-time').slideDown();
 } else {
 $('#alarm-date-time').slideUp();
 }
 }

 $('#new-todo-form input[name=alarm]').change(hideOrShowDateTime);
 hideOrShowDateTime();

});

这个新的事件监听器将监听单选按钮的更改,然后相应地隐藏或显示闹钟日期和时间字段,当“闹钟”设置打开时,结果如下:

操作单选按钮

  1. 我们还需要更改表单提交的路由监听器,以适应这些新字段:
this.post('/', [loggedIn, function() {

  var req  = this.req,
      res  = this.res,
      todo = this.req.body
  ;

  if (! todo.what) {
    res.writeHead(200, {'Content-Type': 'text/html'});
    return res.end(layout(templates['new'], 'New To-Do',
      {error: 'Please fill in the To-Do description'}));
  }

 todo.alarm = todo.alarm === 'true';
 todo.alarm_date = Date.parse(todo['alarm-date'] + ' ' + todo['alarm-time']);
 delete todo['alarm-date'];
 delete todo['alarm-time'];

  todo.created_at = Date.now();

  insert(req.session.user.email, todo, function(err) {

    if (err) {
      res.writeHead(500);
      return res.end(err.stack);
    }

    res.writeHead(303, {Location: '/todos'});
    res.end();
  });

}]);

这段新代码处理了表单字段中提交的闹钟日期和闹钟时间,并将它们解析为时间戳。然后,包含在todo变量中的待办事项被转换为一个看起来像这样的文档:

{ what: 'Deliver books to library',
  alarm: true,
  alarm_date: 1351608900000,
  created_at: 1350915191244 }

测试用户交互

为了测试这些新的表单字段及其组合行为,我们将使用test/todos.js中的测试文件,并增加Todo creation form范围:

  1. 首先,我们测试这些单选按钮是否存在,并且默认情况下闹钟是否关闭:
it('should not present the alarm date form fields when no alarm is selected',
  login(function(browser, done) {
     browser.visit('http://localhost:3000/todos/new', function(err) {
       if (err) throw err;

       browser.choose('No Alarm', function(err) {
         if (err) throw err;

         assert.equal(browser.query('#alarm-date-time').style.display, 'none');
         done();
       });
     });
  })
);

在这里,我们正在验证“闹钟”字段实际上有两个单选按钮,一个具有false值,另一个具有true值。然后我们还验证第一个是否被选中。

  1. 我们还需要验证新的日期和时间表单字段的动画是否有效;包裹闹钟日期和时间输入字段的div元素在用户选择不使用闹钟时应该隐藏。当用户选择“使用闹钟”单选按钮时,div元素应该变为可见:
it('should present the alarm date form fields when alarm', 
  login(function(browser, done) {
    browser.visit('http://localhost:3000/todos/new', function(err) {
      if (err) throw err;

      var container = browser.query('#alarm-date-time');

      browser.choose('No Alarm', function(err) {
        if (err) throw err;

        assert.equal(container.style.display, 'none');

        browser.choose('Use Alarm', function(err) {
          if (err) throw err;

          assert.equal(container.style.display, '');

          browser.choose('No Alarm', function(err) {
            if (err) throw err;

            assert.equal(container.style.display, 'none');

            done();
          });
        });
      });
    });
  })
);

在这里,我们打开和关闭使用闹钟设置,并验证容器div的样式相应更改。在 Zombie 中,所有用户交互函数(如browser.choose()browser.fill()等)都允许您将回调函数作为最后一个参数传递。一旦浏览器事件循环空闲,将调用此函数,这意味着只有在任何动画之后才会调用您的函数。这真的很有用,因为您的测试代码不必显式等待动画完成。您可以确保在调用回调函数后 DOM 被操作。

注意

使用这种技术,您还可以测试任何用户交互。通过提供一个回调函数,当 Zombie 完成所有操作时调用该函数,您可以测试这些操作对文档的影响。

在我们的案例中,我们测试了成功更改div元素的样式属性,但您也可以使用这种技术测试其他交互。例如,正如我们将在下一章中看到的那样,我们可以测试内容是否根据某些用户操作而改变。

选择值

如果表单中有选择框,您还可以指示 Zombie 为您选择列表项。让我们更改我们的待办事项创建表单,以包括描述项目范围的额外选择框 - 项目是否与工作、家庭有关,或者是否是个人任务。

首先,我们需要在templates/todos/new.html中的待办事项创建表单中引入这个额外的字段,就在What文本区域字段之后:

  <label for="scope">
    Scope
    <select name="scope" id="scope">
      <option value="" selected="selected">Please select</option>
      <option value="work">Work</option>
      <option value="personal">Personal</option>
      <option value="family">Family</option>
    </select>
  </label>

这将呈现包含额外的Scope标签和选择框的以下表单:

选择值

现在我们需要有一个测试来验证该表单是否包含select元素和option项。为此,让我们继续扩展test/todos.js文件,在Todo creation form描述范围内:

it('should present the scope select box',
  login(function(browser, done) {
    browser.visit('http://localhost:3000/todos/new', function(err) {
      if (err) throw err;

      var select = browser.queryAll('form select[name=scope]');
      assert.equal(select.length, 1);

      var options = browser.queryAll('form select[name=scope] option');
      assert.equal(options.length, 4);

      options = options.map(function(option) {
        return [option.value, option.textContent];
      });

      var expectedOptions = [
        [null, 'Please select'],
        ['work', 'Work'],
        ['personal', 'Personal'],
        ['family', 'Family']
      ];

      assert.deepEqual(options, expectedOptions);

      done();

    });
  })
);

在这里,我们正在测试select元素是否存在,它是否有四个option项,以及每个项是否具有预期的值和文本。

现在我们需要更改待办事项列表以呈现这个新的范围字段。为此,我们需要在templates/todos/index.html文件中引入它:

<h1>Your To-Dos</h1>

<a class="btn" href="/todos/new">New To-Do</a>

<table class="table">
  <thead>
    <tr>
      <th>#</th>
      <th>What</th>
 <th>Scope</th>
      <th></th>
    </tr>
  </thead>
  <tbody id="todo-list">
    <tr class="todo">
      <td class="pos"></td>
      <td class="what"></td>
 <td class="scope"></td>
      <td class="remove">
        <form action="/todos/delete" method="POST">
          <input type="hidden" name="pos" value="" />
          <input type="submit" name="Delete" value="Delete" />
        </form>
      </td>
    </tr>
  </tbody>
</table>

当在routes/todos.js文件的GET /路由监听器中呈现待办事项列表时,我们还需要填写值:

this.get('/', [loggedIn, function() {

  var res = this.res;

  db.get(this.req.session.user.email, function(err, todos) {

    if (err && err.status_code !== 404) {
      res.writeHead(500);
      return res.end(err.stack);
    }

    if (! todos) todos = {todos: []};
    todos = todos.todos;

    todos.forEach(function(todo, idx) {
      if (todo) todo.pos = idx + 1;
    });

    var map = Plates.Map();
    map.className('todo').to('todo');
    map.className('pos').to('pos');
    map.className('what').to('what');
 map.className('scope').to('scope');
    map.where('name').is('pos').use('pos').as('value');

    var main = Plates.bind(templates.index, {todo: todos}, map);
    res.writeHead(200, {'Content-Type': 'text/html'});
    res.end(layout(main, 'To-Dos'));

  });

这将导致待办事项列表如下截图所示,其中呈现了每个待办事项的scope属性:

选择值

现在我们需要测试待办事项创建是否成功捕获了范围值。为此,我们将稍微更改名为should allow to create a todo的测试:

it('should allow to create a todo', login(function(browser, done) {
  browser.visit('http://localhost:3000/todos/new', function(err) {
    if (err) throw err;

    browser
      .fill('What', 'Laundry')
 .select('scope', 'Personal')
      .pressButton('Create', function(err) {
        if (err) throw err;

        assert.equal(browser.location.pathname, '/todos',
          'should be redirected to /todos after creation');

        var list = browser.queryAll('#todo-list tr.todo');
        assert.equal(list.length, 1, 'To-do list length should be 1');
        var todo = list[0];
        assert.equal(browser.text('td.pos', todo), 1);
        assert.equal(browser.text('td.what', todo), 'Laundry');
 assert.equal(browser.text('td.scope', todo), 'personal');

        done();

      });
  });
}));

总结

Zombie 允许您操纵任何表单对象,包括文本字段、文本区域、选择框、复选框和单选按钮。

Zombie 不仅允许测试服务器响应,还允许模拟用户交互。如果您的应用程序在触发用户事件时动态更改文档(例如选择选项或单击元素),您可以使用 Zombie 和浏览器查询来验证行为是否符合预期。

即使存在用户触发的动画(例如淡入),Zombie 也不会在这些动画完成之前调用回调函数。

在下一章中,我们将分析如何使用 Zombie.js 来测试执行 AJAX 调用的用户交互。

第七章:调试

本章介绍了如何使用浏览器对象来检查应用程序的一些内部状态。

本章涵盖的主题包括:

  • 启用调试输出

  • 转储浏览器状态

默认情况下,Zombie 不会将内部事件输出到控制台,但您可以将 Zombie 运行时的DEBUG环境变量设置为true。如果您使用 UNIX shell 命令行,可以在启动测试套件时添加DEBUG=true,如下所示:

$ DEBUG=true node_modules/.bin/mocha test/todos

如果您使用 Windows,可以按照以下方式设置和取消设置DEBUG环境变量:

$ SET DEBUG=true
$ SET DEBUG=

通过启用此环境变量,Zombie 将输出其进行的每个 HTTP 请求,以及收到的 HTTP 状态代码:

…
Zombie: GET http://localhost:3000/js/todos.js => 200
Zombie: 303 => http://localhost:3000/todos
Zombie: GET http://localhost:3000/todos => 200
Zombie: GET http://localhost:3000/js/jquery-1.8.2.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
…

注意

正如您所看到的,Zombie 还报告了所有3xx-class的 HTTP 重定向以及新的 URL 是什么。

这种输出可能有助于调试一些 URL 加载问题,但很难追踪特定 HTTP 请求所指的测试。

幸运的是,可以通过更改 Mocha 报告器来为测试输出带来一些澄清。Mocha 带有一种称为报告器的功能。到目前为止,我们使用的是默认报告器,它为每个测试报告一个有颜色的点。但是,如果您指定spec报告器,Mocha 会在测试开始之前和测试结束之后输出测试名称。

要启用spec报告器,只需将-R spec添加到 Mocha 参数中,如下所示:

$ DEBUG=true node_modules/.bin/mocha -R spec test/todos

这样,您将获得类似以下的输出:

...
      . should start with an empty list: Zombie: GET http://localhost:3000/session/new => 200
Zombie: GET http://localhost:3000/js/jquery-1.8.2.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
Zombie: 302 => http://localhost:3000/todos
Zombie: GET http://localhost:3000/todos => 200
Zombie: GET http://localhost:3000/js/jquery-1.8.2.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
      ✓ should start with an empty list (378ms)
      . should not load when the user is not logged in: Zombie: 303 => http://localhost:3000/session/new
Zombie: GET http://localhost:3000/session/new => 200
Zombie: GET http://localhost:3000/js/jquery-1.8.2.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
      ✓ should not load when the user is not logged in (179ms)
...

这不仅告诉您给定测试对应的资源加载情况,还告诉您运行该测试所花费的时间。

运行特定测试

如果您遇到特定测试的问题,您无需运行整个测试套件甚至整个测试文件。Mocha 接受-g <expression>命令行选项,并且只运行与该表达式匹配的测试。

例如,您可以仅运行描述中包含remove一词的测试,如下所示:

$ DEBUG=true node_modules/.bin/mocha -R spec -g 'remove' test/todos

  Todos
    Todo removal form
      When one todo item exists
        ◦ should allow you to remove: Zombie: GET http://localhost:3000/session/new => 200
...
        ✓ should allow you to remove (959ms)
      When more than one todo item exists
        ◦ should allow you to remove one todo item: Zombie: GET http://localhost:3000/session/new => 200
...
        ✓ should allow you to remove one todo item (683ms)

  ✔ 2 tests complete (1780ms)

这样,您将只运行这些特定测试。

启用每个测试的调试输出

DEBUG环境变量设置为true可启用所有测试的调试输出,但您也可以通过将browser.debug设置为true来指定要调试的测试。例如,更改test/todos.js文件,大约在第 204 行添加以下内容:

...
      it("should allow you to remove", login(function(browser, done) {
 browser.debug = true;

      browser.visit('http://localhost:3000/todos', function(err, browser) {
...

这样,当运行以下测试时,您无需指定DEBUG环境变量:

$ node_modules/.bin/mocha -R spec -g 'remove' test/todos

  Todos
    Todo removal form
      When one todo item exists
        . should allow you to remove: Zombie: GET http://localhost:3000/todos => 200
Zombie: GET http://localhost:3000/js/jquery.min.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui-1.8.23.custom.min.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
Zombie: 303 => http://localhost:3000/todos
Zombie: GET http://localhost:3000/todos => 200
Zombie: GET http://localhost:3000/js/jquery.min.js => 200
Zombie: GET http://localhost:3000/js/jquery-ui-1.8.23.custom.min.js => 200
Zombie: GET http://localhost:3000/js/bootstrap.min.js => 200
Zombie: GET http://localhost:3000/js/todos.js => 200
        ✓ should allow you to remove (1191ms)
      When more than one todo item exists
        ✓ should allow you to remove one todo item (926ms)

  ✔ 2 tests complete (2308ms)

在这里,您可以看到,正如预期的那样,Zombie 仅为名为should allow you to remove的测试输出调试信息。

使用浏览器 JavaScript 控制台

除了浏览器发出的 HTTP 请求之外,Zombie 不会输出其他可能有趣或有用的内容,以便您调试应用程序。

一个很好的选择,提供了更多的灵活性和洞察力,是在真实浏览器中运行应用程序,并使用开发者工具和/或调试器。

在特定于 Zombie.js 的问题调试中,一个特别有用的替代方法是在浏览器代码中使用console.log()函数(在本应用程序的情况下,该代码位于public/js目录中)。

例如,假设您在处理待办事项创建表单时遇到问题:警报选项未正确触发警报选项窗格的显示和隐藏。为此,我们可以在public/js/todos.js文件中引入以下console.log语句,以检查ringAlarm变量的值:hideOrShowDateTime()函数。

  {
    var ringAlarm = $('input[name=alarm]:checked',
      '#new-todo-form').val() === 'true';

 console.log('\ntriggered hide or show. ringAlarm is ', ringAlarm);

    if (ringAlarm) {
      $('#alarm-date-time').slideDown();
    } else {
      $('#alarm-date-time').slideUp();
    }
  }

这样,当您运行测试时,您将获得以下输出:

$ node_modules/.bin/mocha -R spec -g 'alarm' test/todos

  Todos
    Todo creation form
      . should have an alarm option: 
triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  false
      ✓ should have an alarm option (625ms)
      . should present the alarm date form fields when alarm: 
triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  true

triggered hide or show. ringAlarm is  true

triggered hide or show. ringAlarm is  false

triggered hide or show. ringAlarm is  false
      ✓ should present the alarm date form fields when alarm (1641ms)

  ✔ 2 tests complete (2393ms)

使用这种技术,您可以在运行测试时检查应用程序的状态。

转储浏览器状态

您还可以通过在测试代码中调用browser.dump()函数来检查浏览器状态。

  1. 例如,您可能想在test/todos.js文件中的should present the alarm date form fields when alarm测试中了解完整的浏览器状态。为此,在我们选择“无警报”选项后立即引入browser.dump()调用:
...
    it('should present the alarm date form fields when alarm', 
      login(function(browser, done) {
        browser.visit('http://localhost:3000/todos/new', function(err) {
          if (err) throw err;

          var container = browser.query('#alarm-date-time');

          browser.choose('No Alarm', function(err) {
            if (err) throw err;

            assert.equal(container.style.display, 'none');

            browser.choose('Use Alarm', function(err) {
              if (err) throw err;

              assert.equal(container.style.display, '');

              browser.choose('No Alarm', function(err) {
                if (err) throw err;

 browser.dump();

                assert.equal(container.style.display, 'none');

                done();
              });
            });
          });
        });
      })
    );
...
  1. 在文件中进行更改并运行此测试:
$ node_modules/.bin/mocha -R spec -g 'alarm' test/todos

  Todos
    Todo creation form
      ✓ should have an alarm option (659ms)
      ◦ should present the alarm date form fields when alarm: Zombie: 1.4.1

URL: http://localhost:3000/todos/new
History:
  1\. http://localhost:3000/session/new
  2\. http://localhost:3000/todos
  3: http://localhost:3000/todos/new

sid=AIUjSvUl79S8Qz4Q8foRRAS7; Domain=localhost; Path=/
Cookies:
  true

Storage:

Eventloop:
  The time:   Mon Feb 18 2013 10:59:43 GMT+0000 (WET)
  Timers:     0
  Processing: 0
  Waiting:    0

Document:
  <html>
    <head>    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
      <title id="title">New To-Do</title>
      <link href="/css/bootstrap.min.css" rel="stylesheet" />
      <link href="/css/todo.css" rel="stylesheet" />
  </head>
    <body>    <section role="main" class="container">      <div id="messages"></div>
        <div id="main-body">
          <h1>New To-Do</h1>
          <form id="new-todo-form" action="/todos" method="POST">          <p>            <label for="what">What</...

      ✓ should present the alarm date form fields when alarm (1426ms)

  ✔ 2 tests complete (2236ms)

进行browser.dump()调用时,您将在输出中获得以下内容:

  • 当前 URL

  • 历史记录,即此浏览器实例在创建后访问的所有 URL

  • 离线存储,如果您使用任何

  • 事件循环状态:如果它正在等待任何处理或计时器

  • HTML 文档的第一行,这可能足以调试当前状态

转储整个文档

如果您需要随时检查文档的全部内容,可以检查browser.html()的返回值。例如,如果您想在重新加载浏览器之前检查文档的状态,可以在test/todo.js文件中添加以下行,而不是browser.dump()

...
            browser.choose('Use Alarm', function(err) {
              if (err) throw err;

              assert.equal(container.style.display, '');

              browser.choose('No Alarm', function(err) {
                if (err) throw err;

                console.log(browser.html());

                assert.equal(container.style.display, 'none');

                done();
              });
            });
...

现在您可以运行测试并观察输出:

$ node_modules/.bin/mocha -g 'alarm' test/todos
...
  <html style=""><head>

...

摘要

您的浏览器开发人员工具更适合调试浏览器应用程序。但是,如果遇到特定于 Zombie 的问题,有几种技术可能会对您有所帮助。

一种是启用 Zombie 调试输出。这将显示浏览器正在加载的资源以及显示在旁边的相应响应状态代码。

您可以运行特定的测试。在调试测试中的特定问题时,还可以通过使用-g <pattern>选项来限制 Mocha 仅运行该测试。

您可以在浏览器中运行的代码中使用console.log命令;输出将显示在控制台中。

您可以查看当前的浏览器状态。您可以通过使用browser.dump调用或将browser.html的结果记录到控制台来检查浏览器状态。

如果您需要在测试的某个阶段访问整个文档,还可以记录browser.html()的返回值。

第八章:测试 AJAX

在本书中,我们已经测试了在表单上填写文本字段、点击按钮以及生成的 HTML 文档。这使我们准备好测试传统的基于表单的请求-响应应用程序,但典型的现代应用程序通常比这更复杂,因为它们使用异步 HTTP 请求,以某种方式更新文档而无需刷新它。这是因为它们使用了 AJAX。

当我们呈现待办事项列表页面时,我们的应用程序会发出 AJAX 请求;用户可以拖动一个项目并将其放在新位置。我们放在public/js/todos.js文件中的代码捕捉到变化,并调用服务器/todos/sort URL,改变数据库中的项目顺序。

让我们看看如何使用 Zombie 来测试这个拖放功能。本章涵盖的主题包括:

  • 使用 Zombie 触发 AJAX 调用

  • 使用 Zombie 来测试 AJAX 调用的结果

在本节结束时,您将知道如何使用 Zombie 来测试使用 AJAX 的应用程序。

实现拖放

让我们在test/todos.js文件中添加一些测试。

  1. 我们首先在Todo list作用域结束之前添加一个新的描述作用域:
describe('When there are some items on the list', function() {

这个新的作用域允许我们在运行此作用域内的任何测试之前在数据库中设置一个待办事项列表。

  1. 现在,让我们在新的作用域内添加这个新的beforeEach钩子:
beforeEach(function(done) {
  // insert todo items
  db.insert(fixtures.todos, fixtures.user.email, done);
});
  1. 然后我们通过登录开始测试:
it('should allow me to reorder items using drag and drop',
  login(function(browser, done) {
  1. 我们通过确保我们的项目列表页面中有三个待办事项来开始测试:
var items = browser.queryAll('#todo-list tr');
assert.equal(items.length, 3, 'Should have 3 items and has ' +
  items.length);
  1. 然后我们声明一个辅助函数,将帮助我们验证列表的内容:
function expectOrder(order) {
  var itemTexts = browser.queryAll('#todo-list tr .what').map(
    function(node) {
      return node.textContent.trim();
    }   assert.equal(index + 1, itemPos);
  });
}

这个函数获取一个字符串数组,并断言页面中每个待办事项的whatpos字段的顺序是否符合预期。

  1. 然后我们使用这个新的expectOrder函数来实际测试顺序是否符合预期:
expectOrder(['Do the laundry', 'Call mom', 'Go to gym']);

您可能还记得,在test/fixtures.json文件中声明的待办事项的顺序是在beforeEach钩子加载的。

  1. 接下来我们创建另一个辅助函数,将帮助我们制造和注入鼠标事件:
function mouseEvent(name, target, x, y) {
  var event = browser.document.createEvent('MouseEvents');
  event.initEvent(name, true, true);
  event.clientX = event.screenX = x;
  event.clientY = event.screenY = y;
  event.which = 1;
  browser.dispatchEvent(item, event);
}

这个函数模拟用户鼠标事件,设置了xy坐标,设置了鼠标按钮(event.which = 1),并将事件分派到浏览器中,指定事件发生在哪个项目上。

  1. 接下来我们选择要拖动的待办事项;在这种情况下,我们拖动第一个:
var item = items[0];
  1. 然后我们使用mouseEvent辅助函数来注入一系列制造的事件:
mouseEvent('mousedown', item, 50, 50);
mouseEvent('mousemove', browser.document, 51, 51);
mouseEvent('mousemove', browser.document, 51, 150);
mouseEvent('mouseup',  browser.document, 51, 150);

这些事件有几个重要方面,即事件的顺序、目标元素和鼠标坐标。让我们来分析一下。

这些是组成拖放的事件。首先我们按下鼠标按钮,稍微移动一下,然后再移动一些,最后释放鼠标按钮。这里我们使用的鼠标事件位置的xy值并不重要,重要的是它们之间的相对差异,以便检测到拖动并开始拖动模式。

在第一个事件中,mousedown,我们使用了一个任意的坐标50, 50。在第二个事件中,mousemove,我们将这个坐标增加了一个像素;这开始了拖动。

第二个mousemove事件在 y 轴上继续拖动。看起来多余和冗余,但它是必需的,以便拖动检测起作用,使我们执行的拖动移动连续。

最后,我们有mouseup,用户释放鼠标。这个事件使用了与前一个mousemove相同的坐标,表示用户在拖动后放下了元素。

现在让我们分析事件中的目标元素:

mouseEvent()助手函数的第二个参数是目标元素。在第一个mousedown事件注入中,我们将目标定位到item变量中的待办事项,该变量引用我们要拖动的项目。这表明了我们将要拖动的项目,一旦拖动模式被激活。其余的三个事件将目标定位到浏览器文档,因为用户将在整个文档中拖动待办事项。

我们正在使用的鼠标坐标的进一步澄清:

Zombie 不会渲染项目,因此它不知道每个项目的位置。这是我们可以使用的唯一方法来指示我们正在拖动的元素。在这种情况下,x 和 y 坐标与此无关。

由于 Zombie 不会渲染元素,它不会保留每个元素的位置。事实上,它们都被放置在(0, 0)处,这意味着我们的mouseup事件将拖动的项目放置在最后一个项目之后。

如前所述,初始值和拖动距离是完全任意的,您会发现改变这些值仍然可以使测试工作。

  1. 在将这些鼠标事件注入浏览器事件队列后,我们使用browser.wait()函数等待这些事件被完全处理:
browser.wait(function(err) {
            if (err) throw err;

在这个阶段,浏览器已经改变了元素顺序,并发出了一个 AJAX 请求,将新的顺序发送到服务器。

  1. 现在我们验证待办事项是否按新顺序排列:
expectOrder(['Call mom', 'Go to gym', 'Do the laundry']);
  1. 我们还验证浏览器是否执行了我们预期的 HTTP 请求:
var lastRequest = browser.lastRequest;
assert.equal(lastRequest.url, 'http://localhost:3000/todos/sort');
assert.equal(lastRequest.method, 'POST');

注意

请注意,我们正在使用browser.lastRequest()函数来访问浏览器发出的最后一个 AJAX 请求。

如果您需要访问浏览器发出的每个 HTTP 请求,可以检查browser.resources对象。

现在我们知道浏览器发出了一个HTTP POST请求,命令服务器对待办事项进行排序,我们需要确保数据库中的待办事项已经正确更新。为了验证这一点,我们做了类似于人工测试人员的操作;我们使用browser.reload()重新加载页面,并验证是否顺序确实是预期的:

browser.reload(function(err) {
  if (err) throw err;

  expectOrder(['Call mom', 'Go to gym', 'Do the laundry']);

  done();

});

摘要

使用 Zombie,您可以注入自定义事件来模拟一些复杂的用户操作。您还可以通过使用browser.lastRequest()来检测浏览器执行 HTTP 请求的 URL 和方法。

posted @ 2024-05-23 15:57  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报