Tesing:JavaScript 程序测试
为什么要写测试?
Web 应用程序越来越复杂,这意味着有更多的可能出错。测试是帮助我们提高代码质量、降低错误的最好方法和工具之一。
- 测试可以确保得到预期结果。
- 加快开发速度。
- 方便维护。
- 提供用法的文档。
通过测试提供软件的质量,在开始的时候,可能会降低开发速度。但是从长期看,尤其是那种代码需要长期维护、不断开发的情况,测试会大大加快开发速度,减轻维护难度。
测试的类型
单元测试
单元测试(unit testing)指的是以软件的单元(unit)为单位,对软件进行测试。单元可以是一个函数,也可以是一个模块或组件。它的基本特征就是,只要输入不变,必定返回同样的输出。
“单元测试”这个词,本身就暗示,软件应该以模块化结构存在。每个模块的运作,是独立于其他模块的。一个软件越容易写单元测试,往往暗示着它的模块化结构越好,各模块之间的耦合就越弱;越难写单元测试,或者每次单元测试,不得不模拟大量的外部条件,很可能暗示软件的模块化结构越差,模块之间存在较强的耦合。
单元测试的要求是,每个模块都必须有单元测试,而软件由模块组成。
单元测试通常采取断言(assertion)的形式,也就是测试某个功能的返回结果,是否与预期结果一致。如果与预期不一致,就表示测试失败。
单元测试是函数正常工作、不出错的最基本、最有效的方法之一。 每一个单元测试发出一个特定的输入到所要测试的函数,看看函数是否返回预期的输出,或者采取了预期的行动。单元测试证明了所测试的代码行为符合预期。
单元测试有助于代码的模块化,因此有助于长期的重用。因为有了测试,你就知道代码是可靠的,可以按照预期运行。从这个角度说,测试可以节省开发时间。单元测试的另一个好处是,有了测试,就等于就有了代码功能的文档,有助于其他开发者了解代码的意图。
单元测试应该避免依赖性问题,比如不存取数据库、不访问网络等等,而是使用工具虚拟出运行环境。这种虚拟使得测试成本最小化,不用花大力气搭建各种测试环境。
一般来说,单元测试的步骤如下。
- 准备所有的测试条件
- 调用(触发)所要测试的函数
- 验证运行结果是否正确
- 还原被修改的记录
其他测试类型
(1)集成测试
集成测试(Integration test)指的是多个部分在一起测试,比如测试一个数据库连接模块,是否能够连接数据库。
(2)功能测试
功能测试(Functional test)指的是,自动测试整个应用程序的某个功能,比如使用 Selenium 工具自动打开浏览器运行程序。
(3)端对端测试
端对端测试(End-to-End testing)指的是全链路测试,即从开始端到终止端的测试,比如测试从用户界面、通过网络、经过应用程序处理、到达数据库,是否能够返回正确结果。端对端测试的目的是,确保整个系统能够正常运行,各个子系统之间依赖关系正常,数据能够在子系统之间、模块之间正确传递。
(4)冒烟测试
冒烟测试(smoke testing)指的是,正式的全面测试开始之前,对主要功能进行的预测试。它的主要目的是,确认主要功能能否满足需要,软件是否能运行。冒烟测试可以是手工测试,也可以是自动化测试。
这个名字最早来自对电子元件的测试,第一次对电子元件通电,看看它是否会冒烟。如果没有冒烟,说明通过了测试;如果电流达到某个临界点之后,才出现冒烟,这时可以评估是否能够接受这个临界点。
开发模式
测试不仅能够验证软件功能、保证代码质量,也能够影响软件开发的模式。
TDD
TDD 是“测试驱动的开发”(Test-Driven Development)的简称,指的是先写好测试,然后再根据测试完成开发。使用这种开发方式,会有很高的测试覆盖率。
TDD 的开发步骤如下。
- 先写一个测试。
- 写出最小数量的代码,使其能够通过测试。
- 优化代码。
- 重复前面三步。
TDD 开发的测试覆盖率通常在 90%以上,这意味着维护代码和新增特性会非常容易。因为测试保证了你可以信任这些代码,修改它们不会破坏其他代码的运行。
TDD 接口提供以下四个方法。
- suite()
- test()
- setup()
- teardown()
下面代码是测试计数器是否加 1。
1 | suite("Counter", function () { |
BDD
BDD 是“行为驱动的开发”(Behavior-Driven Development)的简称,指的是写出优秀测试的最佳实践的总称。
BDD 认为,不应该针对代码的实现细节写测试,而是要针对行为写测试。BDD 测试的是行为,即软件应该怎样运行。
BDD 接口提供以下六个方法。
- describe()
- it()
- before()
- after()
- beforeEach()
- afterEach()
下面是测试计数器是否加 1 的 BDD 写法。
1 | describe("Counter", function () { |
下面是一个 BDD 开发的示例。现在,需要开发一个Foo
类,该类的实例有一个sayHi
方法,会对类参数说“Hi”。这就是Foo
类的规格,根据这个规格,我们可以写出测试用例文件foo.spec.js
。
1 | describe("Simple object", function () { |
有了测试用例以后,我们再写出实际的脚本文件foo.js
。
1 | function Foo(name) { |
为了把测试用例与脚本文件分开,我们通常把测试用例放在test
子目录之中。然后,我们就可以使用 Mocha、Jasmine 等测试框架,执行测试用例,看看脚本文件是否通过测试。
BDD 术语
(1)测试套件
测试套件(test suite)指的是,一组针对软件规格的某个方面的测试用例。也可以看作,对软件的某个方面的描述(describe)。
测试套件由一个describe
函数构成,它接受两个参数:第一个参数是字符串,表示测试套件的名字或标题,表示将要测试什么;第二个参数是函数,用来实现这个测试套件。
1 | describe("A suite", function () { |
(2)测试用例
测试用例(test case)指的是,针对软件一个功能点的测试,是软件测试的最基本单位。一组相关的测试用例,构成一个测试套件。测试用例由it
函数构成,它与describe
函数一样,接受两个参数:第一个参数是字符串,表示测试用例的标题;第二个参数是函数,用来实现这个测试用例。
1 | describe("A suite", function () { |
(3)断言
断言(assert)指的是对代码行为的预期。一个测试用例内部,包含一个或多个断言(assert)。
断言会返回一个布尔值,表示代码行为是否符合预期。测试用例之中,只要有一个断言为 false,这个测试用例就会失败,只有所有断言都为true
,测试用例才会通过。
1 | describe("A suite", function () { |
断言
断言是判断实际值与预期值是否相等的工具。
断言有 assert、expect、should 三种风格,或者称为三种写法。
1 | // assert风格 |
Chai.js 是一个很流行的断言库,同时支持上面三种风格。
(1) assert 风格
1 | var assert = require("chai").assert; |
上面代码中,assert 方法的最后一个参数是错误提示信息,只有测试没有通过时,才会显示。
(2)expect 风格
1 | var expect = require("chai").expect; |
(3)should 风格
1 | var should = require("chai").should(); |
Mocha.js
概述
Mocha(发音“摩卡”)是现在最流行的前端测试框架之一,此外常用的测试框架还有Jasmine、Tape、zuul等。所谓“测试框架”,就是运行测试的工具。
Mocha 使用下面的命令安装。
1 | # 全局安装 |
上面代码中,除了安装 Mocha 以外,还安装了断言库chai
,这是因为 Mocha 自身不带断言库,必须安装外部断言库。
测试套件文件一般放在test
子目录下面,配置文件mocha.opts
也放在这个目录里面。
浏览器测试
使用浏览器测试时,先用mocha init
命令在指定目录生成初始化文件。
1 | mocha init <path> |
运行上面命令,就会在该目录下生成一个index.html
文件,以及配套的脚本和样式表。
1 |
|
然后在该文件中,加入你要测试的文件(比如app.js
)、测试脚本(app.spec.js
)和断言库(chai.js
)。
1 | <script src="app.js"></script> |
各个文件的内容如下。
1 | // app.js |
命令行测试
Mocha 除了在浏览器运行,还可以在命令行运行。
还是使用上面的文件,作为例子,但是要改成 CommonJS 格式。
1 | // app.js |
然后,在命令行下执行mocha
,就会执行测试。
1 | mocha |
上面的命令等同于下面的形式。
1 | mocha test --reporter spec --recursive --growl |
mocha.opts
所有 Mocha 的命令行参数,都可以写在test
目录下的配置文件mocha.opts
之中。
下面是一个典型的配置文件。
1 | --reporter spec |
上面三个设置的含义如下。
- 使用 spec 报告模板
- 包括子目录
- 打开桌面通知插件 growl
如果希望测试非存放于 test 子目录的测试用例,可以在mocha.opts
写入以下内容。
1 | server-tests |
上面代码指定运行server-tests
目录及其子目录之中的测试脚本。
生成规格文件
Mocha 支持从测试用例生成规格文件。
1 | mocha test/app.spec.js -R markdown > spec.md |
上面命令生成单个app.spec.js
规格。
生成 HTML 格式的报告,使用下面的命令。
1 | mocha test/app.spec.js -R doc > spec.html |
如果要生成整个test
目录,对应的规格文件,使用下面的命令。
1 | mocha test -R markdown > spec.md --recursive |
只要提供测试脚本的路径,Mocha 就可以运行这个测试脚本。
1 | mocha -w src/index.test.js |
上面命令运行测试脚本src/index.test.js
,参数-w
表示 watch,即当这个脚本一有变动,就会运行。
指定测试脚本时,可以使用通配符,同时指定多个文件。
1 | mocha --reporter spec spec/{my,awesome}.js |
上面代码中,参数--reporter
指定生成的报告格式(上面代码是 spec 格式),-ui
指定采用哪一种测试模式(上面代码是 tdd 模式)。
除了使用 shell 通配符,还可以使用 node 通配符。
1 | mocha --compilers js:babel-core/register 'test/**/*.@(js|jsx)' |
上面代码指定运行test
目录下面任何子目录中,文件后缀名为js
或jsx
的测试脚本。注意,Node 的通配符要放在单引号之中,因为否则星号(*
)会先被 shell 解释。
如果要改用 shell 通配符,执行test
目录下面任何子目录的测试脚本,要写成下面这样。
1 | mocha test/**.js |
如果测试脚本不止一个,最好将它们放在专门的目录当中。Mocha 默认执行test
目录的测试脚本,所以可以将所有测试脚本放在test
子目录。--recursive
参数可以指定运行子目录之中的测试脚本。
1 | mocha --recursive |
上面命令会运行test
子目录之中的所有测试脚本。
--grep
参数用于搜索测试用例的名称(即 it 方法的第一个参数),然后只执行匹配的测试用例。
1 | mocha --reporter spec --grep "Fnord:" server-test/*.js |
上面代码只测试名称中包含“Fnord:”的测试用例。
--invert
参数表示只运行不符合条件的测试脚本。
1 | mocha --grep auth --invert |
如果测试脚本用到了 ES6 语法,还需要用--compiler
参数指定 babel 进行转码。
1 | mocha --compilers js:babel/register --recursive |
上面命令会在运行测试脚本之前,先用 Babel 进行转码。--compilers
参数的值是用冒号分隔的一个字符串,冒号左边是文件的后缀名,右边是用来处理这一类文件的模块名。上面代码表示,运行测试之前,先用babel/register
模块,处理一下 JS 文件。
--require
参数指定测试脚本默认包含的文件。下面是一个test_helper.js
文件。
1 | // test/test_helper.js |
使用--require
参数,将上面这个脚本包含进所有测试脚本。
1 | mocha --compilers js:babel/register --require ./test/test_helper.js --recursive |
测试脚本的写法
测试脚本中,describe 方法和 it 方法都允许调用 only 方法,表示只运行某个测试套件或测试用例。
1 | // 例一 |
上面代码中,只有带有only
方法的测试套件或测试用例会运行。
describe 方法和 it 方法还可以调用 skip 方法,表示跳过指定的测试套件或测试用例。
1 | // 例一 |
上面代码中,带有skip
方法的测试套件或测试用例会被忽略。
如果测试用例包含异步操作,可以 done 方法显式指定测试用例的运行结束时间。
1 | it("logs a", function (done) { |
上面代码中,正常情况下,函数 f 还没有执行,Mocha 就已经结束运行了。为了保证 Mocha 等到测试用例跑完再结束运行,可以手动调用 done 方法
Promise 的测试
对于异步的测试,测试用例之中,通常必须调用done
方法,显式表明异步操作的结束。
1 | var expect = require("chai").expect; |
上面代码之中,Promise 对象的then
方法之中,必须指定reject
时的回调函数,并且使用assert.fail
方法抛出错误,否则这个错误就不会被外界感知。
1 | result.then(function (data) { |
上面代码之中,如果 Promise 被reject
,是不会被捕获的,因为 Promise 之中的错误,不会”泄漏“到外界。
Mocha 内置了对 Promise 的支持。
1 | it("should fail the test", function () { |
上面代码中,Mocha 能够捕获reject
的 Promise。
因此,使用 Mocha 时,Promise 的测试可以简化成下面的写法。
1 | var expect = require("chai").expect; |
模拟数据
单元测试时,很多时候,测试的代码会请求 HTTP 服务器。这时,我们就需要模拟服务器的回应,不能在单元测试时去请求真实服务器数据,否则就不叫单元测试了,而是连同服务器一起测试了。
一些工具库可以模拟服务器回应。
覆盖率
测试的覆盖率需要安装 istanbul 模块。
1 | npm i -D istanbul |
然后,在 package.json 设置运行覆盖率检查的命令。
1 | "scripts": { |
上面代码中,test:cover
是生成覆盖率报告,check-coverage
是设置覆盖率通过的门槛。
然后,将coverage
目录写入.gitignore
防止连这个目录一起提交。
如果希望在git commit
提交之前,先运行一次测试,可以安装 ghooks 模块,配置pre-commit
钩子。
安装 ghooks。
1 | npm i -D ghooks |
在 package.json 之中,配置pre-commit
钩子。
1 | "config": { |
还可以把覆盖率检查,加入.travis.yml
文件。
1 | script: |
如果测试脚本使用 ES6,scripts
字段还需要加入 Babel 转码。
1 | "scripts": { |
覆盖率报告可以上传到codecov.io。先安装这个模块。
1 | npm i -D codecov.io |
然后在 package.json 增加一个字段。
1 | "scripts": { |
最后,在 CI 的配置文件.travis.yml
之中,增加运行这个命令。
1 | after_success: |
WebDriver
WebDriver 是一个浏览器的自动化框架。它在各种浏览器的基础上,提供一个统一接口,将接收到的指令转为浏览器的原生指令,驱动浏览器。
WebDriver 由 Selenium 项目演变而来。Selenium 是一个测试自动化框架,它的 1.0 版叫做 Selenium RC,通过一个代理服务器,将测试脚本转为 JavaScript 脚本,注入不同的浏览器,再由浏览器执行这些脚本后返回结果。WebDriver 就是 Selenium 2.0,它对每个浏览器提供一个驱动,测试脚本通过驱动转换为浏览器原生命令,在浏览器中执行。
定制测试环境
DesiredCapabilities 对象用于定制测试环境。
- 定制 DesiredCapabilities 对象的各个属性
- 创建 DesiredCapabilities 实例
- 将 DesiredCapabilities 实例作为参数,新建一个 WebDriver 实例
操作浏览器的方法
WebDriver 提供以下方法操作浏览器。
close():退出或关闭当前浏览器窗口。
1 | driver.close(); |
quit():关闭所有浏览器窗口,中止当前浏览器 driver 和 session。
1 | driver.quit(); |
getTitle():返回当前网页的标题。
1 | driver.getTitle(); |
getCurrentUrl():返回当前网页的网址。
1 | driver.getCurrentUrl(); |
getPageSource():返回当前网页的源码。
1 | // 断言是否含有指定文本 |
click():模拟鼠标点击。
1 | // 例一 |
clear():清空文本输入框。
1 | // 例一 |
sendKeys():在文本输入框输入文本。
1 | driver.findElement(By.locatorType("path")).sendKeys("your text"); |
submit():提交表单,或者用来模拟按下回车键。
1 | // 例一 |
findElement():返回选中的第一个元素。
1 | driver.get("https://www.google.com"); |
findElements():返回选中的所有元素(0 个或多个)。
1 | // 例一 |
可以使用size()
,查看到底选中了多少个元素。
网页元素的定位
WebDriver 提供 8 种定位器,用于定位网页元素。
- By.id:HTML 元素的 id 属性
- By.name:HTML 元素的 name 属性
- By.xpath:使用 XPath 语法选中 HTML 元素
- By.cssSelector:使用 CSS 选择器语法
- By.className:HTML 元素的 class 属性
- By.linkText:链接文本(只用于选中链接)
- By.tagName:HTML 元素的标签名
- By.partialLinkText:部分链接文本(只用于选中链接)
下面是一个使用 id 定位器,选中网页元素的例子。
1 | driver.findElement(By.id("sblsbb")).click(); |
网页元素的方法
以下方法属于网页元素的方法,而不是 webDriver 实例的方法。需要注意的是,有些方法是某些元素特有的,比如只有文本框才能输入文字。如果在网页元素上调用不支持的方法,WebDriver 不会报错,也不会给出给出任何提示,只会静静地忽略。
getAttribute():返回网页元素指定属性的值。
1 | driver.get("https://www.google.com"); |
getText():返回网页元素的内部文本。
1 | driver.findElement(By.locatorType("path")).getText(); |
getTagName():返回指定元素的标签名。
1 | driver.get("https://www.google.com"); |
isDisplayed():返回一个布尔值,表示元素是否可见。
1 | driver.get("https://www.google.com"); |
isEnabled():返回一个布尔值,表示文本框是否可编辑。
1 | driver.get("https://www.google.com"); |
isSelected():返回一个布尔值,表示一个元素是否可选择。
1 | driver.findElement(By.xpath("//select[@name='jump']/option[1]")).isSelected(); |
getSize():返回一个网页元素的宽度和高度。
1 | var dimensions = driver.findElement(By.locatorType("path")).getSize(); |
getLocation():返回网页元素左上角的 x 坐标和 y 坐标。
1 | var point = driver.findElement(By.locatorType("path")).getLocation(); |
getCssValue():返回网页元素指定的 CSS 属性的值。
1 | driver.get("https://www.google.com"); |
页面跳转的方法
以下方法用来跳转到某一个页面。
get():要求浏览器跳到某个网址。
1 | driver.get("URL"); |
navigate().back():浏览器回退。
1 | driver.navigate().back(); |
navigate().forward():浏览器前进。
1 | driver.navigate().forward(); |
navigate().to():跳转到浏览器历史中的某个页面。
1 | driver.navigate().to("URL"); |
navigate().refresh():刷新当前页面。
1 | driver.navigate().refresh(); |
cookie 的方法
getCookies():获取 cookie
1 | driver.get("https://www.google.com"); |
getCookieNamed() :返回指定名称的 cookie。
1 | driver.get("https://www.google.com"); |
addCookie():将 cookie 加入当前页面。
1 | driver.get("https://www.google.com"); |
deleteCookie():删除指定的 cookie。
1 | driver.get("https://www.google.co.in"); |
浏览器窗口的方法
maximize():最大化浏览器窗口。
1 | var driver = new FirefoxDriver(); |
getSize():返回浏览器窗口、图像、网页元素的宽和高。
1 | driver.manage().window().getSize(); |
getPosition():返回浏览器窗口左上角的 x 坐标和 y 坐标。
1 | console.log("Position X: " + driver.manage().window().getPosition().x); |
setSize():定制浏览器窗口的大小。
1 | var d = new Dimension(320, 480); |
setPosition():移动浏览器左上角到指定位置。
1 | var p = new Point(200, 200); |
getWindowHandle():返回当前浏览器窗口。
1 | var parentwindow = driver.getWindowHandle(); |
getWindowHandles():返回所有浏览器窗口。
1 | var childwindows = driver.getWindowHandles(); |
switchTo.window():在浏览器窗口之间切换。
1 | driver.SwitchTo().Window(childwindow); |
弹出窗口
以下方法处理浏览器的弹出窗口。
dismiss() :关闭弹出窗口。
1 | var alert = driver.switchTo().alert(); |
accept():接受弹出窗口,相当于按下接受 OK 按钮。
1 | var alert = driver.switchTo().alert(); |
getText():返回弹出窗口的文本值。
1 | var alert = driver.switchTo().alert(); |
sendKeys():向弹出窗口发送文本字符串。
1 | var alert = driver.switchTo().alert(); |
authenticateUsing():处理 HTTP 认证。
1 | var user = new UserAndPassword("USERNAME", "PASSWORD"); |
鼠标和键盘的方法
以下方法模拟鼠标和键盘的动作。
- click():鼠标在当前位置点击。
- clickAndHold():按下鼠标不放
- contextClick():右击鼠标
- doubleClick():双击鼠标
- dragAndDrop():鼠标拖放到目标元素
- dragAndDropBy():鼠标拖放到目标坐标
- keyDown():按下某个键
- keyUp():从按下状态释放某个键
- moveByOffset():移动鼠标到另一个坐标位置
- moveToElement():移动鼠标到另一个网页元素
- release():释放拖拉的元素
- sendKeys():控制键盘输出