背景:单元测试是什么

测试代码块的可行性通常都使用单元测试。单元测试指检查与验证软件中最小可测试单元。对于单元测试中单元定义,一般来说要根据实际情况判定其具体含义。
例如JS中单元指一个函数,Java中单元指一个类,GUI中单元指一个窗口等。简而言之,单元就是人为规定最小的被测功能模块。单元测试是软件开发时要进行的最低级别测试活动,独立单元将在与程序其他部分隔离的情况下进行测试。
近几年前端技术高速发展,系统功能变得越来越复杂,这对前端工程化的能力提出更高要求,听到工程化的第一反应必然是高质量的代码设计与高质量的代码实现。如何确保这些环节的稳定,那单元测试就必须得应用起来了。
单元测试的四大特性就完美诠释了其重要作用。

像一些大型前端项目,例如react、vue、babel、webpack等都会接入单元测试,可见单元测试对于这些明星项目来说是多重要的。因为这些大型前端项目需处理大规模的产品及其频繁迭代的功能,这种可持续化的迭代方式迫使它们必须引入自动化测试。进一步看,单元测试有助于增强整体质量与减少运维成本。
目前主流的前端单元测试框架主要有jest与mocha,但更推荐使用jest, 因为jest与mocha从多方面对比都有更明显的优势。

jest作为一款优秀的前端单元测试框架,有着简单易用、高性能、易配置和多功能的特性,内置监控、断言、快照、模拟、覆盖率等功能,这些功能都能开箱即用,因此是作为类库测试的首选工具。

expect与test

单元测试有两个很重要的概念函数,分别是expect()与test()。expect()表示期望得到的运行结果,简称期望结果;test()表示测试结果是否通过预期,简称通过状态。以下从一个简单示例了解这两个函数的工作原理。
声明一个简单的功能函数Sum(),用于累加入参。

1
2
3
4
5
6
js
复制代码function Sum(...vals) {
return vals.reduce((t, v) => t + v, 0);
}

Sum(1, 2, 3); // 6

若Sum()不小心写错把累加写成累乘,当直接在业务代码中使用该函数就会带来无法预期的Bug,需对该函数进行自动化测试,确保无问题再打包为bundle文件供业务组件调用,这样就保障该函数的准确性。
执行以下代码若发现未报错,则表示代码运行结果符合预期。这就是自动化测试最原始的雏形,是一个最简单的单元测试的测试用例。

1
2
3
4
5
6
7
js
复制代码const result = Sum(1, 2, 3);
const expect = 6;

if (result !== expect) {
throw new Error(`期望是${expect},结果是${result}`);
}

把代码改造下变成以下形式,是不是感觉更简洁?

1
2
3
js
复制代码expect(Sum(1, 2, 3)).toBe(6); // 是否等于9
expect(Sum(1, 2, 3)).toBe(9); // 是否等于10

expect()的实现原理也很简单,入参一个值再返回一个toBe(),当然也可返回更多自定义的期望函数。

1
2
3
4
5
6
7
8
9
10
js
复制代码function expect(result) {
return {
toBe(expect) {
if (result !== expect) {
throw new Error(`期望是${expect},结果是${result}`);
}
}
};
}

运行上述两行expect().toBe()代码,结果第二行代码报错。虽然实现了expect(),但报错内容始终一样,不知具体哪个函数出现问题。为了强化expect()的功能,需对其做进一步改良,若在expect()外部再包装一层函数就可传递更多信息进来。

1
2
3
4
5
6
7
js
复制代码test("期望结果是6", () => {
expect(Sum(1, 2, 3)).toBe(6);
});
test("期望结果是9", () => {
expect(Sum(1, 2, 3)).toBe(9);
});

上述封装既能得到运行结果是否符合期望,又能得到详细的自定义测试描述。

1
2
3
4
5
6
7
8
9
js
复制代码function test(desc, fn) {
try {
fn && fn();
console.log(`${desc} 通过测试`);
} catch {
console.log(`${desc} 未通过测试`);
}
}
自动化测试

