1. 为什么要做前端测试
首先,我认为前端测试并不是所有项目都必须的,因为写测试代码是需要要花费一定时间的,当项目比较简单的时候,花时间写测试代码可能反而会影响开发效率,但是需要指出的是,我们前端开发过程中,编写测试代码,有以下这些好处:
- 更快的发现bug,让绝大多数bug在开发阶段发现解决,提高产品质量
- 比起写注释,单元测试可能是更好的选择,通过运行测试代码,观察输入和输出,有时会比注释更能让别人理解你的代码(当然,重要的注释还是要写的。。。)
- 有利于重构,如果一个项目的测试代码写的比较完善,重构过程中改动时可以迅速的通过测试代码是否通过来检查重构是否正确,大大提高重构效率
- 编写测试代码的过程,往往可以让我们深入思考业务流程,让我们的代码写的更完善和规范。
2. 什么是TDD
和BDD
2.1 TDD
与单元测试
2.1.1 什么是TDD
所谓TDD(Test Driven Development)
,即测试驱动开发,简单的来说就是先编写测试代码,然后以使得所有测试代码都通过为目的,编写逻辑代码,是一种以测试来驱动开发过程的开发模式。
2.1.2 单元测试
所谓单元测试(unit testing)
,是指对软件中的最小可测试单元进行检查和验证。通俗的讲,在前端,单元可以理解为一个独立的模块文件,单元测试就是对这样一个模块文件的测试。
对于一个独立的模块(ES6
模块),因为功能相对独立,所以我们可以首先编写测试代码,然后根据测试代码指导编写逻辑代码。
所以提到TDD
,这里的测试一般是指单元测试
2.2 BDD
与集成测试
2.2.1 什么是BDD
所谓BDD(Behavior Driven Development)
,即行为驱动开发,简单的来说就是先编写业务逻辑代码,然后以使得所有业务逻辑按照预期结果执行为目的,编写测试代码,是一种以用户行为来驱动开发过程的开发模式。
2.2.2 集成测试
所谓集成测试(Integration Testing)
,是指对软件中的所有模块按照设计要求进行组装为完整系统后,进行检查和验证。通俗的讲,在前端,集成测试可以理解为对多个模块实现的一个交互完整的交互流程进行测试。
对于多个模块(ES6
模块)组成的系统,需要首先将交互行为完善,才能按照预期行为编写测试代码。
所以提到BDD
,这里的测试一般是指集成测试。
3. Jest
使用---引言部分
3.1 我们如何写测试代码?
如果我们之前从来没有接触过测试代码,那让我们自己来设计测试代码的写法,会是什么样呢?我们需要让测试代码简单,通俗易懂,比如我们举个例子如下:
export function findMax (arr) {
return Math.max(...arr)
}
我们写了一个很简单的获取数组最大值的函数(你可能觉得这样写并不严谨,但我们为了简单,暂时假设输入是非空数值数组),如果对这个函数写一个测试其正确与否的测试程序,它可能构思是这样的:
我期望 findMax([1, 2, 4, 3]) 的结果是 4
进一步转化为英文:
I expect findMax([1, 2, 4, 3]) to be 4
用程序性的语言表示,expect
作为一个函数,为它传入想要测试的对象(findMax
函数),把输出结果也做一层封装toBe(4)
:
expect(findMax([1, 2, 4, 3])).toBe(4) // 有内味了
更进一步,我们想要增加一些描述性信息,比如
测试findMax函数,我期望 findMax([1, 2, 4, 3]) 的结果是 4
这个时候,我们可以再做一层封装,定义一个test
函数,它有两个参数,第一个参数是一些描述性信息(这里是 测试findMax
函数),第二个参数是一个函数,函数里可以执行我们上面的逻辑,如下:
test('findMax函数输出', () => {
expect(findMax([1, 2, 4, 3])).toBe(4) // 内味更深了
})
3.2 简单的自己实现测试代码
我们自己可以简单的实现下test
函数和expect
函数,因为存在链式调用toBe
,所以expect
函数最终应该返回一个具有toBe
方法的对象,如下:
// expect函数
function expect (value) {
return {
toBe: (toBeValue) => {
if (toBeValue === value) {
console.log('测试通过!')
} else {
throw new Error('测试不通过!')
}
}
}
}
// test函数
function test (msg, func) {
try {
func()
console.log(`${msg}测试过程无异常!`)
} catch (err) {
console.error(`${msg}测试过程出错!`)
}
}
我们的测试方法,只是对数字做了简单的测试,实际项目中,需要测试的类型是很多的,这个时候我们就可以选择一些比较成熟的测试框架。一个简单好用,功能强大的工具就呈现在我们面前,它就是jest
。
4. Jest
使用---入门部分
4.1 准备工作
我们这部分的例子主要是为了介绍jest
最基本的用法,首先我们先简单的搭建一下演示环境。
第一步,使用npm init -y
(我的node
版本是v12.14.1
,npm
版本是v6.13.4
)初始化项目
第二步,安装jest
npm install --save-dev jest
(安装可以参考官网)
第三步,运行npx jest --init
命令,生成一份jest的配置文件jest.config.js
,我的选择如下

