JavaScript单元测试框架调研以及基本使用

简介

单元测试:关注应用中每个零部件的正常运转,防止后续修改影响之前的组件。
功能测试:确保其整体表现符合预期,关注能否让用户正常使用。
整合测试:确保单独运行正常的零部件整合到一起之后依然能正常运行。

开发人员主要关注单元测试,作为开发中的反馈。

单元测试关注的是验证一个模块以及一段代码的执行效果是否是和预期结果一致。可能有人会觉得我有时间写这个都有时间去写一个新的模块了,确实是,但是你会通过本次分享改变你对单元测试态度,当你代码量逐渐递增,它能够帮助你很快的定位问题以及将问题定位到具体的细节,甚至能够做到让你的代码完全没有问题。当然,在你重构你的代码时你会感觉到如此便捷!

单元测试类型

TDD(Test-Driven Development)

测试驱动开发,原理是在开发功能代码之前,先编写单元测试用例代码,通过测试代码来确定用来编写什么样的代码。

BDD(Behavior Driven Development)

行为驱动开发,主要是鼓励项目中的开发者、QA以及非技术人员之间的协作,从用户需求出发,强调系统行为。

类型比较

OK,说了两者的概念,反正我是不明白,搜了很多资料也是看的云里雾里,单单就这两个东西足够写一篇论文了,但是这个不是今天的重点,直接来看这两种的代码怎么写来决定使用什么类型不就行了,对比两种代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// TDD
suite('Array', function() {
setup(function() {
});

test('equal -1 when index beyond array length', function() {
assert.equal(-1, [1,2,3].indexOf(4));
});
});

// BDD
describe('Array', function() {
before(function() {
});

it('should return -1 when no such index', function() {
[1,2,3].indexOf(4).should.equal(-1);
});
});

对比这两种语法,自然我选择BDD,因为看着BDD它的语法更符合我们的思考方式,毕竟它显示的更加语义化一些。

单元测试框架

至此,我们自然要选择一套成熟的框架,因为毕竟前人已经做了大量的工作去做这个事情,我们要做的就是选择一套适合自己的测试框架。那框架具体做了什么事,我们希望框架要做的就是简单,快速执行,清晰的错误报告,框架那么多到底选择哪个,我们简单分析一波。

首先我们要选择的自然是目前流行的,社区活跃,使用比较广泛的,能够解决我们目前使用问题的,根据这个要素筛选出来的框架有jasmine、Mocha、Jest,那么从这三种来看,我们到底应该使用哪一种呢,我们就具体的来进行一下对比。

三种框架对比

首先说明一下名词

断言:用于判断结果是否符合预期。有些框架需要单独的断言库。

1
2
3
4
// 断言
if(fun() === 'result'){
// 验证成功操作
}

仿真测试:就是模拟软件的真实使用环境,软件配置到真实的使用状态进行的测试

框架 jasmine Mocha Jest
github star数 14.2k 17.2k 23.7k
断言
仿真
快照
厂商 Facebook
配置 较多
生成展示测试结果
生成测试覆盖率报告

对这三种框架的看法:

jasmine会提供你所需要的几乎所有功能,开箱即用,但是这个框架看上去要稍微老一些对比其它两个框架,但是有时候这也不是一件坏事,因为其它框架遇到的问题可能在这个框架上已经被解决了。

Mocha的可扩展性要强很多,但是这也意味着你要去进行更多的配置,当然这是存在学习成本的,但是却是最灵活的。

jest基于jasmine已经做了大量的修改并且添加了很多新的特性,上面来看jest更具优势,并且jest拥有很多易于理解的文档来帮助学习,开箱即用,功能全面,具体的这几种框架代码写起来是怎样的,来感受一下。

点击这里可以看到具体的对比信息

三大框架代码编写对比

要测试的代码

1
2
3
4
5
6
7
// 创建Math.js
var Math = {
add(a, b) {
return a + b
}
}
module.exports = Math