从上述分析可知,自动化测试其实就是编写一些测试函数,通过测试函数运行业务代码,判断实际结果与期望结果是否相符,是则通过,否则不通过,整个过程都是通过预设脚本自动化处理。
上述expect()与test()都是主流的前端单元测试框架的内置函数,其语法完全一样,它们在不同前端单元测试框架中的实现原理也大同小异。

方案:基于Jest为类库编写测试用例

以第15章开发的工具库为例,通过一步一步上手jest,为bruce-us的每个工具函数加入单元测试的测试用例。

安装

因为使用typescript编码,所以安装jest时也需把typescript相关依赖一起安装。

1
2
bash
复制代码npm i -D @types/jest jest ts-jest

在根目录中创建jest.config.js文件,用于配置jest测试配置。jest整体配置简洁明了,可查看Jest官网,主要用到的配置选项包括preset与testEnvironment。preset表示预设模板,使用安装好的ts-jest模板;testEnvironment表示测试环境,可选web/node。
ts-jest为jest与typescript环境中的单元测试提供类型检查支持与预处理。

1
2
3
4
5
js
复制代码module.exports = {
preset: "ts-jest",
testEnvironment: "node"
};

在package.json中指定scripts,加入测试命令。–no-cache表示每次启动测试脚本不使用缓存;–watchAlls表示监听所有单元测试,若发生更新则重新执行脚本。

1
2
3
4
5
6
json
复制代码{
"scripts": {
"test": "jest --no-cache --watchAll"
}
}

在tsconfig.json中指定types,加入@types/jest。@types/jest提供了expect()与test()。

1
2
3
4
5
6
7
8
9
10
11
json
复制代码{
"compilerOptions": {
"types": [
"@types/jest"
]
},
"exclude": [
"jest.config.js"
]
}
单元测试

在根目录中创建test文件夹。test文件夹内部的目录结构可参照src文件夹,保持源码与测试脚本的目录结构一样,方便后续迭代与维护。单元测试的测试用例使用xyz.spec.ts的方式命名。因为文件众多,那些非重要的文件就不展示了。另外src文件夹中的index.ts、node.ts和web.ts三种文件是供rollup识别入口,因此无需加入对应测试用例文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
txt
复制代码bruce-us
├─ src
│ ├─ common
│ │ ├─ array.ts
│ │ ├─ boolean.ts
│ │ ├─ date.ts
│ │ ├─ function.ts
│ │ ├─ number.ts
│ │ ├─ object.ts
│ │ ├─ regexp.ts
│ │ └─ string.ts
│ ├─ node
│ │ ├─ fs.ts
│ │ ├─ os.ts
│ │ ├─ process.ts
│ │ └─ type.ts
│ └─ web
│ ├─ cookie.ts
│ ├─ dom.ts
│ ├─ function.ts
│ ├─ storage.ts
│ ├─ type.ts
│ └─ url.ts
├─ test
│ ├─ common
│ │ ├─ array.spec.ts
│ │ ├─ boolean.spec.ts
│ │ ├─ date.spec.ts
│ │ ├─ function.spec.ts
│ │ ├─ number.spec.ts
│ │ ├─ object.spec.ts
│ │ ├─ regexp.spec.ts
│ │ └─ string.spec.ts
│ ├─ node
│ │ ├─ fs.spec.ts
│ │ ├─ os.spec.ts
│ │ ├─ process.spec.ts
│ │ └─ type.spec.ts
| └─ web
| ├─ cookie.spec.ts
| ├─ dom.spec.ts
| ├─ function.spec.ts
| ├─ storage.spec.ts
| ├─ type.spec.ts
| └─ url.spec.ts

先将上述Sum()走一次测试用例。在test文件夹中创建index.spec.ts文件,加入以下内容。打开CMD工具,执行npm run test,输出以下信息表示测试通过。

