Vue Test Utils 组件测试

1. 组件测试 vs 单元测试

上一篇讲了工具函数和 composables 的单测,这篇看组件——有模板、有 props、有事件、有子组件。

Java 对照

组件测试的三段式

  1. 挂载(mount)—— 渲染组件到虚拟 DOM
  2. 交互(trigger/setValue)—— 模拟点击、输入
  3. 断言(expect)—— 检查渲染结果、抛出的事件
import { mount } from '@vue/test-utils'

const wrapper = mount(Counter, { props: { initial: 5 } })  // 挂载
await wrapper.find('button').trigger('click')              // 交互
expect(wrapper.text()).toContain('6')                       // 断言

记住这三步,几乎所有组件测试都是这个套路。


2. 挂载方式:mount vs shallowMount

2.1 mount:完整挂载

import { mount } from '@vue/test-utils'
import NoteCard from '@/components/NoteCard.vue'

const wrapper = mount(NoteCard, {
  props: { note: { id: 1, title: '测试', summary: '摘要' } },
})

子组件也会真正渲染。适合叶子组件或需要测真实 DOM 结构的场景。

2.2 shallowMount:浅挂载

import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(NoteList)

子组件被替换成占位桩(<note-card-stub />),只测当前组件。适合包含大量子组件的容器组件,避免测试变成"连带测所有子组件"。

什么时候用哪个


3. 第一个组件测试

<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ initial?: number }>()
const emit = defineEmits<{ change: [value: number] }>()

const count = ref(props.initial ?? 0)

const increment = () => {
  count.value++
  emit('change', count.value)
}
</script>

<template>
  <div class="counter">
    <p class="count">{{ count }}</p>
    <button class="btn-inc" @click="increment">+1</button>
  </div>
</template>
// src/components/Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('默认从 0 开始', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('.count').text()).toBe('0')
  })

  it('接受 initial prop', () => {
    const wrapper = mount(Counter, { props: { initial: 10 } })
    expect(wrapper.find('.count').text()).toBe('10')
  })

  it('点击按钮加 1', async () => {
    const wrapper = mount(Counter, { props: { initial: 5 } })
    await wrapper.find('.btn-inc').trigger('click')
    expect(wrapper.find('.count').text()).toBe('6')
  })

  it('点击时抛出 change 事件', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('.btn-inc').trigger('click')
    await wrapper.find('.btn-inc').trigger('click')

    const events = wrapper.emitted('change')
    expect(events).toHaveLength(2)
    expect(events![0]).toEqual([1])
    expect(events![1]).toEqual([2])
  })
})

为什么大量 await:Vue 的 DOM 更新是异步的,trigger 后要等下一个 tick 才能断言。await trigger(...) 内部已经处理好了。


4. 查找元素的 API

// 单个元素
wrapper.find('.selector')       // CSS 选择器
wrapper.find('[data-test="xx"]') // data 属性
wrapper.find(ChildComponent)     // 子组件

// 多个元素
wrapper.findAll('li')

// 断言前先判断存在
expect(wrapper.find('.error').exists()).toBe(true)

4.1 data-test 属性 vs CSS class

<button class="btn btn-primary" data-test="submit-button">提交</button>
// ❌ 容易随 CSS 改动失效
wrapper.find('.btn.btn-primary')

// ✅ data-test 是专门给测试用的
wrapper.find('[data-test="submit-button"]')

推荐:UI 样式 class 可能频繁改,加 data-testdata-testid 给测试用,不受样式变动影响。生产构建可用插件自动移除,不会污染 DOM。


5. 模拟用户交互

// 点击
await button.trigger('click')

// 键盘
await input.trigger('keydown', { key: 'Enter' })
await input.trigger('keydown.enter') // Vue Test Utils 的快捷写法

// 鼠标
await wrapper.trigger('mouseenter')
await wrapper.trigger('mouseleave')

// 提交表单
await form.trigger('submit.prevent')

// 输入
await input.setValue('hello')    // input / textarea / select
await checkbox.setChecked(true)  // 复选框
await radio.setChecked()         // 单选

// 选择
await select.setValue('option2')

setValue vs trigger('input')setValue 是推荐方式,它内部会触发正确的事件序列。


6. 测 props 与响应式

it('prop 变化触发重渲染', async () => {
  const wrapper = mount(Counter, { props: { initial: 0 } })

  await wrapper.setProps({ initial: 100 })
  // 注意: setProps 不会重新执行 setup,count 不会变
  // 除非组件里 watch 了 props
})

坑点initial<script setup> 里是 props.initial ?? 0 直接赋值给 ref——setProps 改 prop 后 count 不会变。这种场景要么:


7. 测 slot