第四步,运行npm i babel-jest @babel/core @babel/preset-env -D
安装babel
,并且配置.babelrc
如下
{
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
};
第五步,根目录下建立src
文件夹,新建两个文件basic.js
和basic.test.js
第六步,package.json
增加一条命令:
"scripts": {
"test": "jest"
},
以上六步完成后,我们的项目结构应该如下图

4.2 最基本的jest
用法
接下来我们采用TDD
加单元测试的方式来学习jest基本用法:
首先,在basic.js
里定义两个工具函数
// 1. 寻找最大值
export function findMax (arr) {
}
// 2. 给定一个整数数组 nums 和一个目标值 target,在该数组中找出和为目标值的那 两个 整数,如果存在,返回true,否则返回false
export function twoSum (nums, target) {
};
既然是TDD
,我们首先编写测试代码,在这个过程中,我们逐步学习各种jest
的基本用法。测试代码在basic.test.js
文件中编写:
import { findMax, twoSum } from './basic'
// 期望findMax([2, 6, 3])执行后结果为6
test('findMax([2, 6, 3])', () => {
expect(findMax([2, 6, 3])).toBe(6)
})
// 期望twoSum([2, 3, 4, 6], 10)执行后结果为true
test('twoSum([2, 3, 4, 6], 10)', () => {
expect(twoSum([2, 3, 4, 6], 10)).toBe(true)
})
从上面代码,我们可以看到,jest
测试代码的写法,和之前我们自己写的是一样的(当然啦,本来就是模仿jest
的),此时我们运行npm test
命令,观察命令行输出如下:

Expected
代表期望函数执行的结果,也就是toBe
里的那个值,Received
代表实际执行函数得到的结果,因为我们还没有编写业务代码,所以Received
都是undefined
,最后显示一共1
个测试文件(Test Suites
)和2
条测试代码,它们都测试失败了。
接下来我们完善basic.js
里的逻辑
// 1. 寻找最大值
export function findMax (arr) {
return Math.max(...arr)
}
// 2. 给定一个整数数组 nums 和一个目标值 target,在该数组中找出和为目标值的那 两个 整数,如果存在,返回true,否则返回false
export function twoSum (nums, target) {
for (let i = 0; i < nums.length - 1; i++) {
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === target) {
return true
}
}
}
return false
};
然后我们再次运行npm test
,得到结果如下

TDD
和单元测试的开发过程。
4.3 更多的jest
matchers
像是上小节,在expect
函数后面跟着的判断结果的toBe
在jest
中被称为matcher
,我们这一小节就来介绍另外一些常用的matchers
4.3.1 toEqual
我们首先改造下刚刚的twoSum
函数,让它返回找到的两个数的索引数组(leetcode
第一题)
// 2. 给定一个整数数组 nums 和一个目标值 target,在该数组中找出和为目标值的那 两个 整数,
// 并返回他们的数组下标(假设每种输入只会对应一个答案,数组中同一个元素不能使用两遍)。
export function twoSum (nums, target) {
for (let i = 0; i < nums.length - 1; i++) {
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === target) {
return [i, j]
}
}
}
return []
};
接下来测试代码部分我们只保留对twoSum
函数的测试,并同步修改测试代码
test('twoSum([2, 3, 4, 6], 10)', () => {
expect(twoSum([2, 3, 4, 6], 10)).toBe([2, 3])
})
我们的期望是函数执行的结果是[2, 3]
这样的数组,看起来没问题,运行npm test