1
2
3
4
5
6
7
8
ts
复制代码function Sum(...vals: number[]): number {
return vals.reduce((t, v) => t + v, 0);
}

test("期望结果是6", () => {
expect(Sum(1, 2, 3)).toBe(6);
});


加入以下代码,测试脚本会重新执行,输出以下信息表示测试不通过。第二段expect().toBe()测试不通过,期待结果是9,运行结果是6。当修正期待结果就会自动通过测试了。

1
2
3
4
ts
复制代码test("期望结果是9", () => {
expect(Sum(1, 2, 3)).toBe(9);
});


通过上述操作说明jest已被部署到bruce-us中了。因为工具函数众多,因此选取一个工具函数为例,后续有时间可一起完善bruce-us的测试用例。

代码覆盖测试

上述单元测试只不过是验证运行结果是否符合预期,着重于结果。若产生一个运行结果的过程可能存在多个分支,例如以下加强版的Sum(),总共有3个分支,Sum(1, 2, 3)只满足其中一个分支,验证完毕也只占整个函数的33.33%,剩下66.66%的代码还未能验证,那就不能百分百认为该单元测试成功通过。

1
2
3
4
5
6
7
8
9
10
ts
复制代码function Sum(...vals: number[]): number {
if (vals.length === 0) {
return -1;
} else if (vals.length === 1) {
return 0;
} else {
return vals.reduce((t, v) => t + v, 0);
}
}

若要将整个Sum()验证完毕,必须将所有if-else语句验证一遍,当全部满足条件才能百分百认为该单元测试成功通过。这就引入一个测试概念,代码覆盖测试。
代码覆盖测试指程序源码被测试的比例与程度的所得比例。代码覆盖测试生成的指标称为代码覆盖率,它作为一个指导性指标,可在一定程度上反应测试的完备程度,是软件质量度量的重要手段。100%覆盖率的代码并不意味着100%无Bug,代码覆盖率作为质量目标无任何意义,应把它作为一种发现未被测试覆盖的代码的检查手段。简而言之,代码覆盖测试更注重测试过程,而测试结果只是测试过程的一个表现。
jest本身内置代码覆盖测试,改良上述配置就能运行代码覆盖测试了。修改jest.config.js的配置,追加coverage相关配置。修改package.json中scripts的test,追加–coverage。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js
复制代码module.exports = {
coverageDirectory: "coverage",
coverageProvider: "v8",
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100
}
},
preset: "ts-jest",
testEnvironment: "node"
};
1
2
3
4
5
6
json
复制代码{
"scripts": {
"test": "jest --no-cache --watchAll --coverage"
}
}

改动test/index.spec.ts的代码,因为代码改动较大就不贴出来了。测试脚本会重新执行,输出以下信息表示测试通过,但覆盖率未完全通过。

这是一个关于测试覆盖率的说明,从中可知四个测试用例都通过,但覆盖率只有76.19%,不通过的部分集中在Branch。那这些带有%的参数表示什么含义?

参数 说明 描述
%Stmts 语句覆盖率 是否每个语句都执行
%Branch 分支覆盖率 是否每个条件都执行
%Funcs 函数覆盖率 是否每个函数都调用
%Lines 行覆盖率 是否每行代码都执行

其中%Stmts与%Lines可能有些歧义,有缩写代码或压缩代码的情况下,行覆盖率的颗粒度可能大于语句覆盖率,因为可允许一行代码包括多条语句。

1
2
3
4
ts
复制代码function Sum(...vals: number[]): number {
if (vals.length === 0) { return -1; } else if (vals.length === 1) { return 0; } else { return vals.reduce((t, v) => t + v, 0); }
}

除了在控制台输出图表信息,还会生成一个coverage文件夹,点击index.html就会打开一个详细的测试报告,可根据测试报告的详细信息完善单元测试的细节。

使用:认识常见匹配器

