本文作者: 江水
本文主要介绍前端单元测试的一些技术方案。
单元测试的技术方案很多,不同工具之间有互相协同,也存在功能重合,给我们搭配测试方案带来不小的困难,而且随着 ES6
, TypeScript
的出现,单元测试又增加了很多其他步骤,完整配置起来往往需要很大的时间成本。我希望通过对这些工具的各自作用的掌握,了解完整的前端测试技术方案。前端单元测试的领域也很多,这里主要讲对于前端组件如何进行单元测试,最后会主要介绍下对于 React
组件的一些测试方法总结。
通用测试
单元测试最核心的部分就是做断言,比如传统语言中的 assert
函数,如果当前程序的某种状态符合 assert
的期望此程序才能正常执行,否则直接退出应用。所以我们可以直接用 Node
中自带的 assert
模块做断言。
用最简单的例子做个验证
function multiple(a, b) {
let result = 0;
for (let i = 0; i < b; ++i)
result += a;
return result;
}
const assert = require('assert');
assert.equal(multiple(1, 2), 3));
这种例子能够满足基础场景的使用,也可以作为一种单元测试的方法。
nodejs
自带的 assert
模块提供了下面一些断言方法,只能满足一些简单场景的需要。
assert.deepEqual(actual, expected[, message])
assert.deepStrictEqual(actual, expected[, message])
assert.doesNotMatch(string, regexp[, message])
assert.doesNotReject(asyncFn[, error][, message])
assert.doesNotThrow(fn[, error][, message])
assert.equal(actual, expected[, message])
assert.fail([message])
assert.ifError(value)
assert.match(string, regexp[, message])
assert.notDeepEqual(actual, expected[, message])
assert.notDeepStrictEqual(actual, expected[, message])
assert.notEqual(actual, expected[, message])
assert.notStrictEqual(actual, expected[, message])
assert.ok(value[, message])
assert.rejects(asyncFn[, error][, message])
assert.strictEqual(actual, expected[, message])
assert.throws(fn[, error][, message])
自带的 assert
不是专门给单元测试使用, 提供的错误信息文档性不好,上面的 demo
最终执行下来会产生下面的报告:
$ node index.js
assert.js:84
throw new AssertionError(obj);
^
AssertionError [ERR_ASSERTION]: 2 == 3
at Object. (/home/quanwei/git/index.js:4:8)
at Module._compile (internal/modules/cjs/loader.js:778:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)
at Module.load (internal/modules/cjs/loader.js:653:32)
at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
at Function.Module._load (internal/modules/cjs/loader.js:585:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)
at startup (internal/bootstrap/node.js:283:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:623:3)
由于自带的模块依赖 Node
自身的版本,没办法自由升级,所以使用内置的包灵活性有时候不太够,另外我们很多断言函数也需要在浏览器端执行,所以我们需要同时支持浏览器和 Node
端的断言库。同时观察上面的输出可以发现,这个报告更像是程序的错误报告,而不是一个单元测试报告。而我们在做单元测时往往需要断言库能够提供良好的测试报告,这样才能一目了然地看到有哪些断言通过没通过,所以使用专业的单元测试断言库还是很有必要。
chai
chai
是目前很流行的断言库,相比于同类产品比较突出。chai
提供了 TDD (Test-driven development)和 BDD (Behavior-driven development) 两种风格的断言函数,这里不会过多介绍两种风格的优缺,本文主要以 BDD
风格做演示。
TDD 风格的 chai
var assert = require('chai').assert
, foo = 'bar'
, beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };
assert.typeOf(foo, 'string'); // without optional message
assert.typeOf(foo, 'number', 'foo is a number'); // with optional message
assert.equal(foo, 'bar', 'foo equal `bar`');
assert.lengthOf(foo, 3, 'foo`s value has a length of 3');
assert.lengthOf(beverages.tea, 3, 'beverages has 3 types of tea');
chai
比 Node
自带的 assert
增加了一个断言说明参数,可以通过这个参数提高测试报告的可读性
$ node chai-assert.js
/home/quanwei/git/learn-tdd-bdd/node_modules/chai/lib/chai/assertion.js:141
throw new AssertionError(msg, {
^
AssertionError: foo is a number: expected 'bar' to be a number
at Object. (/home/quanwei/git/learn-tdd-bdd/chai-assert.js:6:8)
at Module._compile (internal/modules/cjs/loader.js:778:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)
at Module.load (internal/modules/cjs/loader.js:653:32)
at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
at Function.Module._load (internal/modules/cjs/loader.js:585:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)
at startup (internal/bootstrap/node.js:283:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:623:3)
BDD 风格的 chai
chai
的 BDD
风格使用 expect
函数作为语义的起始,也是目前几乎所有 BDD
工具库都遵循的风格。
chai
的 expect
断言风格如下
expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.lengthOf(3);
BDD
的思想就是写单元测试就像写产品需求,而不关心内部逻辑,每一个用例阅读起来就像一篇文档。例如下面的用例:
- foo 是一个字符串 ->
expect(foo).to.be.a('string')
- foo 字符串里包含 'bar' ->
expect(foo).to.include('bar')
- foo 字符串里不包含 'biz' ->
expect(foo).to.not.include('biz')
可以看到这种风格的测试用例可读性更强。
其他的断言库还有 expect.js
should.js
better-assert , unexpected.js 这些断言库都只提供纯粹的断言函数,可以根据喜好选择不同的库使用。
有了断言库之后我们还需要使用测试框架将我们的断言更好地组织起来。
mocha 和 Jasmine
mocha
是一个经典的测试框架(Test Framework),测试框架提供了一个单元测试的骨架,可以将不同子功能分成多个文件,也可以对一个子模块的不同子功能再进行不同的功能测试,从而生成一份结构型的测试报告。例如 mocha
就提供了describe
和 it
描述用例结构,提供了 before
, after
, beforeEach
, afterEach
生命周期函数,提供了 describe.only
,describe.skip
, it.only
, it.skip
用以执行指定部分测试集。
const { expect } = require('chai');
const { multiple } = require('./index');
describe('Multiple', () => {
it ('should be a function', () => {
expect(multiple).to.be.a('function');
})
it ('expect 2 * 3 = 6', () => {
expect(multiple(2, 3)).to.be.equal(6);
})
})
测试框架不依赖底层的断言库,哪怕使用原生的 assert
模块也可以进行。给每一个文件都要手动引入 chai
比较麻烦 ,这时候可以给 mocha
配置全局脚本,在项目根目录 .mocharc.js
文件中加载断言库, 这样每个文件就可以直接使用 expect
函数了。
// .mocharc.js
global.expect = require('chai').expect;
使用 mocha 可以将我们的单元测试输出成一份良好的测试报告 mocha *.test.js
因为运行在不同环境中需要的包格式不同,所以需要我们针对不同环境做不同的包格式转换,为了了解在不同端跑单元测试需要做哪些事情,可以先来了解一下常见的包格式。
目前我们主流有三种模块格式,分别是 AMD
, CommonJS
, ES Module
。
AMD
AMD 是 RequireJS
推广过程中流行的一个比较老的规范,目前无论浏览器还是 Node
都没有默认支持。AMD
的标准定义了 define
和 require
函数,define
用来定义模块及其依赖关系,require
用以加载模块。例如
Document
+
+
// index.js
define('moduleA', ['https://some/of/cdn/path'], function() {
return { name: 'moduleA' };
});
define(function(require) {
const fs = require('fs');
return fs;
})
define('moduleB', function() {
return { name: 'module B' }
});
require(['moduleA', 'moduleB'], function(moduleA, moduleB) {
console.log(module);
});
这里使用了RequireJS
作为 AMD
引擎, 可以看到 define
函数会定义当前依赖了哪些模块并将模块加载完成后异步回调给当前模块,这种特性使得 AMD 尤为适合浏览器端异步加载。
我们可以使用 webpack
打包一份 amd
模块看下真实代码
// entry.js
export default function sayHello() {
return 'hello amd';
}
// webpack.config.js
module.exports = {
mode: 'development',
devtool: false,
entry: './entry.js',
output: {
libraryTarget: 'amd'
}
}
最终生成代码(精简了不相关的逻辑)
// dist/main.js
define(() => ({
default: function sayHello() {
return 'hello amd';
}
}));
在浏览器/Node
中想要使用 AMD
需要全局引入 RequireJS
,对单元测试而言比较典型的问题是在初始化 karma
时会询问是否使用 RequireJS
,不过一般现在很少有人使用了。
CommonJS
可以缩写成CJS
, 其 规范 主要是为了定义 Node
的包格式,CJS
定义了三个关键字, 分别为 require
,exports
, module
, 目前几乎所有Node
包以及前端相关的NPM
包都会转换成该格式, CJS
在浏览器端需要使用 webpack
或者 browserify
等工具打包后才能执行。
ES Module
ES Module
是 ES 2015
中定义的一种模块规范,该规范定义了 代表为 import
和 export
,是我们开发中常用的一种格式。虽然目前很多新版浏览器都支持 了,支持在浏览器中直接运行
ES6
代码,但是浏览器不支持 node_modules
,所以我们的原始 ES6
代码在浏览器上依然无法运行,所以这里我暂且认为浏览器不支持 ES6
代码, 依然需要做一次转换。
下表为每种格式的支持范围,括号内表示需要借助外部工具支持。
Node | 浏览器 | |
---|---|---|
AMD | 不支持(require.js, r.js) | 不支持(require.js) |
CommonJS | 支持 | 不支持(webpack/browserify) |
ESModule | 不支持(babel) | 不支持(webpack) |
单元测试要在不同的环境下执行就要打不同环境对应的包,所以在搭建测试工具链时要确定自己运行在什么环境中,如果在 Node
中只需要加一层 babel
转换,如果是在真实浏览器中,则需要增加 webpack
处理步骤。
所以为了能够在 Node
环境的 Mocha
中使用 ES Module
有两种方式
Node
环境天生支持ES Module
(node version >= 15)- 使用
babel
代码进行一次转换
第一种方式略过,第二种方式使用下面的配置
npm install @babel/register @babel/core @babel/preset-env --save-dev
// .mocharc.js
+ require('@babel/register');
global.expect = require('chai').expect;
// .babelrc
+ {
+ "presets": ["@babel/preset-env" ,“@babel/preset-typescript”]
+ }
同样地如果在项目中用到了 TypeScript
, 就可以使用ts-node/register
来解决,因为 TypeScript
本身支持 ES Module
转换成 CJS
, 所以支持了 TypeScript
后就不需要使用 babel
来转换了。(这里假设使用了 TypeScript
的默认配置)
npm install ts-node typescript --save-dev
// .mocharc.js
require('ts-node/register');
Mocha
自身支持浏览器和 Node
端测试,为了在浏览器端测试我们需要写一个 html, 里面使用