【测试学习】UI测试工具vue-test-utils入门教程
参考文章官方文档起步1、安装(略)2、挂载组件Vue Test Utils 通过将它们隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。被挂载的组件会返回到一个包裹器(wrapper)内,而包裹器会暴露很多封装、遍历和查询其内部的 Vue 组件实例的便捷的方法。你可以通过 mount 方法来创建包裹器。// 从测试实用
参考文章
官方文档
起步
1、安装(略)
2、挂载组件
Vue Test Utils 通过将它们隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。
被挂载的组件会返回到一个包裹器(wrapper)内,而包裹器会暴露很多封装、遍历和查询其内部的 Vue 组件实例的便捷的方法。
你可以通过 mount 方法来创建包裹器。
// 从测试实用工具集中导入 `mount()` 方法
// 同时导入你要测试的组件
import { mount } from '@vue/test-utils'
import Counter from './counter'
// 现在挂载组件,你便得到了这个包裹器
const wrapper = mount(Counter)
// 你可以通过 `wrapper.vm` 访问实际的 Vue 实例
const vm = wrapper.vm
// 在控制台将其记录下来即可深度审阅包裹器
// 我们对 Vue Test Utils 的探索也由此开始
console.log(wrapper)
3、测试组件渲染出来的 HTML
现在我们已经有了这个包裹器,我们能做的第一件事就是认证该组件渲染出来的 HTML 符合预期。
// 从测试实用工具集中导入 `mount()` 方法
// 同时导入你要测试的组件
import { mount } from '@vue/test-utils'
import Counter from './counter'
// 现在挂载组件,你便得到了这个包裹器
const wrapper = mount(Counter)
// 你可以通过 `wrapper.vm` 访问实际的 Vue 实例
const vm = wrapper.vm
// 在控制台将其记录下来即可深度审阅包裹器
// 我们对 Vue Test Utils 的探索也由此开始
console.log(wrapper)
4、模拟用户交互
当用户点击按钮的时候,我们的计数器应该递增。为了模拟这一行为,我们首先需要通过 wrapper.find() 定位该按钮,此方法返回一个该按钮元素的包裹器。然后我们能够通过对该按钮包裹器调用 .trigger() 来模拟点击。
it('button click should increment the count', () => {
expect(wrapper.vm.count).toBe(0)
const button = wrapper.find('button')
button.trigger('click')
expect(wrapper.vm.count).toBe(1)
})
5、使用nextTick与await
**任何导致操作 DOM 的改变都应该在断言之前 await nextTick 函数。**因为 Vue 会对未生效的 DOM 进行批量异步更新,避免因数据反复变化而导致不必要的渲染。
it('button click should increment the count text', async () => {
expect(wrapper.text()).toContain('0')
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('1')
})
trigger 返回一个可以像上述示例一样被 await 或像普通 Promise 回调一样被 then 链式调用的 Promise。
当你在测试代码中使用 nextTick 时,请注意任何在其内部被抛出的错误可能都不会被测试运行器捕获,因为其内部使用了 Promise。关于这个问题有两个建议:要么你可以在测试的一开始将 Vue 的全局错误处理器设置为 done 回调,要么你可以在调用 nextTick 时不带参数让其作为一个 Promise 返回:
// 错误不会被捕获
it('will time out', done => {
Vue.nextTick(() => {
expect(true).toBe(false)
done()
})
})
// 接下来的三项测试都会如预期工作
it('will catch the error using done', done => {
Vue.config.errorHandler = done
Vue.nextTick(() => {
expect(true).toBe(false)
done()
})
})
it('will catch the error using a promise', () => {
return Vue.nextTick().then(function() {
expect(true).toBe(false)
})
})
it('will catch the error using async/await', async () => {
await Vue.nextTick()
expect(true).toBe(false)
})
常用技巧
1、明白要测试的是什么
对于 UI 组件来说,我们不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。
我们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。
比如,对于每次点击按钮都会将计数加一的 Counter 组件来说,其测试用例将会模拟点击并断言渲染结果会加 1。该测试并没有关注 Counter 如何递增数值,而只关注其输入和输出。
2、浅渲染
在测试用例中,我们通常希望专注在一个孤立的单元中测试组件,避免对其子组件的行为进行间接的断言。
额外的,对于包含许多子组件的组件来说,整个渲染树可能会非常大。重复渲染所有的子组件可能会让我们的测试变慢。
Vue Test Utils 允许你通过 shallowMount 方法只挂载一个组件而不渲染其子组件 (即保留它们的存根):
import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(Component)
wrapper.vm // 挂载的 Vue 实例
3、生命周期钩子
在使用 mount 或 shallowMount 方法时,你可以期望你的组件响应 Vue 所有生命周期事件。但是请务必注意的是,除非使用 Wrapper.destroy(),否则 beforeDestroy 和 destroyed 将不会触发。
此外组件在每个测试规范结束时并不会被自动销毁,并且将由用户来决定是否要存根或手动清理那些在测试规范结束前继续运行的任务 (例如 setInterval 或者 setTimeout)。
4、使用nextTick编写异步测试代码
(好像await trigger函数返回值也可以)
默认情况下 Vue 会异步地批量执行更新 (在下一轮 tick),以避免不必要的 DOM 重绘或者是观察者计算。
这意味着你在更新会引发 DOM 变化的属性后必须等待一下。使用Vue提供的nextTick函数
it('updates text', async () => {
const wrapper = mount(Component)
await wrapper.trigger('click')
expect(wrapper.text()).toContain('updated')
await wrapper.trigger('click')
wrapper.text().toContain('some different text')
})
// 或者你不希望使用 async/await
it('render text', done => {
const wrapper = mount(TestComponent)
wrapper.trigger('click').then(() => {
wrapper.text().toContain('updated')
wrapper.trigger('click').then(() => {
wrapper.text().toContain('some different text')
done()
})
})
})
5、断言触发的事件
每个挂载的包裹器都会通过其背后的 Vue 实例自动记录所有被触发的事件。你可以用 wrapper.emitted() 方法取回这些事件记录。
wrapper.vm.$emit('foo')
wrapper.vm.$emit('foo', 123)
/*
`wrapper.emitted()` 返回以下对象:
{
foo: [[], [123]]
}
*/
然后你可以基于这些数据来设置断言:
// 断言事件已经被触发
expect(wrapper.emitted().foo).toBeTruthy()
// 断言事件的数量
expect(wrapper.emitted().foo.length).toBe(2)
// 断言事件的有效数据
expect(wrapper.emitted().foo[1]).toEqual([123])
你也可以调用 wrapper.emittedByOrder() 获取一个按触发先后排序的事件数组。
6、从子组件触发事件
你可以通过访问子组件实例来触发一个自定义事件
组件:
<template>
<div>
<child-component @custom="onCustom" />
<p v-if="emitted">Emitted!</p>
</div>
</template>
<script>
import ChildComponent from './ChildComponent'
export default {
name: 'ParentComponent',
components: { ChildComponent },
data() {
return {
emitted: false
}
},
methods: {
onCustom() {
this.emitted = true
}
}
}
</script>
测试代码:
import { mount } from '@vue/test-utils'
import ParentComponent from '@/components/ParentComponent'
import ChildComponent from '@/components/ChildComponent'
describe('ParentComponent', () => {
it("displays 'Emitted!' when custom event is emitted", () => {
const wrapper = mount(ParentComponent)
wrapper.find(ChildComponent).vm.$emit('custom')
expect(wrapper.html()).toContain('Emitted!')
})
})
7、操作组件状态
你可以在包裹器上用 setData 或 setProps 方法直接操作组件状态:
it('manipulates state', async () => {
await wrapper.setData({ count: 10 })
await wrapper.setProps({ foo: 'bar' })
})
8、仿造Prop
可以使用 Vue 在内置 propsData 选项向组件传入 prop:
import { mount } from '@vue/test-utils'
mount(Component, {
propsData: {
aProp: 'some value'
}
})
你也可以用 wrapper.setProps({}) 方法更新这些已经挂载的组件的 prop(就是上面的示例)
9、仿造Transitions
这里有问题 用到了再查文档
10、应用全局的插件和混入
有些组件可能依赖一个全局插件或混入 (mixin) 的功能注入,比如 vuex 和 vue-router。
如果你在为一个特定的应用撰写组件,你可以在你的测试入口处一次性设置相同的全局插件和混入。但是有些情况下,比如测试一个可能会跨越不同应用共享的普通的组件套件的时候,最好还是在一个更加隔离的设置中测试你的组件,不对全局的 Vue 构造函数注入任何东西。我们可以使用 createLocalVue 方法来存档它们:
import { createLocalVue, mount } from '@vue/test-utils'
// 创建一个扩展的 `Vue` 构造函数
const localVue = createLocalVue()
// 正常安装插件
localVue.use(MyPlugin)
// 在挂载选项中传入 `localVue`
mount(Component, {
localVue
})
注意有些插件会为全局的 Vue 构造函数添加只读属性,比如 Vue Router。这使得我们无法在一个 localVue 构造函数上二次安装该插件,或伪造这些只读属性。
11、仿造注入
另一个注入 prop 的策略就是简单的仿造它们。你可以使用 mocks 选项:
import { mount } from '@vue/test-utils'
const $route = {
path: '/',
hash: '',
params: { id: '123' },
query: { q: 'hello' }
}
mount(Component, {
mocks: {
// 在挂载组件之前
// 添加仿造的 `$route` 对象到 Vue 实例中
$route
}
})
12、存根组件
你可以使用 stubs 选项覆写全局或局部注册的组件:
import { mount } from '@vue/test-utils'
mount(Component, {
// 将会把 globally-registered-component 解析为
// 空的存根
stubs: ['globally-registered-component']
})
13、处理路由
因为路由需要在应用的全局结构中进行定义,且引入了很多组件,所以最好集成到 end-to-end 测试。对于依赖 vue-router 功能的独立的组件来说,你可以使用上面提到的技术仿造它们。
14、探测样式
当你的测试运行在 jsdom 中时,只能探测到内联样式。
测试键盘、鼠标等其他DOM事件
1、触发事件
Wrapper 暴露了一个 trigger 方法。它可以用来触发 DOM 事件。(注意await)
test('triggers a click', async () => {
const wrapper = mount(MyButton)
await wrapper.trigger('click')
})
你应该注意到了,find 方法也会返回一个 Wrapper。假设 MyComponent 包含一个按钮,下面的代码会点击这个按钮。
test('triggers a click', async () => {
const wrapper = mount(MyComponent)
await wrapper.find('button').trigger('click')
})
2、选项
其 trigger 方法接受一个可选的 options 对象。这个 options 对象里的属性会被添加到事件中。
注意其目标不能被添加到 options 对象中。
test('triggers a click', async () => {
const wrapper = mount(MyComponent)
await wrapper.trigger('click', { button: 0 })
})
3、鼠标点击时间示例
组件代码
<template>
<div>
<button class="yes" @click="callYes">Yes</button>
<button class="no" @click="callNo">No</button>
</div>
</template>
<script>
export default {
name: 'YesNoComponent',
props: {
callMe: {
type: Function
}
},
methods: {
callYes() {
this.callMe('yes')
},
callNo() {
this.callMe('no')
}
}
}
</script>
测试代码(Sinon的使用场景 spies 正如名字所暗示的,spies被用来获取关于函数调用的信息)
import YesNoComponent from '@/components/YesNoComponent'
import { mount } from '@vue/test-utils'
import sinon from 'sinon'
it('Click on yes button calls our method with argument "yes"', async () => {
const spy = sinon.spy()
const wrapper = mount(YesNoComponent, {
propsData: {
callMe: spy
}
})
await wrapper.find('button.yes').trigger('click')
spy.should.have.been.calledWith('yes')
})
键盘事件实例
这个组件允许使用不同的按键将数量递增/递减。
组件代码:
<template>
<input type="text" @keydown.prevent="onKeydown" v-model="quantity" />
</template>
<script>
const KEY_DOWN = 40
const KEY_UP = 38
const ESCAPE = 27
export default {
data() {
return {
quantity: 0
}
},
methods: {
increment() {
this.quantity += 1
},
decrement() {
this.quantity -= 1
},
clear() {
this.quantity = 0
},
onKeydown(e) {
if (e.keyCode === ESCAPE) {
this.clear()
}
if (e.keyCode === KEY_DOWN) {
this.decrement()
}
if (e.keyCode === KEY_UP) {
this.increment()
}
if (e.key === 'a') {
this.quantity = 13
}
}
},
watch: {
quantity: function(newValue) {
this.$emit('input', newValue)
}
}
}
</script>
测试代码:
import QuantityComponent from '@/components/QuantityComponent'
import { mount } from '@vue/test-utils'
describe('Key event tests', () => {
it('Quantity is zero by default', () => {
const wrapper = mount(QuantityComponent)
expect(wrapper.vm.quantity).toBe(0)
})
it('Up arrow key increments quantity by 1', async () => {
const wrapper = mount(QuantityComponent)
await wrapper.trigger('keydown.up')
expect(wrapper.vm.quantity).toBe(1)
})
it('Down arrow key decrements quantity by 1', async () => {
const wrapper = mount(QuantityComponent)
wrapper.vm.quantity = 5
await wrapper.trigger('keydown.down')
expect(wrapper.vm.quantity).toBe(4)
})
it('Escape sets quantity to 0', async () => {
const wrapper = mount(QuantityComponent)
wrapper.vm.quantity = 5
await wrapper.trigger('keydown.esc')
expect(wrapper.vm.quantity).toBe(0)
})
it('Magic character "a" sets quantity to 13', async () => {
const wrapper = mount(QuantityComponent)
await wrapper.trigger('keydown', {
key: 'a'
})
expect(wrapper.vm.quantity).toBe(13)
})
})
点后面的按键名 keydown.up 会被翻译成一个 keyCode。这些被支持的按键名有:
测试异步行为
在编写测试代码时你将会遇到两种异步行为:
1、来自 Vue 的更新
2、来自外部行为的更新
1、来自Vue的更新
Vue 会异步的将未生效的 DOM 批量更新,避免因数据反复变化而导致不必要的渲染。
// 在测试框架中,编写一个测试用例
it('button click should increment the count text', async () => {
expect(wrapper.text()).toContain('0')
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('1')
})
// 两段代码等价
it('button click should increment the count text', async () => {
expect(wrapper.text()).toContain('0')
const button = wrapper.find('button')
button.trigger('click')
await Vue.nextTick()
expect(wrapper.text()).toContain('1')
})
可以被await的方法有:
setData
setValue
setChecked
setSelected
setProps
trigger
2、来自外部行为的更新
在 Vue 之外最常见的一种异步行为就是在 Vuex 中进行 API 调用。以下示例将展示如何测试在 Vuex 中进行 API 调用的方法。本示例使用 Jest 运行测试并模拟 HTTP 库axios。
axios mock 的实现如下所示:
export default {
get: () => Promise.resolve({ data: 'value' })
}
当按钮被点击时,组件将会产生一个 API 调用,并且将响应的返回内容赋值给 value。
组件代码:
<template>
<button @click="fetchResults">{{ value }}</button>
</template>
<script>
import axios from 'axios'
export default {
data() {
return {
value: null
}
},
methods: {
async fetchResults() {
const response = await axios.get('mock/service')
this.value = response.data
}
}
}
</script>
错误示例
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo'
jest.mock('axios', () => ({
get: Promise.resolve('value')
}))
it('fetches async when a button is clicked', () => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
expect(wrapper.text()).toBe('value')
})
上面的代码代码会执行失败,这是因为我们在 fetchResults 方法执行完毕前(异步)就对结果进行断言。绝大多数单元测试框架都会提供一个回调来通知你测试将在何时完成。Jest 和 Mocha 都使用done 这个方法。我们可以将 done 与 $nextTick 或 setTimeout 结合使用,以确保在进行断言前已经处理完所有的 Promise 回调。(用await应该也可以 ,下面有讲解)
it('fetches async when a button is clicked', done => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toBe('value')
done()
})
})
setTimeout 也可以使测试通过的原因是,Promise 回调的微任务队列会排在 setTimeout 回调的微任务队列之前。这意味着当 setTimeout 回调执行时,微任务队列上的所有 Promise 回调已经被执行过了。另一方面,$nextTick 也存在调度微任务的情况,但是由于微任务队列是先进先出的,因此也保证了在进行断言时已经处理完所有的 Promise 回调。
(还有一种是flushPromises的方法。)
为什么不使用 await button.trigger()?
如之前所解释的,Vue 更新其组件和完成其 Promise 对象的时机不同,如 axios 解析出的那个。
一个易于遵循的规则是在诸如 trigger 或 setProps 的变更时始终使用 await。如果你的代码依赖一些诸如 axios 的异步操作,也要为 flushPromises 加入一个 await。
配合Vue Router使用
1、在测试中安装Vue Router
在测试中,你应该杜绝在基本的 Vue 构造函数中安装 Vue Router。安装 Vue Router 之后 Vue 的原型上会增加 $route 和 $router 这两个只读属性。
为了避免这样的事情发生,我们创建了一个 localVue 并对其安装 Vue Router。
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()
shallowMount(Component, {
localVue,
router
})
注意:在一个 localVue 上安装 Vue Router 时也会将 $route 和 $router 作为两个只读属性添加给该 localVue。这意味着如果你使用安装了 Vue Router 的 localVue,则不能在挂载一个组件时使用 mocks 选项来覆写 $route 和 $router。
2、测试使用了 router-link 或 router-view 的组件
当你安装 Vue Router 的时候,router-link 和 router-view 组件就被注册了。这意味着我们无需再导入可以在应用的任意地方使用它们。
当我们运行测试的时候,需要令 Vue Router 相关组件在我们挂载的组件中可用。有以下两种做法。
使用存根:
import { shallowMount } from '@vue/test-utils'
shallowMount(Component, {
stubs: ['router-link', 'router-view']
})
为localVue安装Vue Router
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)
shallowMount(Component, {
localVue
})
3、伪造$route和$router
有的时候你想要测试一个组件在配合 $route 和 $router 对象的参数时的行为。这时候你可以传递自定义假数据给 Vue 实例。
import { shallowMount } from '@vue/test-utils'
const $route = {
path: '/some/path'
}
const wrapper = shallowMount(Component, {
mocks: {
$route
}
})
wrapper.vm.$route.path // /some/path
4、常识
安装 Vue Router 会在 Vue 的原型上添加 $route 和 $router 只读属性。
这意味着在未来的任何测试中,伪造 $route 或 $router 都会失效。
要想回避这个问题,就不要在运行测试的时候全局安装 Vue Router,而用上述的 localVue 用法。
更多推荐
所有评论(0)