前言:为什么组件测试要关注 Props、事件和插槽?
组件的本质:输入与输出
<template>
<!-- Props 是输入 -->
<ChildComponent
:user="userData"
:showDetails="true"
@update="handleUpdate"
@delete="handleDelete"
>
<!-- 插槽也是输入 -->
<template #header>
<h1>标题</h1>
</template>
</ChildComponent>
</template>
组件的测试的关注点
- Props 输入是否正确渲染
- 事件输出是否正确触发
- 插槽内容是否正确分发
为什么这三个要素最重要?
| 要素 | 作用 | 测试重点 |
|---|---|---|
| Props | 父组件向子组件传递数据 | 组件是否能正确接收并渲染数据 |
| 事件 | 子组件向父组件通信 | 交互是否能正确触发事件 |
| 插槽 | 父组件控制子组件内容 | 内容是否能被正确分发和渲染 |
测试 Props - 验证输入
Props 测试的核心
给子组件输入数据,看它能不能正确显示。
最简单的 Props 测试
我们先来看一个简单的 Props 组件:
<!-- Greeting.vue -->
<template>
<h1>Hello, {{ name }}!</h1>
</template>
<script setup>
defineProps(['name'])
</script>
其对应的测试:
// Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'
describe('Greeting', () => {
it('显示传入的名字', () => {
// 传入 name="张三"
const wrapper = mount(Greeting, {
props: { name: '张三' }
})
// 验证是否显示了"Hello, 张三!"
expect(wrapper.text()).toBe('Hello, 张三!')
})
})
测试多种 Props 值
我们再来看一个有多种 Props 值的组件:
<!-- Button.vue -->
<template>
<button
:class="['btn', `btn-${type}`]"
:disabled="disabled"
>
{{ text }}
</button>
</template>
<script setup>
defineProps({
text: String,
type: { type: String, default: 'primary' },
disabled: { type: Boolean, default: false }
})
</script>
其对应的测试:
// Button.spec.js
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button', () => {
it('显示正确的文字', () => {
const wrapper = mount(Button, {
props: { text: '点击我' }
})
expect(wrapper.text()).toBe('点击我')
})
it('默认类型是 primary', () => {
const wrapper = mount(Button, {
props: { text: '按钮' }
})
expect(wrapper.classes()).toContain('btn-primary')
})
it('可以设置 type 为 danger', () => {
const wrapper = mount(Button, {
props: { text: '删除', type: 'danger' }
})
expect(wrapper.classes()).toContain('btn-danger')
})
it('disabled 属性可以禁用按钮', () => {
const wrapper = mount(Button, {
props: { text: '按钮', disabled: true }
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
})
测试复杂 Props 对象
我们再来看一个复杂 Props 对象的组件:
<!-- UserCard.vue -->
<template>
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span v-if="showBadge" class="badge">{{ user.status }}</span>
</div>
</template>
<script setup>
defineProps({
user: {
type: Object,
required: true
},
showBadge: Boolean
})
</script>
其对应的测试:
// UserCard.spec.js
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
const mockUser = {
name: '张三',
email: 'zhangsan@example.com',
status: 'active'
}
describe('UserCard', () => {
it('显示用户信息', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('zhangsan@example.com')
})
it('showBadge 为 true 时显示状态', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser,
showBadge: true
}
})
expect(wrapper.find('.badge').exists()).toBe(true)
expect(wrapper.find('.badge').text()).toBe('active')
})
it('showBadge 为 false 时不显示状态', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser,
showBadge: false
}
})
expect(wrapper.find('.badge').exists()).toBe(false)
})
})
测试 Props 变化后的响应
当 Props 发生变化时,如何测试呢?
<!-- ProgressBar.vue -->
<template>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: percent + '%' }"></div>
</div>
</template>
<script setup>
defineProps(['percent'])
</script>
其对应的测试:
// ProgressBar.spec.js
import { mount } from '@vue/test-utils'
import ProgressBar from './ProgressBar.vue'
describe('ProgressBar', () => {
it('根据 percent 设置宽度', () => {
const wrapper = mount(ProgressBar, {
props: { percent: 50 }
})
const fill = wrapper.find('.progress-fill')
expect(fill.attributes('style')).toContain('width: 50%')
})
it('percent 变化时宽度也跟着变', async () => {
const wrapper = mount(ProgressBar, {
props: { percent: 50 }
})
// 修改 props
await wrapper.setProps({ percent: 80 })
const fill = wrapper.find('.progress-fill')
expect(fill.attributes('style')).toContain('width: 80%')
})
})
测试事件 - 验证输出
事件测试的核心
触发组件的事件交互,看它能不能正确发出事件。
基础事件测试
我们来看一个基础事件组件:
<!-- SubmitButton.vue -->
<template>
<button @click="handleClick" :disabled="disabled">
{{ text }}
</button>
</template>
<script setup>
const props = defineProps({
text: String,
disabled: Boolean
})
const emit = defineEmits(['click'])
const handleClick = () => {
emit('click', 'button clicked')
}
</script>
其对应的测试:
// SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from './SubmitButton.vue'
describe('SubmitButton', () => {
it('点击时触发 click 事件', async () => {
const wrapper = mount(SubmitButton, {
props: { text: '提交' }
})
// 模拟点击
await wrapper.trigger('click')
// 验证事件被触发
expect(wrapper.emitted('click')).toBeTruthy()
// 验证事件参数
expect(wrapper.emitted('click')[0]).toEqual(['button clicked'])
})
it('禁用时点击不触发事件', async () => {
const wrapper = mount(SubmitButton, {
props: { text: '提交', disabled: true }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
})
测试多个事件
我们再来看一个有多个事件的组件:
<!-- SearchInput.vue -->
<template>
<div>
<input
v-model="value"
@input="handleInput"
@keyup.enter="handleEnter"
@focus="handleFocus"
@blur="handleBlur"
/>
<button @click="handleClear" v-if="value">清除</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const value = ref('')
const emit = defineEmits(['input', 'search', 'focus', 'blur', 'clear'])
const handleInput = (e) => {
value.value = e.target.value
emit('input', value.value)
}
const handleEnter = () => {
emit('search', value.value)
}
const handleFocus = () => emit('focus')
const handleBlur = () => emit('blur')
const handleClear = () => {
value.value = ''
emit('clear')
}
</script>
其对应的测试:
// SearchInput.spec.js
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
describe('SearchInput', () => {
it('输入时触发 input 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.setValue('vue')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['vue'])
})
it('按回车时触发 search 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.setValue('vue')
await input.trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')[0]).toEqual(['vue'])
})
it('获得焦点时触发 focus 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.trigger('focus')
expect(wrapper.emitted('focus')).toBeTruthy()
})
it('失去焦点时触发 blur 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.trigger('blur')
expect(wrapper.emitted('blur')).toBeTruthy()
})
it('点击清除按钮触发 clear 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.setValue('test')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('clear')).toBeTruthy()
})
})
测试事件顺序
当值发生变化时,如果确定事件的测试顺序呢?
<!-- Counter.vue -->
<template>
<div>
<span>{{ count }}</span>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const emit = defineEmits(['change'])
const increment = () => {
const oldValue = count.value
count.value++
emit('change', oldValue, count.value)
}
</script>
其对应的测试:
// Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('点击时触发 change 事件,参数是旧值和新值', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('change')).toBeTruthy()
expect(wrapper.emitted('change')[0]).toEqual([0, 1])
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('change')[1]).toEqual([1, 2])
})
})
测试插槽 - 验证内容分发
插槽测试的核心
给组件填充内容,看它能不能正确显示。
测试默认插槽
我们先来看一个默认插槽的组件:
<!-- Card.vue -->
<template>
<div class="card">
<div class="content">
<slot></slot> <!-- 默认插槽 -->
</div>
</div>
</template>
其对应的测试:
// Card.spec.js
import { mount } from '@vue/test-utils'
import Card from './Card.vue'
describe('Card', () => {
it('显示默认插槽的内容', () => {
const wrapper = mount(Card, {
slots: {
default: '<p class="custom">自定义内容</p>'
}
})
expect(wrapper.find('.custom').exists()).toBe(true)
expect(wrapper.find('.custom').text()).toBe('自定义内容')
})
})
测试具名插槽
我们再来看一个具名插槽的组件:
<!-- Layout.vue -->
<template>
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
其对应的测试:
// Layout.spec.js
import { mount } from '@vue/test-utils'
import Layout from './Layout.vue'
describe('Layout', () => {
it('渲染所有插槽', () => {
const wrapper = mount(Layout, {
slots: {
header: '<h1>页面标题</h1>',
default: '<p>主要内容</p>',
footer: '<p>版权信息</p>'
}
})
expect(wrapper.find('header h1').text()).toBe('页面标题')
expect(wrapper.find('main p').text()).toBe('主要内容')
expect(wrapper.find('footer p').text()).toBe('版权信息')
})
it('没有插槽时不显示对应的区域', () => {
const wrapper = mount(Layout, {
slots: {
default: '<p>内容</p>'
}
})
expect(wrapper.find('header').exists()).toBe(true)
expect(wrapper.find('header').text()).toBe('')
expect(wrapper.find('footer').exists()).toBe(true)
expect(wrapper.find('footer').text()).toBe('')
})
})
测试作用域插槽
我们再来看一个作用域插槽的组件:
<!-- DataTable.vue -->
<template>
<table>
<tbody>
<tr v-for="item in data" :key="item.id">
<td>
<!-- 作用域插槽,把数据传给父组件 -->
<slot name="cell" :item="item">
{{ item.name }} <!-- 默认内容 -->
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
defineProps({
data: Array
})
</script>
其对应的测试:
// DataTable.spec.js
import { mount } from '@vue/test-utils'
import DataTable from './DataTable.vue'
const mockData = [
{ id: 1, name: '张三', age: 25 },
{ id: 2, name: '李四', age: 30 }
]
describe('DataTable', () => {
it('默认插槽显示 name', () => {
const wrapper = mount(DataTable, {
props: { data: mockData }
})
const cells = wrapper.findAll('td')
expect(cells[0].text()).toBe('张三')
expect(cells[1].text()).toBe('李四')
})
it('自定义插槽显示 age', () => {
const wrapper = mount(DataTable, {
props: { data: mockData },
slots: {
cell: `
<template #cell="{ item }">
<span class="age">{{ item.age }}</span>
</template>
`
}
})
const ages = wrapper.findAll('.age')
expect(ages[0].text()).toBe('25')
expect(ages[1].text()).toBe('30')
})
})
完整示例 - 表单组件测试
我们来看一个复杂的表单组件:
<!-- LoginForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<div>
<input
v-model="username"
placeholder="用户名"
@blur="validateUsername"
/>
<span v-if="usernameError" class="error">{{ usernameError }}</span>
</div>
<div>
<input
v-model="password"
type="password"
placeholder="密码"
@blur="validatePassword"
/>
<span v-if="passwordError" class="error">{{ passwordError }}</span>
</div>
<div>
<label>
<input type="checkbox" v-model="remember" />
记住我
</label>
</div>
<button type="submit" :disabled="!isValid">
{{ loading ? '登录中...' : '登录' }}
</button>
<div class="footer">
<slot name="footer"></slot>
</div>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const username = ref('')
const password = ref('')
const remember = ref(false)
const loading = ref(false)
const usernameError = ref('')
const passwordError = ref('')
const emit = defineEmits(['submit', 'loading-change'])
const validateUsername = () => {
if (!username.value) {
usernameError.value = '用户名不能为空'
} else if (username.value.length < 3) {
usernameError.value = '用户名至少3个字符'
} else {
usernameError.value = ''
}
}
const validatePassword = () => {
if (!password.value) {
passwordError.value = '密码不能为空'
} else if (password.value.length < 6) {
passwordError.value = '密码至少6个字符'
} else {
passwordError.value = ''
}
}
const isValid = computed(() => {
return !usernameError.value && !passwordError.value &&
username.value && password.value
})
const handleSubmit = async () => {
if (!isValid.value) return
loading.value = true
emit('loading-change', true)
await new Promise(resolve => setTimeout(resolve, 1000))
emit('submit', {
username: username.value,
password: password.value,
remember: remember.value
})
loading.value = false
emit('loading-change', false)
}
</script>
其对应的测试:
// LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'
describe('LoginForm', () => {
// 1. Props 测试(这里没有 Props,跳过)
// 2. 事件测试
describe('Events', () => {
it('提交时触发 submit 事件', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
await wrapper.find('input[placeholder="密码"]').setValue('password123')
await wrapper.find('button[type="submit"]').trigger('click')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0]).toEqual([{
username: 'testuser',
password: 'password123',
remember: false
}])
})
it('表单无效时不触发 submit', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('button[type="submit"]').trigger('click')
expect(wrapper.emitted('submit')).toBeFalsy()
})
it('提交时触发 loading-change 事件', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
await wrapper.find('input[placeholder="密码"]').setValue('password123')
await wrapper.find('button[type="submit"]').trigger('click')
expect(wrapper.emitted('loading-change')).toBeTruthy()
expect(wrapper.emitted('loading-change')[0]).toEqual([true])
// 等待异步完成
await new Promise(resolve => setTimeout(resolve, 1100))
expect(wrapper.emitted('loading-change')[1]).toEqual([false])
})
})
// 3. 插槽测试
describe('Slots', () => {
it('显示 footer 插槽内容', () => {
const wrapper = mount(LoginForm, {
slots: {
footer: '<a href="/register">注册新账号</a>'
}
})
expect(wrapper.find('.footer a').text()).toBe('注册新账号')
})
})
// 4. 验证逻辑测试
describe('Validation', () => {
it('用户名太短时显示错误', async () => {
const wrapper = mount(LoginForm)
const usernameInput = wrapper.find('input[placeholder="用户名"]')
await usernameInput.setValue('a')
await usernameInput.trigger('blur')
expect(wrapper.text()).toContain('用户名至少3个字符')
})
it('用户名正确时清除错误', async () => {
const wrapper = mount(LoginForm)
const usernameInput = wrapper.find('input[placeholder="用户名"]')
await usernameInput.setValue('a')
await usernameInput.trigger('blur')
expect(wrapper.text()).toContain('用户名至少3个字符')
await usernameInput.setValue('abc')
await usernameInput.trigger('blur')
expect(wrapper.text()).not.toContain('用户名至少3个字符')
})
it('密码太短时显示错误', async () => {
const wrapper = mount(LoginForm)
const passwordInput = wrapper.find('input[placeholder="密码"]')
await passwordInput.setValue('123')
await passwordInput.trigger('blur')
expect(wrapper.text()).toContain('密码至少6个字符')
})
})
// 5. 按钮状态测试
describe('Submit Button', () => {
it('表单无效时按钮禁用', async () => {
const wrapper = mount(LoginForm)
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeDefined()
})
it('表单有效时按钮启用', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
await wrapper.find('input[placeholder="密码"]').setValue('password123')
// 触发验证
await wrapper.find('input[placeholder="用户名"]').trigger('blur')
await wrapper.find('input[placeholder="密码"]').trigger('blur')
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeUndefined()
})
it('提交时按钮文字变成"登录中..."', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
await wrapper.find('input[placeholder="密码"]').setValue('password123')
await wrapper.find('input[placeholder="用户名"]').trigger('blur')
await wrapper.find('input[placeholder="密码"]').trigger('blur')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('登录')
await button.trigger('click')
expect(button.text()).toBe('登录中...')
})
})
})
常见问题与解决方案
问题一:异步更新导致断言失败
// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
wrapper.vm.count++
expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})
// ✅ 正确:使用 await
it('updates count', async () => {
wrapper.vm.count++
await wrapper.vm.$nextTick()
expect(wrapper.find('.count').text()).toBe('1')
})
问题二:插槽内容没有正确渲染
// ❌ 错误:没有正确传递插槽内容
const wrapper = mount(DataTable, {
slots: {
'cell-status': '<span class="status">{{ props.value }}</span>'
}
})
// ✅ 正确:使用模板字符串
const wrapper = mount(DataTable, {
slots: {
'cell-status': `
<template #cell-status="{ value }">
<span class="status">{{ value }}</span>
</template>
`
}
})
问题三:事件发射次数断言错误
// ❌ 错误:只检查存在性
expect(wrapper.emitted('click')).toBeTruthy()
// ✅ 正确:检查具体次数和参数
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')?.[0]).toEqual([expectedData])
问题四:过度依赖实现细节
// ❌ 错误:测试内部方法
it('calls validateForm method', () => {
const validateSpy = vi.spyOn(wrapper.vm, 'validateForm')
// ...
expect(validateSpy).toHaveBeenCalled()
})
// ✅ 正确:测试用户可见的行为
it('shows validation error when form is invalid', async () => {
// 操作表单
// 断言错误消息出现
})
组件测试的最佳实践
Props 测试
- 基础渲染
- 默认值
- 不同值
- Props 变化后的响应
- 边界情况(空值、长文本)
事件测试
- 事件是否触发
- 事件参数是否正确
- 事件触发次数
- 事件顺序
- 条件触发(禁用时)
插槽测试
- 默认插槽内容
- 具名插槽内容
- 作用域插槽的 props
- 没有插槽时的行为
结语
组件测试不是测试每一行代码,而是测试组件的行为是否符合预期。 Props 是输入,事件是输出,插槽是扩展点。把握这三个核心,就能写出高效、可靠的组件测试。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!