单元测试是保障代码质量的第一道防线——它不依赖真实浏览器、不请求后端接口,只在 Node 环境中快速验证组件逻辑的正确性。Vue 生态下,组合 Vitest 和 Vue Test Utils 是目前最轻量、最高效的单元测试方案。
为什么选 Vitest?
- 与 Vite 共享配置:不用额外搭建测试环境,
vite.config.ts里的别名、插件自动生效,开箱即用。 - 极快的运行速度:基于 esbuild 转换,Watch 模式几乎零延迟。
- 兼容 Jest 的 API:
describe、it、expect等语法基本一致,迁移成本很低。 - 原生支持 TypeScript、异步、快照、覆盖率:无需安装一堆插件。
环境准备
在一个 Vite + Vue 3 项目中安装:
npm install -D vitest @vue/test-utils happy-dom
在 vite.config.ts 中添加 test 配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
// 模拟 DOM 环境,也可用 jsdom
environment: 'happy-dom',
// 如果组件里用到了全局导入 (如全局组件),在此配置
globals: true
}
})
在 package.json 添加快捷命令:
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
组件渲染测试
最基本的测试:验证组件正确渲染了内容。
<!-- Greeting.vue -->
<template>
<h1>{{ msg }}</h1>
</template>
<script setup>
defineProps({ msg: String })
</script>
// Greeting.test.ts
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'
test('renders props.msg', () => {
const wrapper = mount(Greeting, {
props: { msg: 'Hello Vitest' }
})
// 断言渲染文本
expect(wrapper.text()).toContain('Hello Vitest')
// 也可以直接查询元素
expect(wrapper.find('h1').element.textContent).toBe('Hello Vitest')
})
核心 API 说明:
mount()返回一个 Wrapper 对象,里面包含已挂载的组件 DOM 和一系列断言方法。.text()获取组件的全量文本内容。.find(selector)查询第一个匹配元素,返回 DOMWrapper。.exists()判断元素是否存在。
交互测试
模拟用户操作,触发事件并验证响应。
<!-- Counter.vue -->
<template>
<button @click="count++">{{ count }}</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
// Counter.test.ts
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('increments count on click', async () => {
const wrapper = mount(Counter)
const button = wrapper.find('button')
expect(button.text()).toBe('0')
// 触发点击事件
await button.trigger('click')
expect(button.text()).toBe('1')
// 也可以连续触发
await button.trigger('click')
await button.trigger('click')
expect(button.text()).toBe('3')
})
注意事项:
trigger()返回一个 Promise,需要用await等待 Vue 完成 DOM 更新,否则断言拿到的还是旧值。- 对于需要传递参数的事件,
trigger('customEvent', payload)。
表单与 v-model 测试
<!-- LoginForm.vue -->
<template>
<form @submit.prevent="onSubmit">
<input v-model="username" />
<button type="submit">登录</button>
</form>
</template>
<script setup>
import { ref } from 'vue'
const username = ref('')
const emit = defineEmits(['submit'])
function onSubmit() {
if (username.value) emit('submit', { username: username.value })
}
</script>
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'
test('emits submit with username', async () => {
const wrapper = mount(LoginForm)
const input = wrapper.find('input')
const form = wrapper.find('form')
// 模拟用户输入
await input.setValue('Alice')
expect(input.element.value).toBe('Alice')
// 触发表单提交
await form.trigger('submit.prevent') // 注意 .prevent 可能不会被真实触发,最好直接触发原生事件
// 推荐:用 input 事件 + 按钮 click
})
更真实的写法:直接通过 input.setValue 修改值,再用 wrapper.find('button') 点击提交,然后检查 wrapper.emitted('submit')。
test('emits submit event on valid form', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input').setValue('Bob')
await wrapper.find('button').trigger('click')
// 断言 emit 事件及参数
expect(wrapper.emitted()).toHaveProperty('submit')
expect(wrapper.emitted('submit')[0]).toEqual([{ username: 'Bob' }])
})
快照测试
快照用于捕捉组件在某个状态下的完整渲染输出,防止意外修改。
import { mount } from '@vue/test-utils'
import Notification from './Notification.vue'
test('renders correctly', () => {
const wrapper = mount(Notification, {
props: { type: 'success', message: '操作成功' }
})
// 生成 HTML 快照
expect(wrapper.html()).toMatchSnapshot()
})
首次运行会在 snapshots/ 目录生成一个 .snap 文件。后续运行会对比当前渲染结果与快照是否一致,不一致则测试失败。当有目的性修改后,可以用 vitest --update 更新快照。
注意:快照要谨慎使用,避免过大或过于频繁的快照(例如每个组件都拍一张),否则维护成本会激增。
组合式函数(Composables)单元测试
组合式函数通常依赖 Vue 的响应式 API(ref、reactive)和生命周期钩子。直接调用会被报错,因为不在 setup 上下文里。最可靠的方案是在一个测试用的包裹组件中调用 composable,然后通过组件实例访问暴露的数据。
假设有一个 useMouse composable:
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
测试时,我们写一个简单的组件用它,然后 mount:
// useMouse.test.ts
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { useMouse } from './useMouse'
// 构造测试组件
const TestComponent = defineComponent({
setup() {
const { x, y } = useMouse()
return { x, y }
},
template: `<div>{{ x }},{{ y }}</div>`
})
test('tracks mouse position', async () => {
const wrapper = mount(TestComponent)
// 模拟鼠标事件
window.dispatchEvent(new MouseEvent('mousemove', {
pageX: 200,
pageY: 300
}))
// 等待 DOM 更新
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('200,300')
})
更直接的方式:如果 composable 不依赖生命周期,可以直接在测试中调用,但要包在 withSetup 这类辅助函数里(不推荐初学者自己写,容易踩坑)。对于纯逻辑的 composable(如根据输入返回计算属性),可以直接执行:
import { ref } from 'vue'
import { useCounter } from './useCounter'
test('useCounter increments', () => {
// 注意:如果 composable 内部创建 ref/reactive,可以直接在测试中调用
const { count, increment } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
只是当 composable 内部使用了 onMounted 等钩子时,必须通过真实挂载组件来触发。
测试覆盖率与持续集成
在 vite.config.ts 中开启覆盖率:
test: {
coverage: {
provider: 'istanbul', // 或 'c8'
reporter: ['text', 'html'],
include: ['src/**/*.{ts,vue}']
}
}
运行 vitest run --coverage 即可在终端和 coverage/ 目录查看报告。可配合 CI 管道设置覆盖率底线。
真正的单元测试不在数量多,而在覆盖核心逻辑、可维护、快速反馈。对于 Vue 组件,优先测试:
- Props 是否正确渲染;
- 事件是否正确触发;
- 关键条件分支(登录/未登录、权限不同);
- 异步操作后的状态变化。
这套 Vitest + Vue Test Utils 的组合,能让你的 Vue 3 项目拥有可靠、轻快、无痛的测试体验。