我们发现并没有通过测试,这是因为,toBe
可以判断基本类型数据,但是对于数组,对象这样的引用类型是没办法判断的,这个时候,我们就需要使用toEqual
test('twoSum([2, 3, 4, 6], 10)', () => {
expect(twoSum([2, 3, 4, 6], 10)).toEqual([2, 3])
})
改成toEqual
之后,测试代码就成功了

4.3.2 判断逻辑真假相关的一些matchers
这部分内容很简单,也比较多,所以直接在代码里注释说明:
test('变量a是否为null', () => {
const a = null
expect(a).toBeNull()
})
test('变量a是否为undefined', () => {
const a = undefined
expect(a).toBeUndefined()
})
test('变量a是否为defined', () => {
const a = null
expect(a).toBeDefined()
})
test('变量a是否为true', () => {
const a = 1
expect(a).toBeTruthy()
})
test('变量a是否为false', () => {
const a = 0
expect(a).toBeFalsy()
})
测试结果如下:

4.3.3 not
修饰符
很简单,not
就是对matcher
的否定
test('test not', () => {
const temp = 10
expect(temp).not.toBe(11)
expect(temp).not.toBeFalsy()
expect(temp).toBeTruthy()
})
测试结果如下:

4.3.4 判断数字相关的一些matchers
这部分内容很简单,也比较多,所以直接在代码里注释说明:
// 判断数num是否大于某个数
test('toBeGreaterThan', () => {
const num = 10
expect(num).toBeGreaterThan(7)
})
// 判断数num是否大于等于某个数
test('toBeGreaterThanOrEqual', () => {
const num = 10
expect(num).toBeGreaterThanOrEqual(10)
})
// 判断数num是否小于某个数
test('toBeLessThan', () => {
const num = 10
expect(num).toBeLessThan(20)
})
// 判断数num是否小于等于某个数
test('toBeLessThanOrEqual', () => {
const num = 10
expect(num).toBeLessThanOrEqual(10)
expect(num).toBeLessThanOrEqual(20)
})
测试结果如下:

上面介绍的都是整数判断,十分简单,但是如果是浮点数相关的判断,会不太一样,比如,我们知道0.1 + 0.2 = 0.3
这个式子在数学中没有问题,但是在计算机中,由于精度问题,这个0.1 + 0.2
结果如果用toBe
结果并不是准确的0.3
,如果我们想要判断浮点数的相等,在jest
中提供了一个toBeCloseTo
的matcher
可以解决:
test('toBe', () => {
const sum = 0.1 + 0.2
expect(sum).toBe(0.3)
})
test('toBeCloseTo', () => {
const sum = 0.1 + 0.2
expect(sum).toBeCloseTo(0.3)
})
上面的测试结果如下:

4.3.5 字符串匹配toMatch
这个matcher
就是用来判断字符串是否和toMatch
提供的模式匹配,如下:
// 字符串相关
test('toMatch', () => {
const str = 'Lebron James'
expect(str).toMatch(/Ja/)
expect(str).toMatch('Ja')
})

4.3.6 数组,集合相关的matchers
可以使用toContain
判断数组或者集合是否包含某个元素,使用toHaveLength
判断数组的长度,代码如下:
test('Array Set matchers', () => {
const arr = ['Kobe', 'James', 'Curry']
const set = new Set(arr)
expect(arr).toContain('Kobe')
expect(set).toContain('Curry')
expect(arr).toHaveLength(3)
})

4.3.7 异常相关的matchers
使用toThrow
来判断抛出的异常是否符合预期:
function throwError () {
throw new Error('this is an error!!')
}
test('toThrow', () => {
expect(throwError).toThrow(/this is an error/)
})

5. jest
进阶用法
5.1 分组测试与勾子函数
所谓分组测试,核心在于,将不同的测试进行分组,再结合勾子函数(生命周期函数),完成不同分组的定制化测试,以满足测试过程重的复杂需求。
我们首先在src
下新建两个文件hook.js
和hook.test.js
,这一部分代码在这两个文件中完成,首先直接给出hook.js
代码
// hook.js
export default class Count {
constructor () {
this.count = 2
}
increase () {
this.count ++
}
decrease () {
this.count --
}
double () {
this.count *= this.count
}
half () {
this.count /= this.count
}
}
现在呢,我们想要对Count
类的四个方法单独测试,数据互相不影响,当然我们可以自己去直接实例化4
个对象,不过,jest
给了我们更优雅的写法---分组,我们使用describe
函数分组,如下:
describe('分别测试Count的4个方法', () => {
test('测试increase', () => {
})
test('测试decrease', () => {
})
test('测试double', () => {
})
test('测试half', () => {
})
})
这样我们就使用describe
函数配合test
将测试分为了四组,接下来,为了能更好的控制每个test
组,我们就要用到jest
的勾子函数。
我们这里要介绍的是jest
里的四个勾子函数beforeEach,beforeAll,afterEach,afterAll
。
顾名思义,beforeEach
是在每一个test函数执行之前,会被调用;afterEach
则是在每一个test函数执行之后调用;beforeAll
是在所有test函数执行之前调用;afterAll
则是在所有test函数执行之后调用。我们可以看下面这个例子:
import Count from "./hook"
describe('分别测试Count的4个方法', () => {
let count
beforeAll(() => {
console.log('before all tests!')
})
beforeEach(() => {
console.log('before each test!')
count = new Count()
})
afterAll(() => {
console.log('after all tests!')
})
afterEach(() => {
console.log('after each test!')
})
test('测试increase', () => {
count.increase()
console.log(count.count)
})
test('测试decrease', () => {
count.decrease()
console.log(count.count)
})
test('测试double', () => {
count.double()
console.log(count.count)
})
test('测试half', () => {
count.half()
console.log(count.count)
})
})
输出的结果如图:

可以看到,我们在每个test执行之前,beforeEach
里面重新实例化了count
,所以每一次的count是不同的。合理的使用勾子函数,我们可以更好的定制测试。
5.2 异步代码测试之定时器
在我们前端开发过程中,由于javascript
是单线程的,异步编程是我们开发人员经常要做的工作,而异步代码也是最容易出错的地方,对异步代码逻辑进行测试,是很有必要的,这一节将对jest
如何进行异步测试,做一个详细的介绍。
5.2.1 从最简单的setTimeout
开始
我们首先新建timeout.js,timeout.test.js
文件,timeout.js
文件代码很简单:
export default (fn) => {
setTimeout(() => {
fn()
}, 2000)
}
我们现在的目标就是去测试,写的这个函数,是不是会像我们预期的那样,传入一个函数作为参数(简单为主,没有做参数校验),2s
后,执行这个函数。
我们的测试代码(timeout.test.js
)如下:
import timeout from './timeout'
test('测试timer', () => {
timeout(() => {
expect(2+2).toBe(4)
})
})
如果我们运行这段测试代码,一定是会通过的,但是,这真的代表我们写在timeout
里的方法测试通过了吗?我们在timout.js
中打印输出一段文字
export default (fn) => {
setTimeout(() => {
fn()
console.log('this is timeout!')
}, 2000)
}
然后我们运行测试代码(npm test timeout.test
这样只运行一个文件),你会发现,什么打印内容都没有输出:

jest
在运行测试代码,执行test
方法时,从函数内部第一行执行到最后一行,当执行逻辑走到代码块最后一行时,没有异常就会返回测试成功,这个过程中不会去等待异步代码的执行结果
,所以我们这样的测试方法,不管setTimeout
里怎么实现,回调函数里怎么实现,都不会执行回调函数内部的逻辑。
如果我们需要测试代码在真正执行了定时器里的异步逻辑后,才返回测试结果,我们需要给test
方法的回调函数传入一个done
参数,并在test
方法内异步执行的代码中调用这个done
方法,这样,test
方法会等到done
所在的代码块内容执行完毕后才返回测试结果:
import timeout from './timeout'
test('测试timer', (done) => {
timeout(() => {
expect(2+2).toBe(4)
done()
})
})

