一、Jest 简介
- 优势: 速度快、API简单、配置简单
- 前置: Jest 不支持 ES Module 语法,需要安装 babel
npm install -D @babel/core @babel/preset-env
复制代码
.babelrc
{
"presets": [
[
"@babel/preset-env", {
"targets": {
"node": "current"
}
}
]
]
}
复制代码
jest 在运行前会检查是否安装 babel,如果安装了会去取 .babelrc 文件,结合 babel 将代码进行转化,运行转化后的代码。 3. jest 默认配置
npx jest --init
复制代码
- jest 模式
- jest --watchAll:当发现测试文件变动,将所有测试文件重新跑一遍
- jest --watch:需要和
git
结合使用,会比较现有文件和 commit 的文件的差异,只测试差异文件
二、Jest 匹配器
常见匹配器
- toBe
- toEqual:判断对象内容是否相等
- toMatchObject:expect(obj).toMatchObject(o),期望 o 中包含 obj
- toBeNull
- toBeUndefined
- toBeDefinded
- toBeTruthy
- toBeFalsy
- not:用于否定,比如 .not.toBeTruthy()
Number 相关
- toBeGreaterThan(大于) / toBeGreaterThanOrEqual(大于等于)
- toBeCloseTo:用于比较浮点数,近似相等时断言成立
- toBeLessThan / toBeLessThanOrEqual
String 相关
- toMatch:参数可以传字符串或正则
Array Set 相关
- toContain
异常匹配器
- toThrow:
const throwError = () => {
throw new Error('error')
}
it('can throw error', () => {
expect(throwError).toThrow('error') // 判断throw函数可以抛出异常,异常信息为 "error"。也可以写正则
})
复制代码
这里有个小技巧:当我们想忽略掉单个文件中的其他测试用例,只针对一个测试用例做调试的时候,可以加上 .only
it.only('test', () => {
// ...
})
复制代码
但这并不会忽略其他测试文件的测试用例
三、测试异步代码
这里有三个异步方法,对这三个方法进行代码测试,"www.dell-lee.com/react/api/d…" 会返回 {success: true}, "www.dell-lee.com/react/api/4…" 则不存在。
import axios from 'axios'
export function getData1() {
return axios.get('http://www.dell-lee.com/react/api/demo.json')
}
export function getData2(fn) {
axios.get('http://www.dell-lee.com/react/api/demo.json').then(res => {
fn(res)
})
}
export function get404() {
return axios.get('http://www.dell-lee.com/react/api/404.json')
}
复制代码
对于异步代码测试,时机很重要,必须保证我们的测试用例在异步代码走完之后才结束。有以下几种办法:
- done,控制测试用例结束的时机
- 如果函数执行的返回值是 Promise,将这个 Promise return 出去
- async + await
import {getData1, getData2, get404} from './fetchData/fetchData'
it('getData1 方法1', (done) => {
getData1().then(res => {
expect(res.data).toEqual({
success: true
})
done() // 如果不加 done,还没执行到 .then 方法,测试用例已经结束了
})
})
it('getData1 方法2', () => {
return getData1().then(res => {
expect(res.data).toEqual({
success: true
})
})
})
it('getData2 方法2', (done) => {
getData2((res) => {
expect(res.data).toEqual({
success: true
})
done()
})
})
it('getData1 方法3', async () => {
const res = await getData1()
expect(res.data).toEqual({
success: true
})
})
/*********** 重点关注 ***********/
it('get404', (done) => {
expect.assertions(1)
get404().catch(r => {
expect(r.toString()).toMatch('404')
done()
})
})
复制代码
重点讲一下上面的最后一个测试用例,假设我们现在有一个返回的是 404 的接口,我们需要对这个接口测试,期望他返回 404。 我们用 catch 捕获,在 catch 中判断。
但是,假如这个接口返回的不是 404,而是正常返回 200,这个 catch 则不会执行,expect 也不会执行,测试依然是通过的。这不符合我们的预期!所以,我们需要加上 expect.assertions(1)
进行断言:下面一定会执行一个 expect
当然,也可以用 async await 方法进行 404 接口的测试
it('get404 方法3', async () => {
await expect(get404()).rejects.toThrow()
})
复制代码
四、Jest 中的一些钩子函数
- beforeAll:所有用例开始执行前
- beforeEach:每个用例执行前
- afterEach
- afterAll
- describe
前四个钩子使用起来很简单,调用方法如下:
beforeAll(() => {
// ...
})
复制代码
如果测试前后要做一些处理,尽可能写在这些钩子函数中,他能保证一定的执行顺序。
describe 可以用来进行用例分组,为了让我们的测试输出结果更好看,更有层次。 同时,在每个 describe 中都有上面 4 个钩子函数的存在,我们来看看具体的情况:
describe('测试 Button 组件', () => {
beforeAll(...) // 1
beforeEach(...) // 2
afterEach(...) // 3
afterAll(...) // 4
describe('测试 Button 组件的事件', () => {
beforeAll(...) // 5
beforeEach(...) // 6
afterEach(...) // 7
afterAll(...) // 8
it('event1', ()=>{...})
})
})
复制代码
上面钩子函数的执行顺序是:
1 > 5 > 2 > 6 > 3 > 7 > 4 > 8
外部的钩子函数对 describe 内部的用例也生效,执行顺序为:先外部后内部
五、Jest 中的 mock
1. 在 Jest 中 mock 异步方法
前面提到了可以测试异步代码,对于一些接口都能进行请求测试。但假如每一个接口都真的发起请求,那一次测试需要耗费的时间是很多的。 这时候我们可以模拟请求方法,步骤如下:
- mock.js 中导出了我们的请求方法
import axios from 'axios'
export function getData() {
return axios.get('http://www.dell-lee.com/react/api/demo.json')
}
复制代码
- 在 mock.js 的同级目录下建一个 mocks 的文件夹,文件夹内建立对应文件名的文件,这个文件就是导出的方法就是模拟请求的方法 这里我们直接返回一个 Promise,把假数据 resolve 出去
export function getData() {
return Promise.resolve({
success: true
})
}
复制代码
- 测试用例部分: 这里有一个需要注意的坑:jest.mock 不能写在任何钩子函数里,因为钩子函数的执行时机问题,beforeAll 也不行,当钩子函数执行时,没有写在钩子函数里面的代码已经执行了,也就是已经 import 了!
jest.mock('./mock/mock.js') // 用 jest 模拟 mock.js 方法
import {getData} from './mock/mock.js' // 导入 mock.js,但实际上 jest 会导入 __mocks__ 下的 mock.js
test('mock 方法测试', () => {
getData().then(data => {
expect(data).toEqual({
success: true
})
done()
})
})
复制代码
除了上面的这种办法,还能在 jest.config.js 中配置自动开启 mock,这样 jest 会自动去查找当前文件同级有没有 mock 文件夹,里面有没有对应文件
module.exports = {
automock: true
}
复制代码
讲了两种 mock 的方法,还有一种极端情况需要避免 mock:
我们在 mock.js 中定义了一个需要 mock 的 getData 方法,又另外定义了一个不需要 mock 的普通方法,当我们在测试文件导入的时候,需要避免 jest 去 mocks/mock.js 下找这个普通方法,这里需要用 jest 提供的方法导入:
const { regularMethod } = jest.requireActual('./mock/mock.js')
复制代码
2. 用 Jest 操控时间
当我们有如下代码需要测试的时候:
export default (fn) => {
setTimeout(() => {
fn()
}, 3000)
}
复制代码
我们不可能总是去等待定时器,这时候我们要用 Jest 来操作时间!步骤如下:
- 通过
jest.useFakeTimers()
使用 jest “自制的” 定时器,这里放在 beforeEach 里面是因为快进时间可能被调用多次,我希望在每个测试用例里,这个时钟都是初始状态,不会互相影响。 - 执行 timer 函数之后,快进时间 3 秒
jest.advanceTimersByTime(3000)
,这个方法可以调用任意次,快进的时间会叠加。 - 这时候我们已经穿梭到了 3 秒后,expect 也能生效了!
特别说明一下:jest.fn() 生成的是一个函数,这个函数能被监听调用过几次。
import timer from './timer/timer'
beforeEach(() => {
jest.useFakeTimers()
})
it('timer 测试', () => {
const fn = jest.fn()
timer(fn)
jest.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(1)
})
复制代码
3. mock 类
同样的,当我们只关注类的方法是否被调用,而不关心方法调用产生的结果时,可以 mock 类
在 util/util.js 中定义了 Util 类
export class Util {
a() {}
b() {}
}
复制代码
在 util/useUtil 中调用了这个类
import {Util} from './util'
export function useUtil() {
let u = new Util()
u.a()
u.b()
}
复制代码
我们需要测试 u.a 和 u.b 被调用,jest.mock('./util/util')
会将 Util、Util.a、Util.b 都 mock 成 jest.fn
测试用例如下:
jest.mock('./util/util') // mock Util 类
import {Util} from './util/util'
import {useUtil} from './util/uesUtil'
test('util 的实例方法被执行了', () => {
useUtil()
expect(Util).toHaveBeenCalled()
expect(Util.mock.instances[0].a).toHaveBeenCalled()
expect(Util.mock.instances[0].b).toHaveBeenCalled()
})
复制代码
六、结合 Vue组件 进行单元测试
1. 简单用例入门
Vue 提供了 @vue/test-utils 来帮助我们进行单元测试,创建 Vue 项目的时候勾选测试选项会自动帮我们安装。
先来介绍两个常用的挂载方法:
- mount:会将组件以及组件包含的子组件都进行挂载
- shallowMount:浅挂载,只会挂载组件,忽略子组件
再来看一个简单的测试用例:
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
})
expect(wrapper.props('msg')).toBe(msg)
})
})
复制代码
shallowMount 会返回一个 wrapper,这个 wrapper 上面会包含很多帮助我们测试的方法,详见
2. 快照测试
快照测试的意思是,会将组件像拍照一样拍下来,存底。下次运行测试用例的时候,如果组件发生变化,和快照不一样了,就会报错。
测试用例写法如下: 第一次测试会保存 wrapper 的快照,第二次会比较当前 wrapper 和快照的区别
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
})
expect(wrapper).toMatchSnapshot()
})
})
复制代码
我们再来看看快照长什么样子:
可以看到,快照实际保存的就是组件渲染之后的 html 部分,css 部分没有保存,在元素上绑定的 @click 等一些事件也不会保存, 所以快照适合进行 DOM 节点是否变化的测试。当快照发生变化时,我们可以在终端按 u
进行更新快照
3. 覆盖率测试
覆盖率测试是对测试完全程度的一个评估,测试覆盖到的业务代码越多,覆盖率越高。
在 jest.config.js 中我们可以设置 collectCoverageFrom
,来设置需要进行覆盖率测试的文件,这里我们测试一下所有的 .vue
文件,忽略 node_modules
下所有文件。
要注意,在 Vue 中配置 jest,参考文档
然后添加一条 script 命令,就能进行测试了:
"test:unit": "vue-cli-service test:unit --coverage"
复制代码
执行命令会生成 coverage
文件夹,Icov-report/index.html
里会可视化展示我们的测试覆盖率
七、写在最后
1. 单元测试 or 集成测试?
就拿 shallowMount
来说,这个 api 就很适合单元测试,单元测试不关注单元之间的联系,对每个单元进行独立测试,
这也使得它代码量大,测试间过于独立。在进行一些函数库的测试,各个函数比较独立的时候,就很适合单元测试。
在进行一些业务组件测试时,需要关注组件间的联系,比较适合用集成测试。
2. TDD or BDD?
TDD:测试驱动开发,先写测试用例,然后根据用例写代码,比较关注代码本身。如下:
describe('input 输入回车,向外触发事件,data 中的 inputValue 被赋值', () => {
const wrapper = shallowMount(TodoList)
const inputEle = wrapper.find('input').at(0)
const inputContent = '用户输入内容'
inputEle.setValue(inputContent)
// expect:add 事件被 emit
except(wrapper.emitted().add).toBeTruthy()
// expect:data 中的 inputValue 被赋值为 inputContent
except(wrapper.vm.inputValue).toBe(inputContent)
})
复制代码
TDD 关注代码内部如何实现,关注事件是否触发?属性是否设置?data 数据是否被更新?
BDD:用户行为驱动开发,先写完业务代码,然后站在用户的角度去测试功能,不关注代码实现过程,只是通过模拟用户操作测试功能。
比如下面这个用例:
describe('TodoList 测试', () => {
it(`
1. 用户在 header 输入框输入内容
2. 键盘回车
3. 列表项增加一项,内容为用户输入内容
`, () => {
// 挂载 TodoList 组件
const wrapper = mount(TodoList)
// 模拟用户输入
const inputEle = wrapper.find('input').at(0)
const inputContent = '用户输入内容'
inputEle.setValue(inputContent)
// 模拟触发的事件
inputEle.trigger('content')
inputEle.trigger('keyup.enter')
// expect:列表项增加对应内容
const listItems = wrapper.find('.list-item')
expect(listItems.length).toBe(1) // 增加 1 项
expect(listItems.at(0).text()).toContain(inputContent) // 增加 1 项
})
})
复制代码
参考: