网上有很多vue-test-utils + jest 的相关文章,大体上可以分为两个流派:
- 配置搭建派
- 特殊问题派
通观起来总觉得似乎对于真正想要实施单元测试的入门开发人员来说,缺了一环:面对具体场景时,应该如何进行单元测试编码。
这篇文章就根据不同场景,列出一些真实的代码示例。希望能够帮助到想要真正实施单元测试的团队。
mock 实例方法
场景举例
submit方法内,一般会调用校验方法,依赖校验方法的返回 true || false
来判断是否进行实际提交逻辑的执行。
但有时候需要测试的方法内调用的其他方法很难构造,比如校验方法需要传递若干参数,而且所有参数必须合规才能继续执行提交的代码逻辑。那么此时让校验方法直接通过比较好。
当然这引出另外一个话题,我们在进行单元测试的时候是否应该连带引用方法一起测试?
我的看法是,既然是单元测试,那么引用方法不一定必须全部覆盖到,原因如下:
- 在TDD过程中需测试的方法和其中的引用方法不一定是同一个人负责的
- 单元测试应该仅为当前测试方法的逻辑是否正确负责,如提交方法需要考虑的是提交的各种成功或者失败场景,而校验方法应该自行通过自己的单元测试代码来确保校验本身是否正确
因此在这种场景下,我们对于特别复杂的引用方法可以考虑进行mock。
代码如下
jest.spyOn(wrapper2.vm, 'validate').mockImplementation(() => {return true})
wrapper2.vm.handleSubmit() // 再直接调用提交方法的话,校验方法就会直接返回成功,以便我们直接对提交内部的方法进行测试
对于特定字段的断言
场景举例
有时候在逻辑代码中一个方法的调用,其参数可能非常庞大,但是对于某种测试场景,我们只需要判断参数中是否含有某几个特定字段或者值。
比如我们调用一个方法,其代码如下
function fn () {
...
let params = {
key1: value1,
key2: value2,
key3: value3,
...
keyn: valuen
}
...
request.get(params)
}
在进行单元测试编写中,我们需要校验的是某种场景下,params
必须含有 keyJ = J 的参数值。此时,我们不需要对整个params对象进行断言
let params = {
key1: value1,
key2: value2,
key3: value3,
...
keyn: valuen
}
// 上面完全手动构建了完整的参数对象,不要这样做
expect(request.get).toBeCalledWith(params)
如果只关心一个大对象其中的某个字段的值,不要像上面这样编写测试代码,请参考下面的测试代码示例
expect(request.get).toBeCalledWith(expect.objectContaining({
key3: 42 // 假设我们构建的测试场景只需要特别关注该场景下的key3 值必须为 42
}))
关于refs的处理
在业务代码中含有调用当前组件refs引用自组件,并调用他们方法的时候要怎么处理呢?
考虑如下场景
methods: {
save: () => {
this.$refs[name].validate((valid) => {
if (valid) {
......
}
})
}
}
我们应该如何为save
方法编写测试代码?
如果使用vue test utils的shallowMount
方法,实际上不会在测试中实例化嵌套组件。比如上面这段代码,这里引用到的refs指向的是Form组件。如果直接在测试中执行wrapper.vm.save(),会报错显示调用了undefined 的 validate 方法。
这时候需要在shallowMount的时候通过stubs属性来模拟加载一个嵌入的组件
const Form = {
render: jest.fn(),
methods: {
validate: (cb) => {cb(true)}
}
}
wrapper = shallowMount(indexTip, {
localVue,
store,
propsData: {},
stubs: {Form}
})
先声明这个需要stub的Form组件。这个新声明的组件只用来模拟测试中可能需要的行为,以避免引起undefined的异常,所以只需要声明必要的方法即可。
- render方法直接声明为jest.fn(),因为不需要实际渲染出任何html
- methods里边声明了validate方法,在测试代码中会实际调用这里的声明。注意stub的方法声明无论如何都会返回true。这里依然还是按照上面提及的原则,单元测试仅测试与当前函数相关的逻辑
此外。在shallowMount
的参数中,使用stubs属性内声明自定义的Form。
这样,再次调用wrapper.vm.save 方法的时候,就不会报validate方法未定义了。
注意:业务码中调用的 refs.xxxname,其中 xxxnames 为模板中组件元素的ref名。但stub的时候需要声明的是组件的名称。比如上面的例子,业务代码中refname为xxxname,组件名为Form。这时候需要stub模拟的是Form,而不是xxxname。
Parent组件模拟
与上面的场景相反,这次我们讨论如果业务代码中有关于parent组件的调用应该如何编写测试代码。
this.$parent.getList()
虽然我认为直接调用parent的方法不是一种良好的实践,但是谁让vue提供了这样的能力呢,你就不能保证没有人会这么用。对于这种场景的测试代码,原理跟上面一个场景是类似的,只是在shallowMount
的调用中有一点不同,我们看具体代码
const Parent = {
data: () => ({
val: true
}),
methods: {
getList: () => {} // 当然如果业务代码中需要依赖getList方法的返回,也可以在这里return result
},
template: '<div />'
}
// 接下来在shallowMount的时候声明父组件即可
wrapper = shallowMount(indexTip, {
parentComponent: Parent, // 声明父组件为上面边定义的Parent
localVue,
store,
propsData: {}
})
mock异步请求
在测试中,经常会碰到需要针对不同异步请求的结果编写测试代码的情况。
比如接口返回 code: 0 的时候做什么处理,code: 1的时候做什么处理,甚至是请求在网络失败的情况下做什么处理
function fn (xxx) {
return axios.get(url, {id: xxx})
.then(res => {
if (res.code === 10000) {
this.list = res.list
} else {
this.fail(res.errmsg)
}
})
.catch(err => {
this.toast(err.message)
})
}
如上代码,我们要测试的函数叫做fn,其中调用了一个异步请求,那么我们在测试代码中需要获取到这个异步Promise对象才能对其内部逻辑编写测试代码,因此fn函数需要返回这个Promise对象。
测试代码如下
it('getMarkerList test case', async () => {
await wrapper.vm.fn(xxx)
expect(wrapper.vm.list).toBe([...somelist]) // 断言此时vm.list 已被赋值成功
})
进一步思考,既然是单元测试,是否不应该依赖接口的返回,比如我们需要测试没有网络连接的情况下前端代码的逻辑处理,总不能在持续集成过程中突然拔网线吧。
所以我们还需使用jest提供的mock promise能力
对Promise对象返回值的mock
上面介绍了mock Promise对象的方法,可以让我们在没有实际进行网络调用的情况下就触发相应的处理逻辑。
但大多数情况下,还要更加细分地对不同代码分支做断言。
比如
if (res.code === 10000)
...
else if (res.code === 10002) {
if (res.hasSelected === true)
...
else {
...
}
}
因此,jest提供了快捷的方法来进行不同返回值的模拟
axios.get.mockResolvedValue({code: 10000, list: [1, 2, 3]}) // 模拟成功的状态
await wrapper.vm.fn()
expect(wrapper.vm.list.length).toBe(3) // 断言fn内部promise调用成功时,list属性应该是一个三个元素的数组
axios.get.mockResolvedValue({code: 10001, msg: 'id not fount'}) // 模拟没有找到该id的对应记录
await wrapper.vm.fn()
expect(wrapper.vm.fail).toBeCalledWith('id not found') // 断言fn此场景下,fail方法被调用且参数为 ‘id not found’
axios.get.mockRejectedValue('networkError') // reject 的情况
await wrapper.vm.getLouDong()
expect(wrapper.vm.toast).toBeCalledWith('networkError')
模拟setTimeout
业务代码中,可能会在方法内部执行setTimeout,过一定时间之后,再进行某些操作,比如
submit () {
this.toast('清空中')
setTimeout(() => {
this.loaded = []
}, 300)
}
这个场景可能是出于用户体验的考虑。
那么如果按照常规的方法,我们在执行了getNewList方法之后就马上去进行断言,则测试肯定是无法通过的。
针对这种情况,jest提供了一个有用的功能,useFakeTimers 。
在测试代码中只需要声明jest.useFakeTimers 然后再调用相应方法,让假的计时器向前或者向后若干毫秒即可,看代码
it('submit test case', () => {
jest.useFakeTimers()
wrapper.vm.submit()
jest.advanceTimersByTime(350)
expect(wrapper.vm.loaded.length).toBe(0)
})
模拟window.location
在业务代码中经常会碰到需要使用当前URL来进行判断的场景,但在jest中我们不能直接对window.location进行赋值来模拟这个场景,如何做到?
比如我们需要根据 location.search
中包含特定的参数值来执行不同的代码分支。
可以按照两个步骤来进行模拟:
- 自定义window对象,覆盖jsdom中的window对象
- 通过defineProperty对window.location对象进行赋值
global.window = Object.create(window);
const url = "http://dummy.com?foo=bar";
Object.defineProperty(window, 'location', {
value: {
href: url,
search: '?foo=bar'
}
});
expect(global.location.search.indexOf('foo') > -1).toBeTruthy()
注意:由于覆盖了全局变量location, 因此要注意其只在某一个测试用例中起作用,否则会导致其他测试用例失败。