done
参数之后,得到了预期的结果,打印输出了内容,证明我们回调函数内的代码执行了。
5.2.2 使用fakeTimers
提高测试效率
我们上一小节介绍了如何去测试写在定时器里异步代码的执行,但这里存在一个问题,比如,我们的定时器可能需要几十秒才执行内部逻辑(这虽然很少见,主要看业务需求),我们的测试代码也会很久才会返回结果,这无疑大大的降低了开发测试效率。
jest
也考虑到了这一点,让我们可以使用fakeTimers
模拟真实的定时器。这个fakeTimers
在遇到定时器时,允许我们立即跳过定时器等待时间,执行内部逻辑,比如,对于刚刚的timeout.test
,我们的测试代码可以做如下改变:
- 首先,我们使用
jest.fn()
生成一个jest
提供的用来测试的函数,这样我们之后回调函数不需要自己去写一个 - 其次,我们使用
jest.useFakeTimers()
方法启动fakeTimers
- 最后,我们可以通过
jest.advanceTimersByTime()
方法,参数传入毫秒时间,jest
会立即跳过这个时间值,还可以通过toHaveBeenCalledTimes()
这个mathcer
来测试函数的调用次数。
完整代码如下:
test('测试timer', () => {
jest.useFakeTimers()
const fn = jest.fn()
timeout(fn)
// 时间快进2秒
jest.advanceTimersByTime(2000)
expect(fn).toHaveBeenCalledTimes(1)
})

测试timer(12ms)
对比之前的测试timer(2021ms)
,可以看到,定时器的延迟时间,确实被跳过了,这提高了测试开发效率。
5.2.3 更复杂的定时器场景
经过前面两节的介绍,对于定时器这种异步场景的测试代码编写,实际上我们已经掌握核心内容,这一节,我们去探讨一个更为复杂的场景,那就是定时器嵌套。
我们首先改造timout
里的代码如下:
export default (fn) => {
setTimeout(() => {
fn()
console.log('this is timeout outside!')
setTimeout(() => {
fn()
console.log('this is timeout inside!')
}, 3000)
}, 2000)
}
按照上一小节的写法,我们的测试代码可以改造为:
test('测试timer', () => {
jest.useFakeTimers()
const fn = jest.fn()
timeout(fn)
// 时间快进2秒
jest.advanceTimersByTime(2000)
expect(fn).toHaveBeenCalledTimes(1)
// 时间快进3秒
jest.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(2)
})
其实也很简单,就是在第一次2s
后,再过3s
后执行第二个定时器,此时fn
被调用了2
次,所以我们只需要加上最后两行代码就可以了。执行结果如下:

我们可以看到,两条打印结果都输出了。但是目前的这种实现不是很好,试想一下,如果这里面的定时器嵌套比较多,或者我们不清楚有几个定时器,就会比较麻烦。jest
为这种情况提供了两个有用的方法:
jest.runAllTimers()
这个方法就如同它的名字一样,调用之后,会执行所有定时器,我们的代码可以改造如下:
test('测试timer', () => {
jest.useFakeTimers()
const fn = jest.fn()
timeout(fn)
jest.runAllTimers()
expect(fn).toHaveBeenCalledTimes(2)
})

jest
依旧快速的跳过了定时器等待时间。
jest.runOnlyPendingTimers()
这个方法的意思是,只执行当前正在等待的所有定时器,这个例子中,只有外层定时器是正在等待的,内层定时器只有在外层定时器执行时,才处于等待状态,我们改造测试代码如下:
test('测试timer', () => {
jest.useFakeTimers()
const fn = jest.fn()
timeout(fn)
jest.runOnlyPendingTimers()
expect(fn).toHaveBeenCalledTimes(1)
})

jest.runOnlyPendingTimers()
即可。
关于上述内容,有一点需要说明:
如果我们编写了多个test
函数,它们都使用fakeTimers
,一定要在beforeEach
勾子中每次都调用jest.useFakeTimers()
,否则,多个test
函数中的fakeTimers
会是同一个,将会互相干扰,产生不符合预期的执行结果
beforeEach(() => {
jest.useFakeTimers()
})
5.3 异步代码测试之数据请求(promise/async await)
5.3.1 传统的promise
写法
在我们前端开发中,通过请求后端接口获取数据是很重要的一个流程,这一节主要就是介绍这个过程中如何编写测试代码(实际上这里的很多内容,之前介绍定时器的章节是有介绍过的)
为了简单起见,我们使用axios(npm i axios)
这个成熟的库来辅助我们做数据请求。首先新建request.js, request.test.js
这两个文件,在request.js
文件请求一个免费api:
import axios from 'axios'
export const request = fn => {
axios.get('https://jsonplaceholder.typicode.com/todos/1').then(res => {
fn(res)
console.log(res)
})
}
我们在request.test.js
中,为了保证异步代码执行完毕后结束测试,和之前介绍的一样,在test
的回调函数中传入done
参数,在回调函数里执行done()
,代码如下:
import { request } from './request'
test('测试request', (done) => {
request(data => {
expect(data.data).toEqual({
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
})
done()
})
})