上述代码都有用到expect()、test()和toBe()三个函数,它们组合起来表示一个测试用例中运行结果匹配期待结果,检验是否符合某种匹配状态。该匹配状态又称匹配器,可能是值相等、值全等、满足范围值等。
toBe()是一个使用频率很高的匹配器,除了它,jest还提供一些很好用的匹配器。

toBe()

检查对象是否全等某值,类似===。

1
2
3
4
js
复制代码test("值是否相等3", () => {
expect(1 + 2).toBe(3); // 通过
});
toBeLessThan()

检查对象是否小于某值,类似<。

1
2
3
4
js
复制代码test("值是否小于3", () => {
expect(1 + 2).toBeLessThan(3); // 不通过
});
toBeGreaterThan()

检查对象是否大于某值,类似>。

1
2
3
4
js
复制代码test("值是否大于3", () => {
expect(1 + 2).toBeGreaterThan(3); // 不通过
});
toBeLessThanOrEqual()

检查对象是否小于等于某值,类似<=。

1
2
3
4
js
复制代码test("值是否小于等于3", () => {
expect(1 + 2).toBeLessThanOrEqual(3); // 通过
});
toBeGreaterThanOrEqual()

检查对象是否大于等于某值,类似>=。

1
2
3
4
js
复制代码test("值是否大于等于3", () => {
expect(1 + 2).toBeGreaterThanOrEqual(3); // 通过
});
toBeCloseTo()

检查对象是否约等于某值,类似≈。

1
2
3
4
5
js
复制代码test("0.1+0.2是否约等于0.3", () => {
expect(0.1 + 0.2).toBe(0.3); // 不通过
expect(0.1 + 0.2).toBeCloseTo(0.3); // 通过
});
toEqual()

测试两个对象的值是否相等,只对比值,不对比引用地址。该函数用在引用类型中更佳,例如数组与对象。

1
2
3
4
5
6
js
复制代码test("两数组的内容是否相等", () => {
const arr1 = [0, 1, 2];
const arr2 = [0, 1, 2];
expect(arr1).toEqual(arr2); // 通过
});
toBeUndefined()

检查对象是否为undefined。

1
2
3
4
5
js
复制代码test("值是否为undefined", () => {
const val = undefined;
expect(val).toBeUndefined(); // 通过
});
toBeNull()

检查对象是否为null。

1
2
3
4
5
js
复制代码test("值是否为null", () => {
const val = null;
expect(val).toBeNull(); // 通过
});
toBeTruthy()

检查对象转换为布尔后是否为true。

1
2
3
4
5
6
7
8
js
复制代码test("转换值是否为true", () => {
expect(undefined).toBeTruthy(); // 不通过
expect(null).toBeTruthy(); // 不通过
expect("").toBeTruthy(); // 不通过
expect(0).toBeTruthy(); // 不通过
expect(false).toBeTruthy(); // 不通过
});
toBeFalsy()

检查对象转换为布尔后是否为false。

1
2
3
4
5
6
7
8
js
复制代码test("转换值是否为false", () => {
expect(undefined).toBeFalsy(); // 通过
expect(null).toBeFalsy(); // 通过
expect("").toBeFalsy(); // 通过
expect(0).toBeFalsy(); // 通过
expect(false).toBeFalsy(); // 通过
});
toMatch()

检查对象是否包括字符串或匹配正则,类似字符串的includes()与match()。

1
2
3
4
5
js
复制代码test("值是否被匹配", () => {
expect("https://yangzw.vip").toMatch("yangzw"); // 通过
expect("https://yangzw.vip").toMatch(/^https/); // 通过
});
toContain()

检查对象是否被数组包括,类似数组的includes()。

1
2
3
4
5
js
复制代码test("值是否被包括", () => {
const list = [0, 1, 2];
expect(list).toContain(1); // 通过
});

基本上掌握上述函数就能应付很多需求,当然想了解更多期望函数,可查看Jest预期函数