jasmine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// jasmine
var math = require('../Math')
describe('Math', function() {
var firstNum
var secondNum
beforeEach(function() {
firstNum = 1
secondNum = 2
})
it('should add two num', function() {
var result = math.add(firstNum, secondNum)
expect(result).toEqual(firstNum + secondNum)
})
})

Jest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Jest
var math = require('../Math')
describe('Math', function() {
var firstNum
var secondNum
beforeEach(function() {
firstNum = 1
secondNum = 2
})
it('should add two num', function() {
var result = math.add(firstNum, secondNum)
expect(result).toEqual(firstNum + secondNum)
})
})

Mocha

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Mocha
var assert = require('assert'); // nodejs 内建断言
var math = require('../Math');
describe("Math", function() {
var firstNum;
var secondNum;
beforeEach(function() {
firstNum = 1;
secondNum = 2;
});
it("should add two num", function() {
var result = math.add(firstNum, secondNum);
assert.equal(result, firstNum + secondNum);
});
});

从代码书写方式以及每个框架的缺点和有点来看,jest确实是我们的最佳选择,下面就基本讲一下jest的实例教程!

jest简单入门教程

jest能够在bable、typescript、node、react、angular、vue以及更多的js中使用

安装

使用npm就可以快速安装

1
2
3
npm install --save-dev jest
// 或者使用全局安装
npm install -g jest

安装完成之后我们可以测试一下

我们可以首先新建一个sum.js文件暴露出一个两个数相加的方法

1
2
3
4
5
6
7
8
// sum.js
let funObj = {
sum(a, b) {
return a + b
}
}

module.exports = funObj

然后创建一个名字为sum.test.js,根据其名字也可以看出这是我们的实际测试文件,包含我们的测试代码

1
2
3
4
5
6
// sum.test.js
const funObj = require('./sum')

test('测试两数相加函数', () => {
expect(funObj.sum(1, 2)).toBe(3)
})

使用npm init创建一个package.json的文件,添加npm run test的命令,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
// package.json
{
"name": "jest",
"version": "1.0.0",
"description": "",
"main": "sum.js",
"scripts": {
"test": "jest"
},
"author": "",
"license": "ISC"
}

这样你可以直接在命令行使用npm run test来进行测试了,我们运行这行命令,可以看到终端上已经显示出来测试结果了

简单测试结果

当然,jest也支持babel,webpack,typescript,vscode也有对应的插件,具体的使用方法可以点击查看

匹配器

使用匹配器来实现断言功能,在前面的实例中,我们使用了toBe()匹配器:

1
2
3
4
// sum.test.js
test('测试两数相加函数', () => {
expect(sum(1, 2)).toBe(3)
})

在这段代码中,expect(sum(1, 2))返回了一个期望的对象,.toBe(3)则是一个匹配器,将expect()运行的结果(实际值)和toBe()的参数(期望值)比较,如果不匹配,则打印出对应的错误信息。

下面给大家罗列一些常用的匹配器:

  • toBe使用Object.is判断是否严格相等
  • toEqual递归检查对象或数组的每个字段
  • toBeNull只匹配null
  • toBeUndefined只匹配undefined
  • toBeDefined只匹配非undefined
  • toBeTruthy只匹配真
  • toBeFalsy只匹配假
  • toBeGreaterThan实际值大于期望
  • toBeGreaterThanOrEqual实际值大于或等于期望值
  • toBeLessThan实际值小于期望值
  • toBeLessThanOrEqual实际值小于或等于期望值
  • toBeCloseTo比较浮点数的值,避免误差
  • toMatch正则匹配
  • toContain判断数组中是否包含指定项
  • .toHaveProperty(keyPath, value)判断对象中是否包含指定属性
  • toThrow判断是否抛出指定的异常
  • toBeInstanceOf判断对象是否是某个类的实例,底层使用 instanceof
