在前后端分离大趋势的今天,通过模块的方式来管理代码似乎比以前任何时候都容易。组件都是由 JavaScript 编写,且组件本身就是一个状态机,这为我们编写测试带来了不少便利性。
然而,在如此好的环境下测试似乎依然得不到许多前端人员的重视(包括我)。说起来也是,即便组件化已经深入人心,但实现组件化的方式却多种多样。React, Vue, Angular 这些框架各有各的哲学思想,时间都花在折腾这些工具上了,好好写测试渐渐成了奢望。为此这篇文章我希望能从琳琅满目的前端工具中脱离出来,简单地阐述一些,关于单元测试最基础,或者说稍微本质性的东西。
个人以为,无论一个单元测试有多么复杂,它本质上应该可以划分为下面这些部件
每一个部件都有代表性的程序库,不同的社区可能有不同的选择 (不限于 JS 社区),但个人觉得他们之间区别并不是很大。我们觉得测试复杂,很大一部分原因脚手架导致的,为了把测试集成到项目开发流程中除了需要安装相关的测试依赖库以外还需要搭配Webpack
,当然可能也会包括较为流行的前端框架React
, Vue
等等。
很多时候会造成一种现象就是package.json
里面的依赖包,真正生产环境中会使用的只不过有 1-3 个,然而开发人员所要用到的单单用于测试的依赖包就有十几二十个,怎能不让人生畏?为了排除这些干扰,我只在 Node 平台上面来介绍这些单元测试的基本部件。
Mocha是目前 JS 开源社区用得比较多的一个测试框架,在代码组织层面上它充当了我前面所说的测试声明
的角色,我们可以用它所提供的 DSL 组织测试代码。另外它也包含命令行工具,在 Node 平台上可以执行相关的命令来运行已经定义好的测试。下面我编写一个简单的函数并测试它 (原则上我应该先写测试再写函数)。
// src/handle.js
exports.handleByCallback = (string, callback) => {
return callback(string)
}
这是一个很简单的函数,通过传入回调函数来处理相关的字符串参数,并返回结果。Mocha 如何安装我这里就不多说了,下面是我写的简单的测试文件
// test/handle.spec.js
const assert = require('assert');
const handle = require('../src/handle.js')
describe('Test handle module', () => {
it('handle string by callback method', () => {
assert.equal(typeof handle.handleByCallback, 'function')
const callback = function c(string) {
c.called = true
c.callCount ++
return String.prototype.repeat.call(string, 2)
}
callback.called = false
callback.callCount = 0
assert.equal(handle.handleByCallback("hello", callback), "hellohello")
assert.equal(callback.called, true)
assert.equal(callback.callCount, 1)
assert.equal(handle.handleByCallback("World", callback), "WorldWorld")
assert.equal(callback.called, true)
assert.equal(callback.callCount, 2)
})
})
这测试似乎有点长,试着运行一下 Mocha 的命令行工具并指定对应的测试文件,看看这杯摩卡好不好喝。
看到绿了我就放心了。但是为了写个测试我们还得费心去定义一个函数,这使得我们的测试代码有点长了。耐心看下去,接下来你会知道怎么去优化它。
Sinon.js是我比较喜欢的一个测试辅助工具。我可以用它来创建仿真函数,或者 API 的请求,加快测试编写的进程,使得测试代码更为精炼且可读性更高。接下来我就用这个函数库来优化上面所编写的测试代码。
上面的例子中,我自己创建了一个回调函数,并且为函数设定了相关属性。测试完结之后将会确认两个事情
细想一下如果每次我们都要手动地去定义回调函数及其相关属性的话代码将会越来越长,测试也将越发麻烦。这种时候我们可能会考虑把它封装成一个工厂函数,自动帮我们生成这类函数。毕竟比起内部逻辑我们更关心回调函数的返回值不是吗?这其实就是一种仿真的手段,Sinon 很好地协助我们做好了这个事情,下面是我利用 Sinon 优化过的测试代码
...
const sinon = require('sinon');
describe('Test handle module', () => {
.....
it('handle string by callback method using sinon', () => {
assert.equal(typeof handle.handleByCallback, 'function')
const callback = sinon.fake.returns('Hello World') // 仿真一个总是返回'Hello World'的函数
assert.equal(handle.handleByCallback("hello", callback), "Hello World")
assert.equal(callback.called, true)
assert.equal(callback.callCount, 1)
assert.equal(callback.lastArg, 'hello')
assert.equal(handle.handleByCallback("good job", callback), "Hello World")
assert.equal(callback.called, true)
assert.equal(callback.callCount, 2)
assert.equal(callback.lastArg, 'good job')
})
...
})
上述代码最值得关注的地方在于,只需要
const callback = sinon.fake.returns('Hello World')
就能够仿真出一个总会返回"Hello World"的回调函数,作为一个测试的辅助函数,足矣。
除此之外,仿真函数里面会包含许多可用的属性,具体可参考文档。我这里只列举了几个对于当前测试比较有意义的属性called
-记录函数是否被调用,callCount
-函数被调用的次数,lastArg
-调用函数的最后一个参数,测试效果如下
当然这只是比较简单的场景,Sinon 的能力还远不止如此。我觉得仿真是测试里面的难点,毕竟并不是所有场景都如同上述例子那般简单粗暴,这方面我自己也在慢慢克服着,与君共勉。
Node.js 本身就有断言库,就是我上文引入的assert
。然而很多时候我们的测试代码并不是在 Node 端运行,而是要把相关的代码加载到对应的浏览器中,如 Chrome,Firefox 等等。这种时候就得借助第三方库了。这里我简单介绍一下Chai断言库,它的的断言语句十分丰富,下面我用简单的expect
语句来重写上面的逻辑
...
const chai = require('chai')
const { expect } = chai
describe('Test handle module', () => {
.....
it('handle string by callback method using sinon and chai', () => {
expect(typeof handle.handleByCallback).to.equal('function')
const callback = sinon.fake.returns('Hello World')
expect(handle.handleByCallback('hello', callback)).to.equal("Hello World")
expect(callback.called).to.be.true
expect(callback.callCount).to.equal(1)
expect(callback.lastArg).to.equal('hello')
expect(handle.handleByCallback('good job', callback)).to.equal("Hello World")
expect(callback.called).to.be.true
expect(callback.callCount).to.equal(2)
expect(callback.lastArg).to.equal('good job')
})
...
})
再次运行node_modules/mocha/bin/mocha test/handle.spec.js
命令,结果如下
测试效果跟之前一样。从语法上来看使用了 Chai 之后断言语句似乎有了点 Ruby 范儿。最后来我们聊聊测试 Runner。
测试 Runner 顾名思义就是测试的运行者,上面的例子中每次我们都是通过 Mocha 的命令行程序来运行相应的测试程序,其中 Mocha 就充当了测试 Runner 的角色,然而正式的业务中测试可能会分散到多个不同的目录下,我们可能会在测试或者开发文件中运用较新的 JS 语法,或者是相关的框架的 DSL,为了使这堆代码能够在浏览器端运行就少不了预编译。
前面的例子都是用 Node 来写,相对比较简单且容易理解,但是前端测试注定要复杂的多,我所理解的前端测试对于 Runner 有如下要求
这看起来似乎有点难,但 JS 社区中有一个叫Karma的框架能够大大简化上述工作。它可以简单地与 Webpack 结合,并利用已有的 Webpack 配置来编译我们的代码,只需要简单的配置就可以识别并运行相关的测试。它还能以服务的方式运行,在开发过程中监测文件的改动并重新运行测试。由于篇幅有限就不对它的配置进行更多说明了,有具体需求再去查看文档即可。
PS: 虽说 Angular 最近似乎不怎么受待见,但可别因为 Karma 是 Angular 团队出的就对它视而不见啊。
Q: 为什么没有 Webpack?
A: 说实话确实也计划过在文章里面添加这样一个东西,后来写着写着还是放弃了。Webpack 有丰富的插件系统,确实在某种程度上给予我们开发人员一定的便利性。但是个人觉得它是使得我们如今前端领域变得如此混乱的“罪魁祸首”。单从语法层面来说,Webpack 有点像是 Lisp 系语言中的宏,我们可以定制任何语法,但是在前端领域中这种“宏”却被无节制地使用着,不同的开发人员就能定制出不同的类 JS 语法,为了排除这种干扰,我决定直接采用了 Node 环境下最为“原生”的 JS 写法。
Q: 为什么没有 Webpack 跟 Karma 的集成的相关代码示例子?
A: Karma 本身的配置并不是很复杂,它只是一个测试的 Runner,预编译功能可以依赖 Webpack 来完成,加入一个叫做karma-webpack作为他们之间的桥梁即可。贴相关的代码会导致篇幅过长,且本文重点并不是“配置”。
这篇文章主要简单介绍了一些单元测试的基本部件,每个部件中我都列举了 JS 社区中较为常用的对应的软件库。或许他们会是比较好的选择但却并不是唯一的选择。比如测试框架我们还可以选择Jasmine,断言库我们可以选择expect.js。至于选择什么纯粹是个人喜好的问题,在我看来区别并不是很大。