人人都会AI编程

20.1 单元测试:Vitest + Vue Test Utils

更新时间:2026-07-09

单元测试是保障代码质量的第一道防线——它不依赖真实浏览器、不请求后端接口,只在 Node 环境中快速验证组件逻辑的正确性。Vue 生态下,组合 VitestVue Test Utils 是目前最轻量、最高效的单元测试方案。

为什么选 Vitest?

  • 与 Vite 共享配置:不用额外搭建测试环境,vite.config.ts 里的别名、插件自动生效,开箱即用。
  • 极快的运行速度:基于 esbuild 转换,Watch 模式几乎零延迟。
  • 兼容 Jest 的 APIdescribeitexpect 等语法基本一致,迁移成本很低。
  • 原生支持 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 项目拥有可靠、轻快、无痛的测试体验。