<!-- Dialog.vue -->
<template>
  <div class="dialog">
    <slot name="header">默认标题</slot>
    <slot />
    <slot name="footer" />
  </div>
</template>
it('默认 header', () => {
  const wrapper = mount(Dialog)
  expect(wrapper.text()).toContain('默认标题')
})

it('具名 slot', () => {
  const wrapper = mount(Dialog, {
    slots: {
      header: '自定义标题',
      default: '<p>内容</p>',
      footer: '<button>确定</button>',
    },
  })
  expect(wrapper.text()).toContain('自定义标题')
  expect(wrapper.find('button').text()).toBe('确定')
})

8. 测异步组件 / 接口调用

组件里发请求的场景:

<!-- NoteList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { noteApi } from '@/api/note'

const notes = ref<any[]>([])
const loading = ref(true)

onMounted(async () => {
  const res = await noteApi.page({ page: 1, pageSize: 10 })
  notes.value = res.list
  loading.value = false
})
</script>

<template>
  <div v-if="loading">加载中...</div>
  <ul v-else>
    <li v-for="n in notes" :key="n.id">{{ n.title }}</li>
  </ul>
</template>
import { describe, it, expect, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import NoteList from './NoteList.vue'

vi.mock('@/api/note', () => ({
  noteApi: {
    page: vi.fn().mockResolvedValue({
      list: [
        { id: 1, title: '笔记 A' },
        { id: 2, title: '笔记 B' },
      ],
      total: 2,
    }),
  },
}))

describe('NoteList', () => {
  it('挂载后加载数据', async () => {
    const wrapper = mount(NoteList)
    expect(wrapper.text()).toContain('加载中')

    await flushPromises() // 等所有 Promise 完成

    expect(wrapper.findAll('li')).toHaveLength(2)
    expect(wrapper.text()).toContain('笔记 A')
  })
})

flushPromises() —— 让微任务队列清空,触发所有待处理的 Promise then。这是异步组件测试的关键工具。


9. 测带 Router / Pinia 的组件

9.1 Router

import { createRouter, createMemoryHistory } from 'vue-router'

const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    { path: '/', component: { template: '<div>Home</div>' } },
    { path: '/notes/:id', component: { template: '<div>Note</div>' } },
  ],
})

const wrapper = mount(MyComponent, {
  global: {
    plugins: [router],
  },
})

await router.push('/notes/1')
await router.isReady()

createMemoryHistory 适合测试环境(不操作浏览器 URL)。

9.2 Pinia

import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'

const wrapper = mount(MyComponent, {
  global: {
    plugins: [createTestingPinia({
      createSpy: vi.fn,    // 让 actions 自动变 spy
      initialState: {
        user: { token: 'test-token', info: { username: 'alice', roles: ['admin'] } },
      },
    })],
  },
})

createTestingPinia —— 专门为测试设计的 Pinia,自动 spy actions,可以直接预设 state。

pnpm add -D @pinia/testing

10. 测 Element Plus 组件

Element Plus 组件通常需要全局注册单独 stub

10.1 方案 A:全局注册

// src/__tests__/setup.ts
import ElementPlus from 'element-plus'
import { config } from '@vue/test-utils'

config.global.plugins = [ElementPlus]
// vitest.config.ts
export default defineConfig({
  test: {
    setupFiles: ['./src/__tests__/setup.ts'],
  },
})

优点:测试里直接用 <el-button> 渲染真实组件。 缺点:测试速度慢一点。

10.2 方案 B:Stub 掉

const wrapper = mount(MyForm, {
  global: {
    stubs: {
      'el-button': true,   // 简单 stub
      'el-input': {
        template: '<input :value="modelValue" @input="$emit(`update:modelValue`, $event.target.value)" />',
        props: ['modelValue'],
        emits: ['update:modelValue'],
      },
    },
  },
})

优点:测试快,关注本组件逻辑。 缺点:和真实交互有差距,要自己写 stub 模板。

推荐 A:in4vue 是小项目,全局注册 Element Plus 省心。大项目才考虑 stub 提速。


11. 调试组件测试:看不到页面怎么办

控制台加:

console.log(wrapper.html())

打出当前渲染的 HTML。比干猜快 10 倍。

更好的是Vitest UI 模式

pnpm test:ui

浏览器里可视化看测试,每个组件的 HTML、props、emitted 都一目了然。


12. 常见交互模式

12.1 测表单提交

it('提交表单发请求', async () => {
  const mockSubmit = vi.fn().mockResolvedValue({ id: 1 })
  vi.mock('@/api/note', () => ({ noteApi: { create: mockSubmit } }))

  const wrapper = mount(NoteForm)
  await wrapper.find('[data-test="title-input"]').setValue('我的标题')
  await wrapper.find('[data-test="submit"]').trigger('click')
  await flushPromises()

  expect(mockSubmit).toHaveBeenCalledWith(
    expect.objectContaining({ title: '我的标题' })
  )
})