1
2
3
4
5
6
// toEqual
test('object assignment', () => {
const data = {one: 1};
data['two'] = 2;
expect(data).toEqual({one: 1, two: 2});
});

所有的匹配都可以使用.not取反

1
2
3
4
// sum.test.js
test('测试两数相加函数', () => {
expect(sum(1, 2)).not.toBe(3)
})

异步代码测试

回调

最常见的异步模式就是回调函数了,jest如何测试异步函数呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 要测试的函数
fetchData(callback){
setTimeout(()=>{
callback('time-done')
},1000)
}
// 测试方法
test('测试回调函数', (done) => {
function callback(data){
expect(data).toBe('time-done')
done()
}
fetchData(callback);
})

在测试的时候使用参数done,只有在done函数执行之后才会结束本次的测试,这样就会等待callback函数执行,从而达到异步回调的效果。

Promises

如果你的异步代码返回的是一个Promise对象,那么我们在测试的代码中返回一个Promise,则jest会等待这个Promise来解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 要测试的函数
fetchData()=>{
return new Promise(function(resolve, reject){
setTimeout(()=>{
resolve('promise resolve')
},1000)
})
}
// 测试方法
test('测试promise异步操作', () => {
return fetchData().then(res=>{
expect(res).toBe('promise resolve');
})
})
// rejected状态
test('测试promise异步操作', () => {
return fetchData().catch(e => expect(e).toMatch('error'))
})

.resolves / .rejects

同样是上面的异步函数,可以使用以下方式进行单元测试

1
2
3
4
5
6
7
8
// 测试方法
test('测试promise异步操作', () => {
return expect(fetchData()).resolves.toBe('promise resolve')
})
// rejected状态
test('测试promise异步操作', () => {
return expect(fetchData()).rejects.toMatch('error')
})

.Async/Await

再或者,你可以在测试中使用async/await

1
2
3
4
5
6
7
8
9
10
11
12
13
// 测试方法
test('测试promise异步操作', async () => {
const data = await fetchData();
expect(data).toBe('promise resolve')
})
// rejected状态
test('测试promise异步操作', async () => {
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
})

async 和 await 仅仅只是语法糖,其本身的逻辑与上述使用 Promise 的示例等效

jest钩子

也就是传说中的jest每个测试的生命周期,beforeAll()、afterAll()、beforeEach()、afterEach()

beforeAll()、afterAll()会在所有的测试用例执行前以及执行后执行一次,beforeEach()、afterEach()会在每个测试用例之前和之后执行。

当然,你可以使用describe将测试用例进行分组,在describe块中的钩子函数只作用于块内的测试用例:

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
beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
beforeAll(() => console.log('2 - beforeAll'));
afterAll(() => console.log('2 - afterAll'));
beforeEach(() => console.log('2 - beforeEach'));
afterEach(() => console.log('2 - afterEach'));
test('', () => console.log('2 - test'));
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

需要注意的是,jest执行顶级的beforeEach会在describe块内的beforeEach之前执行。

jest会优先执行describe快内的操作,等块内的操作全部执行完毕,然后再从头开始执行测试用例,因此我们在初始化数据以及销毁数据的时候应该放在钩子函数中运行,而不是写在describe块内,看下面的代码执行顺利就能了解为什么要放在钩子函数中执行:

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
describe('outer', () => {
console.log('describe outer-a');

describe('describe inner 1', () => {
console.log('describe inner 1');
test('test 1', () => {
console.log('test for describe inner 1');
expect(true).toEqual(true);
});
});

console.log('describe outer-b');

test('test 1', () => {
console.log('test for describe outer');
expect(true).toEqual(true);
});

describe('describe inner 2', () => {
console.log('describe inner 2');
test('test for describe inner 2', () => {
console.log('test for describe inner 2');
expect(false).toEqual(false);
});
});

console.log('describe outer-c');
});

// describe outer-a
// describe inner 1
// describe outer-b
// describe inner 2
// describe outer-c
// test for describe inner 1
// test for describe outer
// test for describe inner 2

