Skip to main content

Vue Test Utils

Guides

测试组件,首先需要用 mount 加载组件,这是在 Vue-Test-Utils 中渲染组件的主要方式。mount 组件后会返回一个 Wrapper , 它会暴露一系列和组件查询、交互的方法。

然后用get()、 find()、findAll()定位元素的位置,然后进行一系列的操作。

通过id,class,attr 获取元素,设置input的值等

wrapper 中的 get() 方法用来找已经存在的元素,用的是 querySelector语法,如果没有获取到元素,会抛出一个错误。获取到元素会返回一个 DOMWrapper ,它是一个简洁版的 Wrapper 。

test('', async () => {
const wrapper = mount(Component) // 加载组件

// Vue是异步更新DOM的。我们需要将测试标记为async,并在可能导致DOM更改的任法上调用 await。
await wrapper.get('.input').setValue('hello') // 通过class获取DOM, setValue设置input值
await wrapper.get('#input').setValue('hello') // 通过id获取DOM
await wrapper.get('input').setValue('hello') // 通过tag获取DOM

// 使用 trigger 提交form、button
await wrapper.get('#form').trigger('submit')

// text() 获取DOM文本
expect( wrapper.get('#content').text()).toEqual('Hello')

// 通过属性获取DOM, toContain 是否包含某值
expect(wrapper.get('[data-name="heaedr"]').classes()).toContain('active')

// toHaveLength 判断元素的个数
expect(wrapper.findAll('[class="box"]')).toHaveLength(2)
})

get在假定元素存在的时候起作用,当不存在的时候会报错,并终止test。一般不建议用 get 来断言是否存在。建议使用 find()exists()

find 和 mount 比较像,返回一个 Wrapper 。

test('', async () => {    
// exists 判断是否存在
expect(wrapper.find('#admin').exists()).toBe(false)
})

给组件传值

test('renders an admin link', () => {
const wrapper = mount(Nav, {
data() {
return {
admin: true
}
}
})

expect(wrapper.get('#admin').text()).toEqual('Admin')
})

判断组件的显隐