我们现在改造一下request.js
的代码,让它返回一个promise
:
export const request = () => {
return axios.get('https://jsonplaceholder.typicode.com/todos/1')
}
为了测试上述代码,我们request.test.js
也要做一定的修改:
test('测试request', () => {
return request().then(data => {
expect(data.data).toEqual({
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
})
})
})
注意,上面的写法不需要传入done
参数了,但是,需要我们使用return
返回,如果不写return
,那jest
执行test
函数时,将不会等待promise
返回,这样的话,测试结果输出时,then
方法将不会执行。我们可以尝试以下两种写法(改变"completed": true
),第一种写法测试不会通过,第二种测试是可以通过的(因为promise并没有返回结果):
// 第一种
test('测试request', () => {
return request().then(data => {
expect(data.data).toEqual({
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": true
})
})
})

// 第二种
test('测试request', () => {
request().then(data => {
expect(data.data).toEqual({
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": true
})
})
})

上面的测试代码,我们也可以写成下面的形式:
test('测试request', () => {
return expect(request()).resolves.toMatchObject({
data: {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
})
})
注意,resolves
将返回request
方法执行后所有返回内容,我们使用toMatchObject
这个matcher
,当传入的对象能够匹配到request
方法执行后返回对象的一部分键值对,测试就会通过。
5.3.2 使用async await
语法糖
async await
本质上就是promise
链式调用的语法糖,我们上一小节最后的测试代码,如果使用async await
的方式去书写,如下:
// 写法一
test('测试request', async () => {
const res = await request()
expect(res.data).toEqual({
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
})
})
// 写法二
test('测试request', async () => {
await expect(request()).resolves.toMatchObject({
data: {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
})
})

5.3.3 对于请求出现错误的测试
在我们实际项目中,需要对这种接口请求做错误处理,同样,也需要对异常情况编写测试代码。
我们首先在request.js
增加一个方法:
export const requestErr = fn => {
return axios.get('https://jsonplaceholder.typicode.com/sda')
}
这里请求一个不存在的接口地址,会返回404
,于是我们的的测试代码为:
test('测试request 404', () => {
return requestErr().catch((e) => {
console.log(e.toString())
expect(e.toString().indexOf('404') > -1).toBeTruthy()
})
})

jest
官网的一段说明:

catch
,jest不回去执行catch
里的内容,所以需要我们去写expect.assertions(1)
这句话,代表,期望执行的断言是1次,catch
方法算一次断言,所以,正常情况,由于不会执行catch
,这里会报错(执行了0次断言),当这里报错了,说明我们的代码也按照预期产生了异常。
这种写法目前已经不需要了,详细见removed useless expect.assertions,所以,现在就按照上面那种方式,直接书写,测试通过代表确实如我们预期的产生异常。
同样的,我们还可以使用另一种方式完成异常代码测试:
test('测试request 404', () => {
return expect(requestErr()).rejects.toThrow(/404/)
})
这里的rejects
和上一节的resolves
相互对于,代表执行方法产生的错误对象,这个错误对象抛出404
异常(toThrow(/404/)
)
我们同样可以使用async await
语法糖书写异常测试的代码:
test('测试request 404', async () => {
await expect(requestErr()).rejects.toThrow(/404/)
})
// 或者可以使用try catch语句写的更完整
test('测试request 404', async () => {
try {
await requestErr()
} catch (e) {
expect(e.toString()).toBe('Error: Request failed with status code 404')
}
})
5.4 在测试中模拟(mock
)数据
我们首先新建mock.js, mock.test.js
文件
5.4.1 使用jest.fn()
模拟函数
首先在mock.js
写一个函数:
export const run = fn => {
return fn('this is run!')
}
实际上之前我们已经使用过jest.fn()
了,这里我们更进一步的学习它。
- 首先,我们的
fn()
函数可以接受一个函数作为参数,这个函数就是我们想要jest.fn()
为我们mock
的函数,我们编写mock.test.js
:
test('测试 jest.fn()', () => {
const fn = jest.fn(() => {
return 'this is mock fn 1'
})
})
- 其次,
jest.fn()
可以初始化时候不传入参数,然后通过调用生成的mock
函数的mockImplementation
或者mockImplementationOnce
方法来改变mock函数内容,这两个方法的区别是,mockImplementationOnce
只会改变要mock
的函数一次:
test('测试 jest.fn()', () => {
const func = jest.fn()
func.mockImplementation(() => {
return 'this is mock fn 1'
})
func.mockImplementationOnce(() => {
return 'this is mock fn 2'
})
const a = run(func)
const b = run(func)
const c = run(func)
console.log(a)
console.log(b)
console.log(c)
})

this is mock fn 2
,之后都是this is mock fn 1
同样的,我们可以使用mock
函数的mockReturnValue
和mockReturnValueOnce(一次)
方法来改变函数的返回值:
test('测试 jest.fn()', () => {
const func = jest.fn()
func.mockImplementation(() => {
return 'this is mock fn 1'
})
func.mockImplementationOnce(() => {
return 'this is mock fn 2'
})
func.mockReturnValue('this is mock fn 3')
func.mockReturnValueOnce('this is mock fn 4')
.mockReturnValueOnce('this is mock fn 5')
.mockReturnValueOnce('this is mock fn 6')
const a = run(func)
const b = run(func)
const c = run(func)
const d = run(func)
console.log(a)
console.log(b)
console.log(c)
console.log(d)
})

注意到,方法是可以链式调用的,方便多次输出不同的返回值。
- 最后,我们可以使用
toBeCalledWith
这个matcher
来测试函数的传参数是否符合预期:
test('测试 jest.fn()', () => {
const func = jest.fn()
const a = run(func)
expect(func).toBeCalledWith('this is run!')
})

5.4.2 模拟接口中获取的数据
很多时候,我们在前端开发过程中,后端接口还没有提供,我们需要去mock
接口返回的数据。
我们首先在mock.js
中编写一个简单的请求数据的代码:
import axios from 'axios'
export const request = fn => {
return axios.get('https://jsonplaceholder.typicode.com/todos/1')
}
接着,我们在mock.test.js
中,使用jest.mock()
方法模拟axios
,使用mockResolvedValue
和mockResolvedValueOnce
方法模拟返回的数据,同样的,mockResolvedValueOnce
方法只会改变一次返回的数据:
import axios from 'axios'
import { request } from './mock'
jest.mock('axios')
test('测试request', async () => {
axios.get.mockResolvedValueOnce({ data: 'Jordan', position: 'SG' })
axios.get.mockResolvedValue({ data: 'kobe', position: 'SG' })
await request().then((res) => {
expect(res.data).toBe('Jordan')
})
await request().then((res) => {
expect(res.data).toBe('kobe')
})
})

我们使用jest.mock('axios')
来使用jest
去模拟axios
,测试正确的通过了。
5.5 dom
相关测试
dom
相关的测试其实很简单,我们首先新建dom.js, dom.test.js
两个文件,代码如下:
// dom.js
export const generateDiv = () => {
const div = document.createElement('div')
div.className = 'test-div'
document.body.appendChild(div)
}
// dom.test.js
import { generateDiv } from './dom'
test('测试dom操作', () => {
generateDiv()
generateDiv()
generateDiv()
expect(document.getElementsByClassName('test-div').length).toBe(3)
})

这里只有一点要说明,jest
的运行环境是node.js
,这里jest
使用jsdom
来让我们可以书写dom
操作相关的测试逻辑。
5.6 快照(snapshot
)测试
我们如果没有接触过快照测试,可能会觉得这个名字很高大上。所以我们首先新建snapshot.js, shapshot.test.js
来看看快照测试究竟是什么。
在我们的日常开发中,总会写一些配置性的代码,它们大体不会变化,但是也会有小的变更,这样的配置可能如下(snapshot.js
):
export const getConfig = () => {
return {
server: 'https://demo.com',
port: '8080'
}
}
我们的测试代码如下:
import { getConfig } from './snapshot'
test('getConfig测试', () => {
expect(getConfig()).toEqual({
server: 'https://demo.com',
port: '8080'
})
})
这样我们通过了测试。但是,假如后续我们的配置改变了,我就需要同步的去修改测试代码,这样会比较麻烦,从而,jest
为我们引入了快照测试,先上测试代码:
test('getConfig测试', () => {
expect(getConfig()).toMatchSnapshot()
})
我们运行测试代码之后,会在项目根目录下生成一个__snapshots__
文件夹,下面有一个snapshot.test.js.snap
快照文件,文件内容如下:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getConfig测试 1`] = `
Object {
"port": "8080",
"server": "https://demo.com",
}
`;
jest
会在运行toMatchSnapshot()
的时候,首先检查有没有这个快照文件,如果没有,则生成,当我们改动配置内容时,比如把port
改为8090
,再次运行测试代码,测试不通过,结果如下:

npm test snapshot.test -- -u
,就可以自动更新我们的快照文件,测试再次通过,这就让我们不需要每次更改配置文件的时候,手动去同步更新测试代码,提高了测试开发效率:

此时我们的快照文件更新为如下代码:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getConfig测试 1`] = `
Object {
"port": "8090",
"server": "https://demo.com",
}
`;
6. jest
其它一些有用的知识
6.1 让jest
监听文件变化
这个功能很简单,我们只需要运行jest
命令的时候,后面加上--watch
即可,我们在package.json
中新增一条命令:
"scripts": {
"test": "jest",
"test-watch": "jest --watch"
},
在新增完这条命令后,为了能让jest
可以监听文件变化,我们还需要把我们的代码文件变成一个git
仓库,jest
也正式依靠git
的能力实现监听文件变化的,我们运行git init
,接着我们运行npm run test-watch
,在一定时间后,我们开启监听模式,命令行最后几行输出应该是:

这里对watch
模式的几个有用功能做一个简单介绍(也就是图中英文说明):
- 按
a
键运行所有测试代码 - 按
f
键只运行所有失败的测试代码 - 按
p
键按照文件名筛选测试代码(支持正则) - 按
t
键按照测试名筛选测试代码(支持正则) - 按
q
键盘推出watch
模式 - 按
enter
键触发一次测试运行
这些我建议大家自行去尝试,它们都是十分简单好用的功能。
6.2 生成测试覆盖率文件
测试覆盖率,简单来说就是我们业务代码中,编写测试代码的比例,jest
给我们提供了直接生成测试覆盖率文件的方法,也就是运行jest
命令时后面加上--coverage
参数,我们修改package.json
文件如下:
"scripts": {
"test": "jest",
"test-watch": "jest --watch",
"coverage": "jest --coverage"
},
接下来,运行npm run coverage
,我们可以看到命令行输出如下:

coverage
文件夹:

index.html
,如下图:

这里对这个表格项目做一个简单的说明:
-
Statements
是语句覆盖率:表示代码中有多少执行的语句被测试到了 -
Branches
是分支覆盖率:表示代码中有多少if else switch
分支被测试到了 -
Functions
是函数覆盖率:表示代码中被测试到的函数的占比 -
Lines
是行覆盖率:表示代码中被测试到的行数占比
我们可以利用生成的测试覆盖率文件,更好的完善改进我们的测试代码。
6.3 关于jest.config.js
配置文件
我对于学习一个工具的配置文件的建议是,首先按照默认的来,当你需要改变配置的时候,再去查阅官方文档学习,不推荐去死记硬背。
我这里也不会去介绍怎么去配置jest
文件,我们可以通过jest初始化时候默认生成的那个jest.config.js
来学习(有详细注释),也可以在官网中查阅相关的配置参数。
7. 写在最后
由于篇幅原因,不适合再介绍更多的信息,更多的api
相关的信息,建议去查阅官网来学习。
这篇文章个人认为已经把jest
的基础和最核心的内容做了阐述,可能我们开发过程中,使用react(enzyme), vue( @vue/test-utils)
这样的开发框架,使用webpack
这样的工程化工具,在使用jest
的时候,会结合使用一些开源库,我相信学好了jest
本身之后,配置和使用它们都不会有太多困难。
最后,希望这篇文章可以帮助到大家,感谢能看到这里的每一个小伙伴。