单元测试之基本构成

1,010 阅读8分钟

在前后端分离大趋势的今天,通过模块的方式来管理代码似乎比以前任何时候都容易。组件都是由JavaScript编写,且组件本身就是一个状态机,这为我们编写测试带来了不少便利性。

然而,在如此好的环境下测试似乎依然得不到许多前端人员的重视(包括我)。说起来也是,即便组件化已经深入人心,但实现组件化的方式却多种多样。React, Vue, Angular这些框架各有各的哲学思想,时间都花在折腾这些工具上了,好好写测试渐渐成了奢望。为此这篇文章我希望能从琳琅满目的前端工具中脱离出来,简单地阐述一些,关于单元测试最基础,或者说稍微本质性的东西。

1. 关于单元测试

个人以为,无论一个单元测试有多么复杂,它本质上应该可以划分为下面这些部件

  1. 测试声明
  2. 测试断言
  3. 测试运行者
  4. 仿真(可能会有)

每一个部件都有代表性的程序库,不同的社区可能有不同的选择(不限于JS社区),但个人觉得他们之间区别并不是很大。我们觉得测试复杂,很大一部分原因脚手架导致的,为了把测试集成到项目开发流程中除了需要安装相关的测试依赖库以外还需要搭配Webpack,当然可能也会包括较为流行的前端框架React, Vue等等。

很多时候会造成一种现象就是package.json里面的依赖包,真正生产环境中会使用的只不过有1-3个,然而开发人员所要用到的单单用于测试的依赖包就有十几二十个,怎能不让人生畏?为了排除这些干扰,我只在Node平台上面来介绍这些单元测试的基本部件。

2. 单元测试基本构成

1) 测试框架Mocha

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的命令行工具并指定对应的测试文件,看看这杯摩卡好不好喝。

One

看到绿了我就放心了。但是为了写个测试我们还得费心去定义一个函数,这使得我们的测试代码有点长了。耐心看下去,接下来你会知道怎么去优化它。

2) 测试辅助工具Sinon

Sinon.js是我比较喜欢的一个测试辅助工具。我可以用它来创建仿真函数,或者API的请求,加快测试编写的进程,使得测试代码更为精炼且可读性更高。接下来我就用这个函数库来优化上面所编写的测试代码。

上面的例子中,我自己创建了一个回调函数,并且为函数设定了相关属性。测试完结之后将会确认两个事情

  1. 配合回调函数所得到的结果是否符合预期。
  2. 回调函数是否被调用,以及调用了多少次。

细想一下如果每次我们都要手动地去定义回调函数及其相关属性的话代码将会越来越长,测试也将越发麻烦。这种时候我们可能会考虑把它封装成一个工厂函数,自动帮我们生成这类函数。毕竟比起内部逻辑我们更关心回调函数的返回值不是吗?这其实就是一种仿真的手段,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-调用函数的最后一个参数,测试效果如下

Two

当然这只是比较简单的场景,Sinon的能力还远不止如此。我觉得仿真是测试里面的难点,毕竟并不是所有场景都如同上述例子那般简单粗暴,这方面我自己也在慢慢克服着,与君共勉。

3) 更丰富的断言Chai

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命令,结果如下

Three

测试效果跟之前一样。从语法上来看使用了Chai之后断言语句似乎有了点Ruby范儿。最后来我们聊聊测试Runner。

4) 测试Runner-Karma

测试Runner顾名思义就是测试的运行者,上面的例子中每次我们都是通过Mocha的命令行程序来运行相应的测试程序,其中Mocha就充当了测试Runner的角色,然而正式的业务中测试可能会分散到多个不同的目录下,我们可能会在测试或者开发文件中运用较新的JS语法,或者是相关的框架的DSL,为了使这堆代码能够在浏览器端运行就少不了预编译。

前面的例子都是用Node来写,相对比较简单且容易理解,但是前端测试注定要复杂的多,我所理解的前端测试对于Runner有如下要求

  1. 编译代码(包括测试代码和源文件代码)。
  2. 识别相关的测试目录。
  3. 批量运行测试。
  4. 可以根据需求安装相关的插件。

这看起来似乎有点难,但JS社区中有一个叫Karma的框架能够大大简化上述工作。它可以简单地与Webpack结合,并利用已有的Webpack配置来编译我们的代码,只需要简单的配置就可以识别并运行相关的测试。它还能以服务的方式运行,在开发过程中监测文件的改动并重新运行测试。由于篇幅有限就不对它的配置进行更多说明了,有具体需求再去查看文档即可。

PS: 虽说Angular最近似乎不怎么受待见,但可别因为Karma是Angular团队出的就对它视而不见啊。

3. Question & Answer

Q: 为什么没有Webpack?

A: 说实话确实也计划过在文章里面添加这样一个东西,后来写着写着还是放弃了。Webpack有丰富的插件系统,确实在某种程度上给予我们开发人员一定的便利性。但是个人觉得它是使得我们如今前端领域变得如此混乱的“罪魁祸首”。单从语法层面来说,Webpack有点像是Lisp系语言中的宏,我们可以定制任何语法,但是在前端领域中这种“宏”却被无节制地使用着,不同的开发人员就能定制出不同的类JS语法,为了排除这种干扰,我决定直接采用了Node环境下最为“原生”的JS写法。


Q: 为什么没有Webpack跟Karma的集成的相关代码示例子?

A: Karma本身的配置并不是很复杂,它只是一个测试的Runner,预编译功能可以依赖Webpack来完成,加入一个叫做karma-webpack作为他们之间的桥梁即可。贴相关的代码会导致篇幅过长,且本文重点并不是“配置”。

4. 尾声

这篇文章主要简单介绍了一些单元测试的基本部件,每个部件中我都列举了JS 社区中较为常用的对应的软件库。或许他们会是比较好的选择但却并不是唯一的选择。比如测试框架我们还可以选择Jasmine,断言库我们可以选择expect.js。至于选择什么纯粹是个人喜好的问题,在我看来区别并不是很大。

Happy Coding and Writing!!