/** 
* 组件 v-show = false, 则 find 和 exists 会返回true, 因为元素始终在DOM中
* 应该使用 isVisible()
* display: none, visibility: hidden, opacity :0
* <details> 中的标签
* hidden 属性
* 以上情况 isVisible() 返回 false.
* /

expect(wrapper.get('#user-dropdown').isVisible()).toBe(false)

触发事件

const Counter = {
template: '<button @click="handleClick">Increment</button>',
data() {
return {
count: 0
}
},
methods: {
handleClick() {
this.count += 1
this.$emit('increment', this.count)
}
}
}
test('emits an event when clicked', () => {
const wrapper = mount(Counter)

wrapper.find('button').trigger('click')
wrapper.find('button').trigger('click')

// emitted() 它返回一个包含组件发出的所有事件的对象
expect(wrapper.emitted()).toHaveProperty('increment')

const incrementEvent = wrapper.emitted('increment')

// 触发了两次”click“事件,因此 ’increment‘事件数组长度应该为 2
expect(incrementEvent).toHaveLength(2)

// 第一次点击
// 注意值应该是个数组
expect(incrementEvent[0]).toEqual([1])

// 第二次点击
expect(incrementEvent[1]).toEqual([2])

// console.log(wrapper.emitted('increment'))
[
[1], // 第一次点击 `count` is 1
[2] // 第二次点击 `count` is 2
]
})

复杂事件

const Counter = {
template: `<button @click="handleClick">Increment</button>`,
data() {
return {
count: 0
}
},
methods: {
handleClick() {
this.count += 1

this.$emit('increment', {
count: this.count,
isEven: this.count % 2 === 0
})
}
}
}
test('emits an event with count when clicked', () => {
const wrapper = mount(Counter)

wrapper.find('button').trigger('click')
wrapper.find('button').trigger('click')

expect(wrapper.emitted('increment')[0]).toEqual([
{
count: 1,
isEven: false
}
])

expect(wrapper.emitted('increment')[1]).toEqual([
{
count: 2,
isEven: true
}
])
})

测试Form

这里常用的方法主要是 setValuetrigger

<template>
<div>
<input type="email" v-model="email" />
<button @click="submit">Submit</button>
</div>
</template>

<script>
export default {
data() {
return {
email: ''
}
},
methods: {
submit() {
this.$emit('submit', this.email)
}
}
}
</script>
test('sets the value', async () => {
const wrapper = mount(Component)
const input = wrapper.find('input')

// 如果你没有为OPTION, CHECKBOX或RADIO输入传递一个参数给setValue,它们将被设置为选中。
// 当使用 setValue 时, 使用 await 确保 vue 可以对行为做出反应
await input.setValue('my@mail.com')


expect(input.element.value).toBe('my@mail.com')

await wrapper.find('button').trigger('click')

expect(wrapper.emitted()).toHaveProperty('submit')
})

复杂 form 测试演示

<template>
<form @submit.prevent="submit">
<input type="email" v-model="form.email" />

<textarea v-model="form.description" />

<select v-model="form.city">
<option value="new-york">New York</option>
<option value="moscow">Moscow</option>
</select>

<input type="checkbox" v-model="form.subscribe" />

<input type="radio" value="weekly" v-model="form.interval" />
<input type="radio" value="monthly" v-model="form.interval" />

<button type="submit">Submit</button>
</form>
</template>

<script>
export default {
data() {
return {
form: {
email: '',
description: '',
city: '',
subscribe: false,
interval: ''
}
}
},
methods: {
async submit() {
this.$emit('submit', this.form)
}
}
}
</script>
import { mount } from '@vue/test-utils'
import FormComponent from './FormComponent.vue'

test('submits a form', async () => {
const wrapper = mount(FormComponent)

const email = 'name@mail.com'
const description = 'Lorem ipsum dolor sit amet'
const city = 'moscow'

await wrapper.find('input[type=email]').setValue(email)
await wrapper.find('textarea').setValue(description)
await wrapper.find('select').setValue(city)
await wrapper.find('input[type=checkbox]').setValue()
await wrapper.find('input[type=radio][value=monthly]').setValue()

await wrapper.find('form').trigger('submit.prevent')

expect(wrapper.emitted('submit')[0][0]).toStrictEqual({
email,
description,
city,
subscribe: true,
interval: 'monthly'
})
})

测试复杂 Input 组件

项目中可能会使用到 UI 库,参照下面的例子

<template>
<form @submit.prevent="handleSubmit">
<v-textarea v-model="description" ref="description" />
<button type="submit">Send</button>
</form>
</template>

<script>
export default {
name: 'CustomTextarea',
data() {
return {
description: ''
}
},
methods: {
handleSubmit() {
this.$emit('submitted', this.description)
}
}
}
</script>
test('emits textarea value on submit', async () => {
const wrapper = mount(CustomTextarea)
const description = 'Some very long text...'

// findComponent
await wrapper.findComponent({ ref: 'description' }).setValue(description)

wrapper.find('form').trigger('submit')

expect(wrapper.emitted('submitted')[0][0]).toEqual(description)
})

向组件传递数据

const Password = {
template: `
<div>
<input v-model="password">
<div v-if="error">{{ error }}</div>
</div>
`,
props: {
minLength: {
type: Number
}
},
computed: {
error() {
if (this.password.length < this.minLength) {
return `Password must be at least ${this.minLength} characters.`
}
return
}
}
}
test('renders an error if length is too short', () => {
const wrapper = mount(Password, {
props: {
minLength: 10
},
data() {
return {
password: 'short'
}
}
})

// await wrapper.setProps({ minLength: 10 })
// await wrapper.setData({ count: 2 })

expect(wrapper.html()).toContain('Password must be at least 10 characters')
})

slot

const Layout = {
template: `
<div>
<h1>Welcome!</h1>
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</div>
`
}
test('layout default slot', () => {
const wrapper = mount(Layout, {
slots: {
default: 'Main Content'
footer: '<div>Footer</div>',
header: [
'<div id="one">One</div>',
'<div id="two">Two</div>'
]
}
})

expect(wrapper.html()).toContain('Main Content')
expect(wrapper.find('main').text()).toContain('Main Content') // 更准确
expect(wrapper.html()).toContain('<div>Footer</div>')
expect(wrapper.find('#one').exists()).toBe(true)
expect(wrapper.find('#two').exists()).toBe(true)
})

// 高级使用
import { h } from 'vue'
import Header from './Header.vue'

test('layout full page layout', () => {
const wrapper = mount(Layout, {
slots: {
header: Header
main: h('div', 'Main Content'),
sidebar: { template: '<div>Sidebar</div>' },
footer: '<div>Footer</div>',
}
})

expect(wrapper.html()).toContain('<div>Header</div>')
expect(wrapper.html()).toContain('<div>Main Content</div>')
expect(wrapper.html()).toContain('<div>Footer</div>')
})

// Scoped Slots
const ComponentWithSlots = {
template: `
<div class="scoped">
<slot name="scoped" v-bind="{ msg }" />
</div>
`,
data() {
return {
msg: 'world'
}
}
}

test('scoped slots', () => {
const wrapper = mount(ComponentWithSlots, {
slots: {
scoped: `<template #scoped="params">
Hello {{ params.msg }}
</template>
`
}
})

expect(wrapper.html()).toContain('Hello world')
})

HTTP请求

<template>
<button @click="getPosts">Get posts</button>
<ul>
<li v-for="post in posts" :key="post.id" data-test="post">
{{ post.title }}
</li>
</ul>
</template>

<script>
import axios from 'axios'

export default {
data() {
return {
posts: null
}
},
methods: {
async getPosts() {
this.posts = await axios.get('/api/posts')
}
}
}
</script>
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import PostList from './PostList.vue'

const mockPostList = [
{ id: 1, title: 'title1' },
{ id: 2, title: 'title2' }
]

// 下面几行告诉Jest 模拟对 axios.get 的任何调用, 并返回 mockPostList
jest.spyOn(axios, 'get').mockResolvedValue(mockPostList)

test('loads posts on button click', async () => {
const wrapper = mount(PostList)

await wrapper.get('button').trigger('click')

// 断言 axios.get 正确的调用次数
expect(axios.get).toHaveBeenCalledTimes(1)

// 断言 axios.get 正确的调用参数
expect(axios.get).toHaveBeenCalledWith('/api/posts')

// 等待 DOM 更新
await flushPromises()

// 确定更新后的内容
const posts = wrapper.findAll('[data-test="post"]')

expect(posts).toHaveLength(2)
expect(posts[0].text()).toContain('title1')
expect(posts[1].text()).toContain('title2')
})

断言 loading

<template>
<button :disabled="loading" @click="getPosts">Get posts</button>

<p v-if="loading" role="alert">Loading your posts…</p>
<ul v-else>
<li v-for="post in posts" :key="post.id" data-test="post">
{{ post.title }}
</li>
</ul>
</template>

<script>
import axios from 'axios'

export default {
data() {
return {
posts: null,
loading: null
}
},
methods: {
async getPosts() {
this.loading = true

this.posts = await axios.get('/api/posts')

this.loading = null
}
}
}
</script>
test('displays loading state on button click', async () => {
const wrapper = mount(PostList)

// 先判断loading不存在, button 没有 disabled 属性
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.get('button').attributes()).not.toHaveProperty('disabled')

await wrapper.get('button').trigger('click')

expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.get('button').attributes()).toHaveProperty('disabled')

// 等待 DOM 更行
await flushPromises()

// http 请求完成后, 判断loaing button 状态是否重置
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.get('button').attributes()).not.toHaveProperty('disabled')
})