本文详解如何在Vue项目中结合Jest与官方Vue-test-utils进行单元测试。内容涵盖环境配置、shallow渲染隔离子组件、DOM与Props验证、自定义事件监听、计算属性测试,以及异步代码、Mock依赖和防抖函数的处理技巧,帮助开发者建立完整的Vue测试体系。
Jest与Vue-test-utils基础配置
介绍
Vue-test-utils是Vue官方提供的单元测试工具库,旨在简化Vue应用的单元测试编写过程。尽管市面上存在多种主流的JavaScript测试运行器,Vue Test Utils均能兼容支持,具有测试运行器无关的特性。
Jest是由Facebook开发的单元测试框架,也是Vue官方推荐的测试运行器之一。
Jest被视为功能最全面的测试运行器,其优势在于配置极简、默认集成JSDOM、内置断言库且命令行体验优秀。不过,测试单文件组件需要预处理器支持。官方提供了vue-jest预处理器以处理大多数单文件组件特性,尽管其功能覆盖度尚未达到vue-loader的100%。
可以理解为,Vue-test-utils在Vue与Jest之间充当桥梁角色,通过暴露特定接口,使开发者能够更便捷地利用Jest为Vue应用编写单元测试。
安装
在使用Vue-cli创建项目模板时,若选择启用单元测试并指定框架,Vue会自动完成Jest的相关配置。
若需在现有项目中后期添加单元测试,首先需安装Jest和Vue Test Utils:
npm install --save-dev jest @vue/test-utils
随后在package.json中定义单元测试脚本。
// package.json
{
"scripts": {
"test": "jest"
}
}
为使Jest能够处理*.vue文件,需安装并配置vue-jest预处理器:
npm install --save-dev vue-jest
接着在jest.conf.js配置文件中进行相应配置。
module.exports = {
moduleFileExtensions: ['js', 'json', 'vue'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
transform: {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
'.*\\.(vue)$': '<rootDir>/node_modules/vue-jest'
},
}
其他具体配置细节可参考官方文档。
完成上述配置后,即可开始编写单元测试代码。
import { mount } from '@vue/test-utils'
import Component from './component'
describe('Component', () => {
test('是一个 Vue 实例', () => {
const wrapper = mount(Component)
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
前述示例中,通过vue-test-utils提供的mount方法挂载组件,从而创建包裹器(Wrapper)和Vue实例。
若不使用vue-test-utils,同样可以手动挂载组件:
import Vue from 'vue';
import Test1 from '@/components/Test1';
const Constructor = Vue.extend(HelloWorld);
const vm = new Constructor().$mount();
执行单元测试的命令如下:
npm run unit
可在命令后追加-- --watch以启动监听模式。
别名配置
在Vue开发中,使用别名是常见做法,可有效避免复杂且易错的相对路径引用:
import Page from '@/components/Test5/Test5'
上述代码中的@即为别名。在使用Vue-cli搭建的项目中,webpack.base.conf.js默认已对@进行了配置:
module.exports = {
...
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': path.join(__dirname, '..', 'src')
}
},
}
同样,在使用Jest时,也需在jest.conf.js配置文件中添加相应配置:
"jest": {
"moduleNameMapper": {
'^@/(.*)$': "<rootDir>/src/$1",
},
...
Shallow Rendering与DOM结构测试
Shallow Rendering(浅渲染)
创建一个App.vue组件:
<template>
<div id="app">
<Page :messages="messages"></Page>
</div>
</template>
<script>
import Page from '@/components/Test1'
export default {
name: 'App',
data() {
return {
messages: ['Hello Jest', 'Hello Vue']
}
},
components: {
Page
}
}
</script>
随后创建一个Test1子组件。
<template>
<div>
<p v-for="message in messages" :key="message">{{message}}</p>
</div>
</template>
<script>
export default {
props: ['messages'],
data() {
return {}
}
}
</script>
针对App.vue编写单元测试文件App.spec.js。
// 从测试实用工具集中导入 `mount()` 方法
import { mount } from 'vue-test-utils';
// 导入你要测试的组件
import App from '@/App';
describe('App.test.js', () => {
let wrapper,
vm;
beforeEach(() => {
wrapper = mount(App);
vm = wrapper.vm;
wrapper.setProps({ messages: ['Cat'] })
});
it('equals messages to ["Cat"]', () => {
expect(vm.messages).toEqual(['Cat'])
});
// 为App的单元测试增加快照(snapshot):
it('has the expected html structure', () => {
expect(vm.$el).toMatchSnapshot()
})
});
执行单元测试后,若测试通过,Jest会在test/__snapshots__/目录下生成快照文件App.spec.js.snap。
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App.test.js has the expected html structure 1`] = `
<div
id="app"
>
<div>
<p>
Cat
</p>
</div>
</div>
`;
通过查看快照可以发现,子组件Test1已被渲染至App中。
这里存在一个问题:单元测试应当以独立单位进行。即在测试App组件时,无需也不应关注其子组件的具体表现,以此保证单元测试的独立性。例如,子组件created钩子函数中的操作可能会引入测试的不确定性。
为解决此问题,Vue-test-utils提供了shallow方法。该方法与mount类似,均创建包含被挂载和渲染的Vue组件的Wrapper,但区别在于shallow创建的是被存根(stubbed)的子组件。
此方法确保在渲染目标组件时,不会同时渲染其子组件,从而避免子组件可能带来的副作用(如HTTP请求等)。
因此,将App.spec.js中的mount方法更改为shallow方法,再次查看快照。
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App.test.js has the expected html structure 1`] = `
<div
id="app"
>
<!---->
</div>
`;
结果显示子组件未被渲染,此时针对App.vue的单元测试已从组件树中完全隔离。
测试DOM结构
通过mount、shallow、find、findAll等方法均可返回包裹器对象,该对象暴露了诸多封装、遍历及查询内部Vue组件实例的便捷方法。
其中,find和findAll方法均接受选择器作为参数。find方法返回匹配选择器的DOM节点或Vue组件的Wrapper;findAll方法返回所有匹配选择器的DOM节点或Vue组件的Wrappers的WrapperArray。
选择器可以是CSS选择器、Vue组件或查找选项对象。
CSS选择器:可匹配任何有效的CSS选择器。
- 标签选择器(div、foo、bar)
- 类选择器(.foo、.bar)
- 特性选择器([foo]、[foo="bar"])
- id选择器(#foo、#bar)
- 伪选择器(div:first-of-type)
- 符合选择器(div > #bar > .foo、div + .foo)
Vue组件:Vue组件本身也是有效的选择器。
查找选项对象:
- Name:可根据组件的name属性选择元素。例如:wrapper.find({ name: 'my-button' })
- Ref:可根据$ref属性选择元素。例如:wrapper.find({ ref: 'myButton' })
基于上述方法,我们可以对DOM结构进行验证:
describe('Test for Test1 Component', () => {
let wrapper,
vm;
beforeEach(() => {
// wrapper = mount(App);
wrapper = shallow(Test1, {
propsData: {
messages: ['bye']
}
});
});
it('is a Test1 component', () => {
// 使用Vue组件选择器
expect(wrapper.is(Test1)).toBe(true);
// 使用CSS选择器
expect(wrapper.is('.outer')).toBe(true);
// 使用CSS选择器
expect(wrapper.contains('p')).toBe(true)
});
});
还可以进一步对DOM结构进行更细致的验证:
// exists():断言 Wrapper 或 WrapperArray 是否存在。
it('不存在img', () = > {
expect(wrapper.findAll('img').exists()).toBeFalsy()
});
// isEmpty():断言 Wrapper 并不包含子节点。
it('MyButton组件不为空', () = > {
expect(wrapper.find(MyButton).isEmpty()).toBeFalsy()
});
// attributes():返回 Wrapper DOM 节点的特性对象
// classes():返回 Wrapper DOM 节点的 class 组成的数组
it('MyButton组件有my-class类', () = > {
expect(wrapper.find(MyButton).attributes().class).toContain('my-button');
expect(wrapper.find(MyButton).classes()).toContain('my-button');
})
样式、Props与自定义事件测试
测试样式
UI样式测试旨在验证样式是否符合设计稿预期。通过样式测试,我们可以感知代码变化带来的UI变更及其是否符合预期。
Inline Style:若样式为内联样式,可使用hasStyle方法进行验证,或使用Jest的Snapshot Testing(快照测试)最为便捷。
// hasStyle:判断是否有对应的内联样式
it('MyButton组件有my-class类', () = > {
expect(wrapper.find(MyButton).hasStyle('padding-top', '10')).toBeTruthy()
})
CSS:属于E2E(端到端)测试范畴。E2E测试将整个系统视为黑盒,仅通过UI暴露给用户来测试应用从头到尾的流程是否符合设计初衷。有专门的E2E测试框架,如流行的nightwatch等,相关介绍可参考专门文章。
测试Props
父组件向子组件传递数据使用Props,而子组件向父组件传递数据则需在子组件中触发父组件的自定义事件。
当测试父组件向子组件传递数据的行为时,我们旨在验证:当传递给子组件特定参数时,子组件是否按预期表现。
在初始化时向子组件传值,可使用propsData方法。
const wrapper = mount(Foo, {
propsData: {
foo: 'bar'
}
})
也可使用setProps方法:
const wrapper = mount(Foo)
wrapper.setProps({ foo: 'bar' })
我们传递给Test1组件的messages为['bye']数组,以此验证其存在性:
beforeEach(() = > {
wrapper = mount(Test1, {
propsData: {
messages: ['bye']
}
});
});
// props:返回 Wrapper vm 的 props 对象。
it('接收到了bye作为Props', () = > {
expect(wrapper.props().messages).toContain('bye')
});
有时需对Props的Type、默认值或通过validator进行的自定义验证进行测试。
props: {
messages: {
type: Array,
required: true,
validator: (messages) = > messages.length > 1,
default () {
return [0, 2]
}
}
},
可通过Vue实例的$options获取包括Props在内的初始化选项:
// vm.$options返回Vue实例的初始化选项
describe('验证Props的各个属性', () = > {
wrapper = mount(Test1, {
propsData: {
messages: ['bye', 'bye', 'bye']
}
});
const messages = wrapper.vm.$options.props.messages;
it('messages is of type array', () = > {
expect(messages.type).toBe(Array)
});
it('messages is required', () = > {
expect(messages.required).toBeTruthy()
});
it('messages has at least length 2', () = > {
expect(messages.validator && messages.validator(['a'])).toBeFalsy();
expect(messages.validator && messages.validator(['a', 'a'])).toBeTruthy();
});
wrapper.destroy()
});
测试自定义事件
自定义事件的测试点至少包括以下两项:
- 测试事件是否被正常触发
- 测试事件触发后的后续行为是否符合预期
具体到Test1组件和MyButton组件:
TEST1组件:
// TEST1
<MyButton class="my-button" style="padding-top: 10px" buttonValue="Me" @add="addCounter"></MyButton>
// 省略一些代码
methods: {
addCounter(value) {
this.count = value
}
},
MyButton组件:
<button @click="increment">Click {{buttonValue}} {{innerCount}}</button>、
// 省略一些代码
data() {
return {
innerCount: 0
}
},
computed: {},
methods: {
increment() {
this.innerCount += 1;
this.$emit('add', this.innerCount)
}
},
测试目的为:
1. 当MyButton组件的按钮被点击后,会触发increment事件 2. 点击事件发生后,Test1组件的addCounter函数会被触发且结果符合预期(即数字递增)
首先为MyButton编写单元测试文件:
describe('Test for MyButton Component', () => {
const wrapper = mount(MyButton);
it('calls increment when click on button', () => {
// 创建mock函数
const mockFn = jest.fn();
// 设置 Wrapper vm 的方法并强制更新。
wrapper.setMethods({
increment: mockFn
});
// 触发按钮的点击事件
wrapper.find('button').trigger('click');
expect(mockFn).toBeCalled();
expect(mockFn).toHaveBeenCalledTimes(1)
})
});
通过setMethods方法使用mock函数代替真实方法,随后即可断言点击按钮后对应方法是否被触发、触发次数及传入参数等。
现已测试点击事件后能触发对应方法,接下来需测试increment方法将触发Test1组件中自定义的add方法。
// increment方法会触发add方法
it('triggers a addCounter event when a handleClick method is called', () = > {
const wrapper = mount(MyButton);
// mock自定义事件
const mockFn1 = jest.fn();
wrapper.vm.$on('add', mockFn1);
// 触发按钮的点击事件
wrapper.find('button').trigger('click');
expect(mockFn1).toBeCalled();
expect(mockFn1).toHaveBeenCalledWith(1);
// 再次触发按钮的点击事件
wrapper.find('button').trigger('click');
expect(mockFn1).toHaveBeenCalledTimes(2);
expect(mockFn1).toHaveBeenCalledWith(2);
})
此处使用了$on方法,将Test1自定义的add事件替换为Mock函数。
对于自定义事件,不能使用trigger方法触发,因为trigger仅用于DOM事件。自定义事件需使用$emit触发,前提是通过find找到MyButton组件。
// $emit 触发自定义事件
describe('验证addCounter是否被触发', () = > {
wrapper = mount(Test1);
it('addCounter Fn should be called', () = > {
const mockFn = jest.fn();
wrapper.setMethods({
'addCounter': mockFn
});
wrapper.find(MyButton).vm.$emit('add', 100);
expect(mockFn).toHaveBeenCalledTimes(1);
});
wrapper.destroy()
});
计算属性、监听器与异步测试
测试计算属性
创建Test2组件,实现功能为使用计算属性将输入框输入的字符翻转:
<template>
<div class="wrapper">
<label for="input">输入:</label>
<input id="input" type="text" v-model="inputValue">
<p>输出:{{outputValue}}</p>
</div>
</template>
<script>
export default {
name: 'Test2',
props: {
needReverse: {
type: Boolean,
default: false
}
},
data() {
return {
inputValue: ''
}
},
computed: {
outputValue () {
return this.needReverse ? ([...this.inputValue]).reverse().join('') : this.inputValue
}
},
methods: {},
components: {}
}
</script>
<style scoped>
.wrapper {
width: 300px;
margin: 0 auto;
text-align: left;
}
</style>
在Test2.spec.js中,可通过wrapper.vm属性访问实例的所有方法和属性。此属性仅存在于Vue组件包裹器中。
describe('Test for Test2 Component', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(Test2);
});
afterEach(() => {
wrapper.destroy()
});
it('returns the string in normal order if reversed property is not true', () => {
wrapper.setProps({needReverse: false});
wrapper.vm.inputValue = 'ok';
expect(wrapper.vm.outputValue).toBe('ok')
});
it('returns the string in normal order if reversed property is not provided', () => {
wrapper.vm.inputValue = 'ok';
expect(wrapper.vm.outputValue).toBe('ok')
});
it('returns the string in reversed order if reversed property is true', () => {
wrapper.setProps({needReverse: true});
wrapper.vm.inputValue = 'ok';
expect(wrapper.vm.outputValue).toBe('ko')
})
});
测试监听器
Vue提供的watch选项提供了一种更通用的响应数据变化的方法。
为Test添加侦听器:
watch: {
inputValue: function(newValue, oldValue) {
if (newValue.trim().length > 0 && newValue !== oldValue) {
this.printNewValue(newValue)
}
}
},
methods: {
printNewValue(value) {
console.log(value)
}
},
为进行测试,首先需在测试前使用jest的spyOn方法mock掉console的log方法,最好在测试结束后通过mockClear方法将其重置,以避免引入无关状态。
describe('Test watch', () = > {
let spy;
beforeEach(() = > {
wrapper = shallow(Test2);
spy = jest.spyOn(console, 'log')
});
afterEach(() = > {
wrapper.destroy();
spy.mockClear()
});
}
随后执行给inputValue赋值,按预期spy方法应被调用。
it('is called with the new value in other cases', () = > {
wrapper.vm.inputValue = 'ok';
expect(spy).toBeCalled()
});
但在执行后发现并非如此,spy并未被调用,原因是:
watch中的方法被Vue推迟到了更新的下一个循环队列中异步执行。若该watch被触发多次,仅会被推送到队列一次。这种缓冲行为可有效去除重复数据造成的不必要性能开销。
因此,当设置inputValue为'ok'后,watch中的方法并未立即执行,而expect却已执行,导致断言失败。
解决方法是将断言放入$nextTick中,在下一个循环队列中执行,同时在expect后执行Jest提供的done()方法,Jest会等待done()方法被执行后再结束测试。
it('is called with the new value in other cases', (done) = > {
wrapper.vm.inputValue = 'ok';
wrapper.vm.$nextTick(() = > {
expect(spy).toBeCalled();
done()
})
});
在测试第二种情况时,由于对inputValue赋值时spy会被执行一次,因此需清除spy状态,才能得出正确预期:
it('is not called with same value', (done) = > {
wrapper.vm.inputValue = 'ok';
wrapper.vm.$nextTick(() = > {
// 清除已发生的状态
spy.mockClear();
wrapper.vm.inputValue = 'ok';
wrapper.vm.$nextTick(() = > {
expect(spy).not.toBeCalled();
done()
})
})
});
测试方法
单元测试的核心之一是测试方法行为是否符合预期。测试时需避免一切依赖,将所有依赖Mock掉。
创建Test3组件,输入问题后,点击按钮,使用axios发送HTTP请求以获取答案。
<template>
<div class="wrapper">
<label for="input">问题:</label>
<input id="input" type="text" v-model="inputValue">
<button @click="getAnswer">click</button>
<p>答案:{{answer}}</p>
<img :src="src">
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'Test3',
data() {
return {
inputValue: 'ok?',
answer: '',
src: ''
}
},
methods: {
getAnswer() {
const URL = 'https://yesno.wtf/api';
return axios.get(URL).then(result => {
if (result && result.data) {
this.answer = result.data.answer;
this.src = result.data.image;
return result
}
}).catch(e => {})
}
}
}
</script>
<style scoped>
.wrapper {
width: 500px;
margin: 0 auto;
text-align: left;
}
</style>
此例中,我们仅关注测试getAnswer方法,其他内容忽略。为测试该方法,需执行以下操作:
- 无需实际调用axios.get方法,需将其Mock掉。
- 需测试是否调用了axios方法(但不实际触发)且返回了一个Promise对象。
- 返回的Promise对象执行了回调函数,设置用户名和头像。
当前需做的就是Mock掉外部依赖。Jest提供了优秀的Mock系统,使我们能轻松Mock所有依赖。前文曾使用jest.spyOn方法和jest.fn方法,但对于上述示例,仅使用这两个方法尚显不足。
现需Mock整个axios模块,使用jest.mock方法即可Mock依赖模块。
jest.mock('dependency-path', implementationFunction)
在Test3.spec.js中,首先将axios中的get方法替换为Mock函数,随后引入相应模块。
jest.mock('axios', () => ({
get: jest.fn()
}));
import { shallow } from 'vue-test-utils';
import Test3 from '@/components/Test3';
import axios from 'axios';
随后测试点击按钮后,axios的get方法是否被调用:
describe('Test for Test3 Component', () => {
let wrapper;
beforeEach(() => {
axios.get.mockClear();
wrapper = shallow(Test3);
});
afterEach(() = > {
wrapper.destroy()
});
// 点击按钮后调用了 getAnswer 方法
it('getAnswer Fn should be called', () => {
const mockFn = jest.fn();
wrapper.setMethods({getAnswer: mockFn});
wrapper.find('button').trigger('click');
expect(mockFn).toBeCalled();
});
// 点击按钮后调用了axios.get方法
it('axios.get Fn should be called', () => {
const URL = 'https://yesno.wtf/api';
wrapper.find('button').trigger('click');
expect(axios.get).toBeCalledWith(URL)
});
});
测试结果发现,虽然Mock函数被调用,但控制台仍报错。原因是Mock的axios.get方法虽被调用,但未返回任何值,导致报错。因此下一步需给get方法返回一个Promise,查看方法能否正确处理返回数据。
jest.fn()接受一个工厂函数作为参数,从而可定义其返回值。
const mockData = {
data: {
answer: 'mock_yes',
image: 'mock.png'
}
};
jest.mock('axios', () => ({
get: jest.fn(() => Promise.resolve(mockData))
}));
getAnswer为异步请求,Jest提供的解决异步代码测试的方法有以下三种:
- 回调函数中使用done()参数
- Promise
- Async/Await
第一种方法是在异步请求的回调函数中使用Jest提供的单参数done(),Jest会等待done()执行结束后再结束测试。
我们使用第二种和第三种方法来测试getAnswer方法的返回值,前提是在方法中返回一个Promise(通常在被测方法中给出返回值会使测试更简单)。Jest会等待Promise解析完成。若Promise被拒绝,则测试自动失败。
// axios.get方法返回值(Promise)
it('Calls get promise result', () = > {
return expect(wrapper.vm.getAnswer()).resolves.toEqual(mockData);
});
或者可使用第三种方法,即使用async和await来测试异步代码:
// 可以用 Async/Await 测试 axios.get 方法返回值
it('Calls get promise result 3', async() = > {
const result = await wrapper.vm.getAnswer();
expect(result).toEqual(mockData)
});
Jest均提供了resolves和rejects方法作为then和catch的语法糖:
it('Calls get promise result 2', () = > {
return wrapper.vm.getAnswer().then(result = > {
expect(result).toEqual(mockData);
})
});
it('Calls get promise result 4', async() = > {
await expect(wrapper.vm.getAnswer()).resolves.toEqual(mockData)
});
Mock依赖与插槽测试
Mock依赖
可创建一个__mocks__文件夹,将Mock文件放入其中,这样便无需在每个测试文件中单独手动Mock模块依赖。
在__mocks__文件夹下创建axios.js文件:
// test/__mocks__/axios.js
const mock = {
get: jest.fn(() => Promise.resolve({
data: {
answer: 'mock_yes',
image: 'mock.png'
}
}))
};
export default mock
如此即可将Test3.spec.js中的jest.mock部分代码移除。Jest会自动在__mocks__文件夹下寻找Mock模块,但需注意,模块的注册和状态会一直被保存。因此,若在Test3.spec.js最后增加一条断言:
// 如果不清除模块状态此条断言会失败
it('Axios should not be called here', () = > {
expect(axios.get).not.toBeCalled()
});
因在beforeEach中添加了axios.get的状态清除语句axios.get.mockClear(),故上述断言会通过,否则将失败。
也可使用resetModules和clearAllMocks来确保每次开始前都重置模块和Mock依赖的状态。
beforeEach(() = > {
wrapper = shallow(Test3);
jest.resetModules();
jest.clearAllMocks();
});
项目中有时需根据需要对不同HTTP请求的数据进行Mock。以MockJS为例,通常每个组件(模块)对应一个Mock文件,并通过index.js导入系统。Jest也可直接将MockJS的数据导入,只需在setup.js中导入MockJS的index.js文件即可。
测试插槽
插槽(slots)用于在组件中插入、分发内容。创建一个使用slots的组件Test4。
// TEST4
<MessageList>
<Message v-for="message in messages" :key="message" :message="message"></Message>
</MessageList>
// MessageList
<ul class="list-messages">
<slot></slot>
</ul>
// Message
<li>{{message}}</li>
在测试slots时,关注点在于slots中的内容是否在组件中出现在正确位置,测试方法与前文介绍的测试DOM结构的方法相同。
具体到示例中,需测试Message组件是否出现在具有list-messages类的ul中。测试时,为将slots传递给MessageList组件,需在MessageList.spec.js中的mount或shallow方法中使用slots属性。
import { mount } from 'vue-test-utils';
import MessageList from '@/components/Test4/MessageList';
describe('Test for MessageList of Test4 Component', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(MessageList, {
slots: {
default: '<div class="fake-msg"></div>'
}
});
});
afterEach(() => {
wrapper.destroy()
});
// 组件中应该通过slots插入了div.fake-msg
it('Messages are inserted in a ul.list-messages element', () => {
const list = wrapper.find('ul.list-messages');
expect(list.contains('div.fake-msg')).toBeTruthy()
})
});
为测试内容是否通过插槽插入组件,伪造了一个div.fake-msg通过slots选项传入MessageList组件,断言组件中应存在该div。
此外,slots选项还可传入组件或数组:
import AnyComponent from 'anycomponent'
mount(MessageList, {
slots: {
default: AnyComponent // or [AnyComponent, AnyComponent]
}
})
此处存在一个问题,例如想测试Message组件是否通过插槽插入MessageList组件中,可将slots选项中传入Message组件。但由于Message组件需传入message作为Props,按前述说明,应这样做:
beforeEach(() = > {
const fakeMessage = mount(Message, {
propsData: {
message: 'test'
}
});
wrapper = mount(MessageList, {
slots: {
default: fakeMessage
}
})
});
对应的断言为:
// 组件中应该通过slots插入了Message,并且传入的文本是test
it('Messages are inserted in a ul.list-messages element', () = > {
const list = wrapper.find('ul.list-messages');
expect(list.contains('li')).toBeTruthy();
expect(list.find('li').text()).toBe('test')
})
但这会失败,查阅资料后,似乎无法通过这种方式mounted的组件传入slots中。
尽管如此,可通过渲染函数(render function)作为一种非正式解决方法:
const fakeMessage = {
render(h) {
return h(Message, {
props: {
message: 'test'
}
})
}
};
wrapper = mount(MessageList, {
slots: {
default: fakeMessage
}
})
测试命名插槽(Named Slots)
测试命名插槽与默认插槽原理相同。创建Test5组件,其中应用新的MessageList组件,组件中增加一个给定名字为header的插槽,并设定默认内容:
<div>
<header class="list-header">
<slot name="header">This is a default header</slot>
</header>
<ul class="list-messages">
<slot></slot>
</ul>
</div>
在Test5中即可使用该命名插槽:
<MessageList>
<header slot="header">Awesome header</header>
<Message v-for="message in messages" :key="message" :message="message"></Message>
</MessageList>
对MessageList组件进行测试时,首先测试组件中是否渲染了命名插槽的默认内容:
// 渲染命名插槽的默认内容
it('Header slot renders a default header text', () = > {
const header = wrapper.find('.list-header');
expect(header.text()).toBe('This is a default header')
});
随后测试插槽是否能插入给定内容,只需将mount方法中的slots选项的键值default改为被测试插槽的name即可:
// 向header插槽中插入内容
it('Header slot is rendered withing .list-header', () = > {
wrapper = mount(MessageList, {
slots: {
header: '<header>What an awesome header</header>'
}
});
const header = wrapper.find('.list-header');
expect(header.text()).toBe('What an awesome header')
})
防抖函数与多次Mock处理
测试debounce
常使用lodash的debounce方法,以避免高频操作导致函数在短时间内反复执行。例如在Test6组件中,对button的点击事件进行了debounce,频率为500ms。这意味着若在500ms内用户再次点击按钮,handler方法将被推迟执行:
<template>
<div class="outer">
<p>This button has been clicked {{count}}</p>
<button @click="addCounter">click</button>
</div>
</template>
<script>
import _ from 'lodash';
export default {
data() {
return { count: 0 }
},
methods: {
addCounter: _.debounce(function () {
this.handler()
}, 500),
handler() {
this.count += 1;
}
}
}
</script>
在编写Test6的单元测试时,预期为:当addCounter方法被触发时,若500ms内无后续操作,handler方法应被触发。
若未进行特殊处理,单元测试文件应如下所示:
import { shallow } from 'vue-test-utils';
import Test6 from '@/components/Test6';
describe('Test for Test6 Component', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(Test6);
});
afterEach(() => {
wrapper.destroy()
});
it('test for lodash', () => {
const mockFn2 = jest.fn();
wrapper.setMethods({ handler: mockFn2 });
wrapper.vm.addCounter();
expect(mockFn2).toHaveBeenCalledTimes(1);
})
});
测试结果发现,addCounter被触发时,handler方法并未执行。
因lodash中debounce方法涉及setTimeout,handler方法应在500ms后执行,故此时执行时方法尚未运行。
因此需在Jest中对setTimeout进行特殊处理:Jest提供了相关方法,需使用jest.useFakeTimers()和jest.runAllTimers()。
前者用于让Jest模拟使用的诸如setTimeout、setInterval等计时器,后者用于执行setTimeout、setInterval等异步任务中的宏任务(macro-task),并将所需的新macro-task放入队列并执行。更多信息可参考官网的timer-mocks。
因此对test6.spec.js进行修改,在代码开始处增加jest.useFakeTimers(),在触发addCounter方法后通过jest.runAllTimers()触发macro-task任务。
jest.useFakeTimers();
import { shallow } from 'vue-test-utils';
import Test6 from '@/components/Test6';
import _ from 'lodash';
describe('Test for Test6 Component', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(Test6);
});
afterEach(() => {
wrapper.destroy()
});
it('test for lodash', () => {
const mockFn2 = jest.fn();
wrapper.setMethods({ handler: mockFn2 });
wrapper.vm.addCounter();
jest.runAllTimers();
expect(mockFn2).toHaveBeenCalledTimes(1);
})
});
结果仍失败,报错原因为:
Ran 100000 timers, and there are still more! Assuming we've hit an infinite recursion and bailing out…
程序陷入死循环。换用Jest提供的另一个API:jest.runOnlyPendingTimers(),该方法仅执行当前队列中的macro-task,遇到的新macro-task则不予执行。
将jest.runAllTimers()替换为jest.runOnlyPendingTimers()后,上述错误消失,但handler仍未执行。
查阅大量资料后,这可能是lodash的debounce机制与Jest的timer-mocks无法兼容所致。若有人能解决此问题,敬请指教。
在此情况下,退而求其次,不去验证addCounter是否会被debounce,因debounce为第三方模块方法,默认视为正确。需验证的是addCounter能正确触发handler方法。
因此可另辟蹊径,通过Mock将lodash的debounce修改为立即执行的函数。需为lodash的debounce替换为jest.fn(),并提供一个工厂函数,返回值即为传入的函数。
import _ from 'lodash';
jest.mock('lodash', () => ({
debounce: jest.fn((fn => fn))
}));
如此修改后,测试通过,handler方法正确执行。
同一方法的多次Mock
在组件中,可能会多次用到同一外部方法,但每次返回值不同,需对其进行多次不同Mock。
举例而言,在组件Test7中,mounted时forData返回一个数组,经map处理后赋给text;点击getResult按钮,返回0或1的数字,根据返回值给result赋值。
<template>
<div class="outer">
<p>{{text}}</p>
<p>Result is {{result}}</p>
<button @click="getResult">getResult</button>
</div>
</template>
<script>
import { forData } from '@/helper';
import axios from 'axios'
export default {
data() {
return {
text: '',
result: ''
}
},
async mounted() {
const ret = await forData(axios.get('text.do'));
this.text = ret.map(val => val.name)
},
methods: {
async getResult() {
const res = await forData(axios.get('result.do'));
switch (res) {
case 0 : {
this.result = '000';
break
}
case 1 : {
this.result = '111';
break
}
}
},
}
}
</script>
针对getResult方法编写单元测试,针对两种返回值编写两个用例。在用例中将forData方法Mock掉,返回值为Promise值,再根据给定返回值判断结果是否符合预期:
describe('Test for Test7 Component', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(Test7);
});
afterEach(() => {
wrapper.destroy()
});
it('test for getResult', async () => {
// 设定forData返回值
const mockResult = 0;
const mockFn = jest.fn(() => (Promise.resolve(mockResult)));
helper.forData = mockFn;
// 执行
await wrapper.vm.getResult();
// 断言
expect(mockFn).toHaveBeenCalledTimes(1);
expect(wrapper.vm.result).toBe('000')
});
it('test for getResult', async () => {
// 设定forData返回值
const mockResult = 1;
const mockFn = jest.fn(() => (Promise.resolve(mockResult)));
helper.forData = mockFn;
// 执行
await wrapper.vm.getResult();
// 断言
expect(mockFn).toHaveBeenCalledTimes(1);
expect(wrapper.vm.result).toBe('111')
})
});
运行测试用例,虽测试用例全部通过,但控制台仍报错:
(node:17068) UnhandledPromiseRejectionWarning: TypeError: ret.map is not a function
为何如此?
原因在于,第一个用例运行后,代码中的forData方法已被Mock。因此在运行第二个用例时,执行mounted钩子函数,forData返回值即为上个用例中给定的1,故使用map方法会报错。
为解决此问题,需在beforeEach(或afterEach)中重置forData状态。若代码中使用了MockJS,只需让默认forData获取的数据走原路径,由MockJS提供假数据即可。如此只需在代码最开始将forData保存,在beforeEach使用restoreAllMocks方法重置状态,然后恢复forData状态,每个用例中针对forData进行单独Mock即可。
const test = helper.forData;
describe('Test for Test7 Component', () => {
let wrapper;
beforeEach(() => {
jest.restoreAllMocks();
helper.forData = test;
wrapper = shallow(Test7);
});
afterEach(() => {
wrapper.destroy()
});
// 用例不变
若未使用MockJS,则均需自行提供数据,需在afterEach中提供mounted时所需数据:
beforeEach(() = > {
jest.restoreAllMocks();
const mockResult = [{ name: 1}, {name: 2}];
helper.forData = jest.fn(() = > (Promise.resolve(mockResult)));
wrapper = shallow(Test7);
});
如此处理后,运行用例通过,且控制台不再报错。
若在同一个方法中遇到需不同返回结果的forData,例如下面的getQuestion方法:
async getQuestion() {
const r1 = await forData(axios.get('result1.do'));
const r2 = await forData(axios.get('result2.do'));
const res = r1 + r2;
switch (res) {
case 2:
{
this.result = '222';
break
}
case 3:
{
this.result = '333';
break
}
}
},
通过forData发出两个不同HTTP请求,返回结果不同。此时测试时需使用mockImplementationOnce方法,该方法Mock的函数仅被调用一次,多次调用时将根据定义时的顺序依次调用Mock函数,故测试用例如下:
it('test for getQuestion', async() = > {
// 设定forData返回值
const mockFn = jest.fn()
.mockImplementationOnce(() = > (Promise.resolve(1)))
.mockImplementationOnce(() = > (Promise.resolve(2)));
helper.forData = mockFn;
// 执行
await wrapper.vm.getQuestion();
// 断言
expect(mockFn).toHaveBeenCalledTimes(2);
expect(wrapper.vm.result).toBe('333')
});
测试用例通过,且控制台无报错。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。