当我们执行整个测试用例的时候,发现测试失败,然后我们要检查的第一件事就是当我们仅仅在运行这一条测试用例的时候是否还会失败,遇到这种情况,我们只需要将test命令改为test.only:

1
2
3
4
5
6
7
8
// 只会执行这个
test.only('this will be the only test that runs', () => {
expect(true).toBe(false);
});
// 不会执行这个
test('this test will not run', () => {
expect('A').toBe('A');
});

Mock方法

jest提供的mock方法可以更加方便的让你去测试数据请求的返回值,jest内置了mock机制,提供了很多mock方式。

Mock函数使用

函数mock很简单,使用jest.fn()即可,mock函数有一个定义的.mock属性,保存着一些函数的调用信息,.mock还会追踪每次调用的this值。举例看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 要测试的函数
function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index]);
}
}
// mock测试用例
const mockCallback = jest.fn(x => 42 + x);
forEach([0, 1], mockCallback);
// 这个mockCallback函数被调用的两次
expect(mockCallback.mock.calls.length).toBe(2);
// 这个函数第一次被调用的时候传递的参数为0
expect(mockCallback.mock.calls[0][0]).toBe(0);
// 这个函数第一次被调用的时候传递的参数为1
expect(mockCallback.mock.calls[1][0]).toBe(1);
// 这个函数第一次被调用返回的结果是42
expect(mockCallback.mock.results[0].value).toBe(42);

Mock函数返回值

mock函数的也可以用于在测试期间将测试值注入代码中

1
2
3
4
5
6
7
8
9
10
11
12
const filterTestFn = jest.fn();

// Make the mock return `true` for the first call,
// and `false` for the second call
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);

const result = [11, 12].filter(filterTestFn);

console.log(result);
// > [11]
console.log(filterTestFn.mock.calls);
// > [ [11], [12] ]

Mocking Modules

当然我们更多的时候使用axios去请求数据,假如现在我们有一个获取用户信息的api

1
2
3
4
5
6
7
8
9
10
// users.js
import axios from 'axios';

class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data);
}
}

export default Users;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
const users = [{name: 'Bob'}];
const resp = {data: users};
axios.get.mockResolvedValue(resp);

// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))

return Users.all().then(resp => expect(resp.data).toEqual(users));
});

结语

当然,你能想象到的一些测试功能都已经涵盖,但是毕竟不是专业的测试,而且写这些测试的东西也是需要花费更多的时间去处理,许多新的功能官方网站基本都有。地址:https://jestjs.io/docs/zh-Hans/getting-started,它的理念就是能够让开发人员快速上手,使用少量配置或者默认配置就可。

总结

所以,看完这些,前端到底值不值得去跑单元测试,当然写单元测试在我看来肯定会有如下好处:

  1. 保证代码质量和实现的完整度
  2. 提升开发效率,这点可能就有疑问了,我在开发的时候写测试用例还能提升开发效率?这里在你写测试用例的时候能够提前发现代码中的问题这是肯定的,此时你修复的速度肯定比QA给你提出来对应的bug修复速度要快很多
  3. 这是最重要的一点,便于项目的维护,后续如果新增什么内容或工具也跑这个单元测试就能够避免代码执行时发生问题,即使进行重构或者人员发生变化也能够保证功能实现

当然,凡事都有两面性,除了会增加我们自己一些工作量,而且不是所有项目都适合引用单元测试,毕竟要维护这套东西也是需要成本的,所以对于一些需求很频繁,复用性低的页面,比如一些活动页面去专门写这个东西是得不偿失的,但是在一些我们常用的工具函数,公共方法上使用确实是有保障的,一个是维护成本低,而且多处使用能够保证其质量。

这就是我对我们进行单元测试的一些见解,大家如果有什么问题可以提出来,不对的地方也欢迎大家指正。

小伙子别走,如果帮到您你懂得☺
0%