expect.objectContaining —— 部分匹配,不用把所有字段都写全。

12.2 测条件渲染

it('未登录显示登录按钮', () => {
  const wrapper = mount(Header, {
    global: {
      plugins: [createTestingPinia({
        initialState: { user: { token: '' } },
      })],
    },
  })
  expect(wrapper.find('[data-test="login-btn"]').exists()).toBe(true)
  expect(wrapper.find('[data-test="logout-btn"]').exists()).toBe(false)
})

it('已登录显示登出按钮', () => {
  const wrapper = mount(Header, {
    global: {
      plugins: [createTestingPinia({
        initialState: { user: { token: 'abc', info: { username: 'alice' } } },
      })],
    },
  })
  expect(wrapper.find('[data-test="login-btn"]').exists()).toBe(false)
  expect(wrapper.find('[data-test="logout-btn"]').exists()).toBe(true)
})

12.3 测 v-model

it('双向绑定', async () => {
  const wrapper = mount(MyInput, {
    props: { modelValue: '初始值', 'onUpdate:modelValue': (v: string) => wrapper.setProps({ modelValue: v }) },
  })
  await wrapper.find('input').setValue('新值')
  expect(wrapper.props('modelValue')).toBe('新值')
})

13. 反模式

13.1 测试实现细节

// ❌ 坏:测内部变量名和实现细节
expect((wrapper.vm as any).count).toBe(5)

// ✅ 好:测用户能看到的结果
expect(wrapper.find('.count').text()).toBe('5')

组件怎么实现用户不关心,他们关心屏幕上显示什么、点击后发生什么。测试也应该站在用户视角。

13.2 超长的单个测试

一个 it 里 30 行断言,搞乱了到底在测什么。一个测试测一件事

// ❌ 一个 it 测太多
it('works', async () => {
  // 测初始状态
  expect(...)
  // 点击加号
  await ...
  expect(...)
  // 点击减号
  await ...
  expect(...)
  // 重置
  await ...
  expect(...)
})

// ✅ 拆开
it('initial count is 0', () => { /* ... */ })
it('increment increases count', async () => { /* ... */ })
it('decrement decreases count', async () => { /* ... */ })
it('reset restores initial', async () => { /* ... */ })

失败时能立刻看到"哪件事挂了"。

13.3 大量 vi.mock 堆满文件

一个测试里 mock 10 个模块,读起来像阅读理解。降级

// src/__tests__/mocks/api.ts
export function mockNoteApi(overrides = {}) {
  return {
    page: vi.fn().mockResolvedValue({ list: [], total: 0 }),
    ...overrides,
  }
}

14. 测试粒度建议

组件类型 要不要测 测什么
原子 UI(Button、Tag) ✅ 测 渲染、click 事件
业务组件(NoteCard) ✅ 测 props → 渲染、关键交互
容器页面(NoteList) 🟡 视情况 数据加载、核心流程
全站布局(AdminLayout) ❌ 通常不测 测 e2e 更划算
纯展示(静态文字) ❌ 不测 没逻辑

经验法则有 emit 的组件一定要测 emit,有 props 的组件测 prop → 渲染/行为差异


15. 常见坑点

现象 原因 解法
wrapper.find 找不到元素 可能在异步渲染之后才出现 await flushPromises()
trigger 后断言失败 Vue DOM 更新异步 必须 await trigger(...)
Element Plus 组件报错 没注册 setup.tsconfig.global.plugins = [ElementPlus]
Router 相关报错 没 mock router createMemoryHistory
(wrapper.vm).xxx 报类型错 wrapper.vm 类型弱 as any 或重构成测行为
测试互相干扰 共享状态没清 beforeEachsetActivePinia(createPinia())

16. 心智模型:Arrange-Act-Assert

每个测试三段:

it('...', async () => {
  // Arrange: 准备
  const wrapper = mount(MyComponent, { props: { ... } })

  // Act: 行动
  await wrapper.find('button').trigger('click')

  // Assert: 断言
  expect(wrapper.emitted('submit')).toBeTruthy()
})

Java 对照:Mockito 社区叫 "Given-When-Then" ——一回事。


小练习

  1. NoteCard 组件(props: note)写测试:标题显示、点击抛 click 事件
  2. 给登录表单写测试:空值提交报错、正确填写后调 api
  3. createTestingPinia 测"已登录/未登录"两种状态的顶栏
  4. 给带接口调用的组件写测试,mock noteApi.page 返回假数据
  5. 尝试 pnpm test:ui,体验可视化测试运行

延伸阅读