Vue 基础体系 · 第 50/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 组件测试:挂载、用户交互、异步更新和契约验证
组件测试不是把组件“渲染出来,再检查几个文本”这么简单。一个 Vue 组件同时包含输入、状态、DOM 输出、事件、插槽、生命周期和异步任务;测试需要验证这些边界之间的因果关系:
本文使用 Vue 3、Composition API、TypeScript、Vite、Vitest 和 Vue Test Utils,重点说明四件事:
- 如何正确挂载组件,并理解挂载过程创建了什么。
- 如何模拟用户交互,而不是直接修改组件内部状态。
- 如何等待 Vue 的异步更新、外部 Promise 和定时器。
- 如何把组件测试写成契约验证,避免测试被内部实现绑架。
一、测试环境:Vue 组件测试到底运行在哪里
Vue 组件测试通常不在真实浏览器中执行,而是在 Node.js 中配合 DOM 模拟环境运行。Vite 负责项目构建和模块解析,Vitest 使用 Vite 的配置与转换能力执行测试;Vue Test Utils 则提供 mount、find、trigger 等组件测试 API。
一个最小配置如下:
npm install -D vitest @vue/test-utils jsdom
假设项目已经安装了 vue、typescript 和 @vitejs/plugin-vue,可以在 vite.config.ts 中加入测试配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
},
})
environment: 'jsdom' 的含义是:测试代码可以访问类似浏览器的 document、HTMLElement 和事件系统。它并不等价于真实浏览器:
- CSS 布局、真实绘制和视觉像素通常不会被验证;
- 某些浏览器 API 需要手动 mock;
- 浏览器的原生行为可能与 jsdom 存在差异;
- 组件测试主要验证 DOM 结构、事件和状态,不是完整的跨浏览器验收。
在 package.json 中增加脚本:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
然后执行:
npm test
vitest run 会执行一次并退出;vitest 会监听文件变化并重新执行相关测试。测试失败时应先区分失败来源:是 TypeScript 类型错误、模块解析错误、DOM 环境缺失,还是组件行为断言失败。它们对应的修复路径不同。
二、什么是“挂载”:从组件定义到可观察实例
2.1 mount 创建了一个测试中的 Vue 应用
Vue 组件源码只是一个组件定义,不能直接查询它的 DOM。Vue Test Utils 的 mount 会创建一个测试用 Vue 应用,执行组件的 setup、渲染函数和生命周期,并返回一个 VueWrapper:
import { mount } from '@vue/test-utils'
import SearchBox from './SearchBox.vue'
const wrapper = mount(SearchBox, {
props: {
modelValue: 'vue',
},
})
挂载过程可以抽象为:
组件定义
↓
创建测试应用
↓
注入 props、插件、全局配置
↓
执行 setup()
↓
渲染虚拟 DOM
↓
生成 jsdom DOM
↓
返回 VueWrapper
wrapper 不是 DOM 节点本身,而是测试工具提供的包装器。常见操作包括:
wrapper.find('input') // 查找一个后代节点
wrapper.findAll('li') // 查找多个节点
wrapper.get('button') // 查找不到时直接抛错
wrapper.text() // 获取文本内容
wrapper.attributes('aria-label')
wrapper.emitted() // 获取组件发出的事件
await wrapper.setProps({ ... }) // 更新 props
await wrapper.unmount() // 卸载组件
find 找不到元素时返回一个空包装器,后续操作可能在更晚的位置失败;get 找不到元素时立即失败。因此,当元素本来就应该存在时,get 通常能给出更接近实际问题的错误信息。
2.2 mount 和 shallowMount 的差异
mount 会渲染子组件,适合验证父子组件之间的真实集成:
const wrapper = mount(ParentComponent)
shallowMount 会把子组件替换为 stub:
const wrapper = shallowMount(ParentComponent)
它适合在父组件测试中隔离复杂子组件,但会隐藏真实的子组件 DOM、插槽渲染和父子交互路径。例如,父组件向子组件传递的 prop 可能是正确的,但子组件内部是否正确响应,shallowMount 无法证明。
因此,二者验证的对象不同:
mount:验证当前组件及其真实子树的可观察行为;shallowMount:验证当前组件与子组件的边界,不验证子组件内部实现。
shallowMount 不是“更快所以总是更好”。如果测试目标是“点击父组件中的按钮后,真实子组件显示了结果”,就不应把该子组件 stub 掉。
2.3 一个可测试的受控组件
下面实现一个 SearchBox.vue。它是一个受控组件:
modelValue是父组件传入的当前值;- 输入框变化时发出
update:modelValue; - 点击按钮时发出
search; - 组件本身不擅自修改
modelValue。
<!-- src/components/SearchBox.vue -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
search: [query: string]
}>()
function handleInput(event: Event) {
const target = event.target
if (!(target instanceof HTMLInputElement)) {
return
}
emit('update:modelValue', target.value)
}
function handleSearch() {
emit('search', props.modelValue)
}
</script>
<template>
<form @submit.prevent="handleSearch">
<label for="search-input">搜索</label>
<input
id="search-input"
:value="modelValue"
:disabled="disabled"
@input="handleInput"
>
<button
type="submit"
:disabled="disabled"
>
搜索
</button>
</form>
</template>
这里的 modelValue 和 update:modelValue 遵循 Vue 3 的 v-model 约定。组件发出更新事件并不意味着组件内部的 prop 已经改变;真正改变 prop 的是父组件。这个区别会直接影响测试写法。
父组件的简化逻辑相当于:
<SearchBox
v-model="query"
@search="search"
/>
输入过程是:
用户输入 "vue"
↓
SearchBox 发出 update:modelValue("vue")
↓
父组件修改 query
↓
父组件重新传入 modelValue="vue"
↓
SearchBox 的 input value 更新
如果测试只挂载 SearchBox,就没有真实父组件自动接收事件并回传 prop。因此,测试必须明确自己是在验证“组件发出的事件”,还是在验证“父子组件联动后的最终 DOM”。
三、用户交互:测试用户能做什么,而不是组件内部有什么
3.1 trigger 模拟 DOM 事件
测试用户点击按钮的基本写法是:
await wrapper.get('button').trigger('click')
trigger 会在目标节点上派发指定事件。await 很重要,因为事件处理函数可能修改响应式状态,而 Vue 不一定在当前同步调用栈中立即完成 DOM 更新。
测试 SearchBox 的输入和搜索契约:
// tests/SearchBox.spec.ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SearchBox from '../src/components/SearchBox.vue'
describe('SearchBox', () => {
it('在输入变化时发出 update:modelValue', async () => {
const wrapper = mount(SearchBox, {
props: {
modelValue: '',
},
})
await wrapper.get('input').setValue('vue')
expect(wrapper.emitted('update:modelValue')).toEqual([
['vue'],
])
})
it('提交表单时发出当前查询字符串', async () => {
const wrapper = mount(SearchBox, {
props: {
modelValue: 'vue',
},
})
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('search')).toEqual([
['vue'],
])
})
})
这里使用 setValue 而不是手动执行:
wrapper.vm.someInternalState = 'vue'
原因是 setValue 更接近真实用户路径:修改输入元素的值,再派发 input 事件。相反,直接访问 wrapper.vm 会绕过 DOM 事件和组件公开边界,无法证明用户真的能完成这个操作。
3.2 setValue 处理输入框的两个动作
对文本输入框来说:
await input.setValue('vue')
通常包含两个步骤:
- 设置 DOM 元素的
value; - 触发
input事件。
因此它适合测试 v-model 或自定义输入事件。
但对于受控组件,需要注意以下反例:
const wrapper = mount(SearchBox, {
props: {
modelValue: '',
},
})
await wrapper.get('input').setValue('vue')
expect((wrapper.get('input').element as HTMLInputElement).value)
.toBe('vue')
这个断言在某些实现下可能暂时成立,但它不能证明父组件已经更新了 modelValue。如果组件重新渲染,仍然传入空字符串,DOM 也可能被重新设置为空字符串。
若要验证父子联动,应挂载一个测试宿主组件:
import { defineComponent, h, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import SearchBox from '../src/components/SearchBox.vue'
it('通过 v-model 完成父子组件联动', async () => {
const Host = defineComponent({
components: { SearchBox },
setup() {
const query = ref('')
return { query }
},
template: `
<SearchBox v-model="query" />
<output data-testid="query">{{ query }}</output>
`,
})
const wrapper = mount(Host)
await wrapper.get('input').setValue('vue')
expect(wrapper.get('[data-testid="query"]').text()).toBe('vue')
})
这个测试验证的是完整数据流:
input
→ SearchBox 发出 update:modelValue
→ Host 更新 query
→ Host 重新传入 modelValue
→ DOM 与 query 同步
3.3 事件提交和原生默认行为
如果组件使用:
<form @submit.prevent="handleSearch">
测试表单提交时应触发 submit:
await wrapper.get('form').trigger('submit')
而不是只点击按钮。点击按钮可能覆盖“按钮点击处理”,但不能完整验证表单提交路径。
反过来,如果测试要求验证浏览器默认行为,例如页面跳转、原生表单提交或文件选择,就不能只依赖 jsdom 中的组件测试。此时应使用浏览器端测试或端到端测试,因为组件测试环境不会真实执行完整浏览器导航。
四、Vue 的异步更新:nextTick 等待的到底是什么
4.1 响应式状态变化不是同步 DOM 变化
Vue 的响应式赋值和 DOM 更新并不是同一个时刻:
state.value = '已完成'
// 此时响应式值已变,但 DOM 可能尚未更新
await nextTick()
// Vue 已完成当前更新批次的 DOM 刷新
Vue 会将多个同步状态修改合并到更新队列中,以减少重复渲染。nextTick 等待的是 Vue 自己的渲染调度。
例如:
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
function show() {
visible.value = true
}
</script>
<template>
<button @click="show">显示</button>
<p v-if="visible">内容已显示</p>
</template>
测试可以写成:
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ToggleMessage from '../src/components/ToggleMessage.vue'
it('点击后显示内容', async () => {
const wrapper = mount(ToggleMessage)
expect(wrapper.find('p').exists()).toBe(false)
await wrapper.get('button').trigger('click')
expect(wrapper.get('p').text()).toBe('内容已显示')
})
Vue Test Utils 的 trigger 已经返回一个等待更新的 Promise,因此这里不需要显式调用 nextTick。如果测试通过其他方式修改状态,或者使用 setProps 后需要明确等待,也可以写:
import { nextTick } from 'vue'
wrapper.vm.visible = true
await nextTick()
不过,直接访问 wrapper.vm 只适用于测试没有更好公开入口的内部机制;验证用户行为时,优先通过 DOM 和事件完成操作。
4.2 nextTick 不会等待外部 Promise
考虑一个异步组件:
<!-- src/components/ResultsPanel.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
export interface Result {
id: number
title: string
}
const props = defineProps<{
query: string
fetchResults: (query: string) => Promise<Result[]>
}>()
const results = ref<Result[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let requestId = 0
async function loadResults(query: string) {
const currentRequestId = ++requestId
loading.value = true
error.value = null
try {
const nextResults = await props.fetchResults(query)
if (currentRequestId !== requestId) {
return
}
results.value = nextResults
} catch {
if (currentRequestId === requestId) {
results.value = []
error.value = '加载失败'
}
} finally {
if (currentRequestId === requestId) {
loading.value = false
}
}
}
watch(
() => props.query,
query => {
void loadResults(query)
},
{ immediate: true },
)
</script>
<template>
<section aria-label="搜索结果">
<p v-if="loading" role="status">加载中</p>
<p v-else-if="error" role="alert">
{{ error }}
</p>
<ul v-else-if="results.length > 0">
<li v-for="result in results" :key="result.id">
{{ result.title }}
</li>
</ul>
<p v-else>没有结果</p>
</section>
</template>
这里存在两层异步:
props.query 改变
↓
watch 回调进入 loadResults
↓
fetchResults 返回外部 Promise
↓
Promise 完成
↓
results.value 被赋值
↓
Vue 调度 DOM 更新
nextTick 只能覆盖最后一步,不能让 fetchResults 提前完成。等待外部 Promise 时应使用 Vue Test Utils 提供的 flushPromises:
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ResultsPanel from '../src/components/ResultsPanel.vue'
describe('ResultsPanel', () => {
it('加载完成后显示结果', async () => {
const fetchResults = vi.fn().mockResolvedValue([
{ id: 1, title: 'Vue' },
{ id: 2, title: 'Vite' },
])
const wrapper = mount(ResultsPanel, {
props: {
query: '前端',
fetchResults,
},
})
expect(wrapper.get('[role="status"]').text()).toBe('加载中')
expect(fetchResults).toHaveBeenCalledWith('前端')
await flushPromises()
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.findAll('li').map(item => item.text()))
.toEqual(['Vue', 'Vite'])
})
})
flushPromises 的作用是推进当前测试中已创建的 Promise,使其回调执行。若 Promise 回调中又触发了 Vue 更新,在复杂测试中可以显式补充:
await flushPromises()
await nextTick()
通常 flushPromises 与 Vue Test Utils 的更新等待已经足够,但显式拆开能帮助诊断到底是外部 Promise 还没完成,还是 Vue DOM 更新还没完成。
4.3 异步失败必须测试错误状态
只测试成功路径无法证明组件在失败时不会永久显示“加载中”。测试拒绝的 Promise:
it('请求失败后显示错误状态', async () => {
const fetchResults = vi
.fn()
.mockRejectedValue(new Error('network error'))
const wrapper = mount(ResultsPanel, {
props: {
query: 'vue',
fetchResults,
},
})
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('加载失败')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.findAll('li')).toHaveLength(0)
})
这个测试验证了完整的状态转换:
初始:loading = true, error = null
请求失败:
results = []
error = "加载失败"
loading = false
如果组件只在 catch 中设置 error,没有在 finally 中设置 loading = false,测试就会发现错误提示和“加载中”同时存在,或者错误后仍然无法结束加载。
五、并发异步请求:旧结果不能覆盖新结果
5.1 为什么请求顺序不等于响应顺序
用户快速修改查询条件时,可能出现:
t1:query = "vue",发出请求 A
t2:query = "vite",发出请求 B
t3:请求 B 先返回
t4:请求 A 后返回
如果组件无条件地写入结果,最终显示的可能是旧查询 "vue" 的结果。
这是一种竞态条件。请求的发出顺序是:
但响应顺序可能是:
若不做保护,最终状态由最后完成的请求决定,而不是由最新查询决定。
ResultsPanel 使用递增的 requestId:
const currentRequestId = ++requestId
每次请求开始时生成一个编号,只有编号仍然等于当前 requestId 的请求才允许写入状态。后发请求的编号更大,因此旧请求返回时会被丢弃。
5.2 用 deferred Promise 重现竞态
可以手动创建一个“由测试决定何时完成”的 Promise:
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
测试旧请求不能覆盖新请求:
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ResultsPanel, { type Result } from '../src/components/ResultsPanel.vue'
it('新请求返回后,旧请求不能覆盖结果', async () => {
const first = deferred<Result[]>()
const second = deferred<Result[]>()
const fetchResults = vi.fn((query: string) => {
if (query === 'vue') {
return first.promise
}
return second.promise
})
const wrapper = mount(ResultsPanel, {
props: {
query: 'vue',
fetchResults,
},
})
await wrapper.setProps({ query: 'vite' })
second.resolve([
{ id: 2, title: 'Vite 结果' },
])
await flushPromises()
expect(wrapper.findAll('li').map(item => item.text()))
.toEqual(['Vite 结果'])
first.resolve([
{ id: 1, title: 'Vue 旧结果' },
])
await flushPromises()
expect(wrapper.findAll('li').map(item => item.text()))
.toEqual(['Vite 结果'])
})
这个测试的关键不是“调用了几次 mock”,而是验证并发条件下的最终可观察行为。真实项目中也可以使用 AbortController 取消旧请求,但取消本身不是防止状态污染的充分条件:请求可能已经完成、取消可能不被底层库支持,仍然需要在结果写入前确认请求是否过期。
六、定时器与异步更新的组合
如果组件使用防抖、轮询或延迟提示,Promise 和定时器需要分别推进。
例如:
import { onMounted, onUnmounted, ref } from 'vue'
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
timer = setTimeout(() => {
visible.value = true
}, 300)
})
onUnmounted(() => {
if (timer) {
clearTimeout(timer)
}
})
测试时:
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('延迟显示', () => {
afterEach(() => {
vi.useRealTimers()
})
it('300ms 后显示提示', async () => {
vi.useFakeTimers()
const wrapper = mount(DelayedMessage)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(300)
await nextTick()
expect(wrapper.get('[role="status"]').text()).toBe('已显示')
})
})
这里的顺序是:
useFakeTimers接管测试中的计时器;advanceTimersByTimeAsync(300)让 300ms 定时器到期;nextTick等待定时器回调触发的 Vue DOM 更新。
不要在使用 fake timers 后忘记恢复:
afterEach(() => {
vi.useRealTimers()
})
否则后续测试可能继承假的计时器,表现为 Promise、动画或超时逻辑异常。若回调中同时产生外部 Promise,还需要在推进计时器后再执行 flushPromises()。
七、契约验证:测试组件对外承诺什么
7.1 组件契约的形式化表示
组件可以抽象为一个边界:
其中:
- :输入,包括 props、插槽、依赖和用户事件;
- :输出,包括 DOM、emits、可访问性属性和错误状态;
- :环境行为,包括网络请求、时间、路由、存储和插件。
对给定输入 和环境 ,组件应满足某个可观察条件:
其中 是契约断言。例如:
modelValue = "vue"时,输入框应显示"vue";- 用户输入
"vite"时,应发出update:modelValue("vite"); - 请求未完成时,应存在
role="status"; - 请求失败时,应存在
role="alert"; - 新请求完成后,旧请求不能覆盖结果。
契约测试关注 P 是否成立,而不是组件内部用了 ref、computed 还是某个函数名。
7.2 Props:验证输入如何影响可观察输出
it('根据 modelValue 显示输入值,并支持 disabled', () => {
const wrapper = mount(SearchBox, {
props: {
modelValue: 'vue',
disabled: true,
},
})
const input = wrapper.get('input').element as HTMLInputElement
const button = wrapper.get('button').element as HTMLButtonElement
expect(input.value).toBe('vue')
expect(input.disabled).toBe(true)
expect(button.disabled).toBe(true)
})
这个测试没有检查 wrapper.vm.modelValue,因为 prop 本身是否存在并不是用户契约;用户真正能观察到的是输入框的值和禁用状态。
还应验证边界值:
it('空查询也能正常渲染', () => {
const wrapper = mount(SearchBox, {
props: {
modelValue: '',
},
})
expect(wrapper.get('input').attributes('aria-label')).toBeUndefined()
expect(wrapper.get('button').text()).toBe('搜索')
})
如果组件没有 aria-label,这里的断言没有价值,只是在记录当前实现。契约应优先选择语义角色、可见文本、表单标签等稳定接口:
expect(wrapper.get('label').text()).toBe('搜索')
7.3 Emits:验证输出事件和 payload
Vue 的事件名和 payload 是组件与父组件之间的协议。测试不仅要检查“事件被发出”,还要检查参数:
it('search 事件携带当前查询值', async () => {
const wrapper = mount(SearchBox, {
props: {
modelValue: 'typescript',
},
})
await wrapper.get('button').trigger('click')
const events = wrapper.emitted('search')
expect(events).toHaveLength(1)
expect(events?.[0]).toEqual(['typescript'])
})
只写:
expect(wrapper.emitted('search')).toBeTruthy()
只能证明事件发生过,不能证明父组件收到的数据正确。对于事件驱动的组件,payload 往往比事件是否发生更重要。
7.4 DOM:优先查询用户语义
以下查询方式体现了不同稳定性:
wrapper.get('button')
wrapper.get('input')
wrapper.get('[role="alert"]')
wrapper.get('[data-testid="result-list"]')
wrapper.get('.internal-loading-class')
通常优先选择:
- 可访问角色,如
role="status"、role="alert"; - 表单标签和按钮文本;
- 对测试确有必要的
data-testid; - 最后才是 CSS class 或内部 DOM 层级。
CSS class 经常因为样式重构而变化,而且它通常不是组件契约的一部分。若加载状态需要被辅助技术识别,代码本身就应提供:
<p role="status">加载中</p>
测试这个角色,同时也推动组件拥有更明确的可访问性语义。
7.5 Slots:验证内容投影,而不是内部实现
如果组件提供插槽:
<template>
<section>
<slot name="header">
默认标题
</slot>
<slot />
</section>
</template>
测试插槽契约:
const wrapper = mount(Card, {
slots: {
header: '<h2>自定义标题</h2>',
default: '<p>正文</p>',
},
})
expect(wrapper.get('h2').text()).toBe('自定义标题')
expect(wrapper.get('p').text()).toBe('正文')
这个测试验证的是“调用方传入的内容被渲染到正确位置”。不应断言插槽内部一定包裹了三层 div,除非这些层级本身是对外依赖的语义结构。
7.6 defineExpose 和 wrapper.vm 的边界
在 <script setup> 中,组件内部绑定默认不会成为父组件通过模板 ref 直接访问的公开实例 API。如果确实要公开方法,可以显式使用:
defineExpose({
focus,
})
这时 focus 就成为有意设计的公开契约,可以测试:
const wrapper = mount(SearchBox)
await wrapper.vm.$nextTick()
// 只有在该方法被明确 expose 且类型可见时,才考虑调用公开 API
但测试内部 ref、私有函数名或未公开状态会带来较高维护成本。重构实现而不改变用户行为时,这类测试会先失败,无法区分“功能坏了”还是“实现换了”。
八、生命周期和资源清理
挂载不仅执行渲染,还会触发生命周期:
mount
→ setup
→ beforeMount
→ mounted
→ 更新
→ beforeUnmount
→ unmounted
依赖 onMounted 发起请求的组件,测试必须在挂载后处理异步结果。依赖 onUnmounted 清理定时器、事件监听器或订阅的组件,则需要验证卸载后的行为。
测试之间应隔离组件实例:
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/vue' // 仅在使用 Testing Library 时
afterEach(() => {
// 使用 Vue Test Utils 时,通常在测试中显式 wrapper.unmount()
})
Vue Test Utils 不应依赖一个测试中创建的 wrapper 在所有环境下自动清理。可以使用:
let wrapper: ReturnType<typeof mount>
afterEach(() => {
wrapper?.unmount()
})
如果组件注册了全局事件监听器却没有在卸载时移除,后续测试可能出现重复回调。这种故障通常表现为:
- 第一个测试通过,第二个测试事件触发两次;
- mock 函数调用次数不断增加;
- 测试顺序改变后结果不稳定。
因此,生命周期测试的重点不是单纯覆盖 onUnmounted,而是验证资源是否真的停止影响外部环境。
九、错误路径:不要只测试“请求成功”
异步组件至少应区分以下状态:
idle 初始状态或尚未开始
loading 请求进行中
success 请求成功
empty 成功但结果为空
error 请求失败
这些状态不是互斥的文本分支,而是对用户行为有影响的协议。例如:
loading时按钮是否禁用;error时是否仍显示旧结果;empty是否与error使用不同文案;- 组件卸载后,已完成的请求是否还能写入状态;
- 查询变化时,旧请求结果是否会覆盖新结果。
对 ResultsPanel,成功、空结果和失败应分开验证:
it('成功但没有结果时显示空状态', async () => {
const fetchResults = vi.fn().mockResolvedValue([])
const wrapper = mount(ResultsPanel, {
props: {
query: '不存在的内容',
fetchResults,
},
})
await flushPromises()
expect(wrapper.get('p').text()).toBe('没有结果')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
如果使用 wrapper.text() 做断言:
expect(wrapper.text()).toContain('没有结果')
它可能同时包含隐藏分支、布局文本或其他区域内容。更精确的节点查询可以避免断言与组件其他文本耦合。
十、Mock 依赖时验证调用边界
ResultsPanel 接收 fetchResults 作为 prop,而不是在组件内部直接导入网络模块。这种依赖注入让测试可以控制:
- 请求参数;
- 成功结果;
- 失败原因;
- 完成顺序;
- 是否永远 pending。
最基本的调用契约如下:
expect(fetchResults).toHaveBeenCalledTimes(1)
expect(fetchResults).toHaveBeenCalledWith('vue')
但 mock 不是越多越好。若每个测试都断言内部函数被调用的精确次数,组件将很难增加缓存、重试或请求合并逻辑。只有当“调用次数或参数”本身是契约时才应断言,例如:
- 查询变化必须发出一次请求;
- 相同查询不应重复请求;
- 提交时必须带当前查询;
- 旧请求必须被取消或忽略。
如果组件直接使用 fetch、Axios 或路由模块,也可以在模块边界 mock。但应避免让每个组件测试都重复理解网络库的细节。更大范围的 HTTP 契约适合使用 MSW 等请求拦截工具;组件测试只需要验证组件如何处理成功和失败的响应。
十一、常见失败写法与诊断方式
11.1 断言发生在 Vue 更新之前
失败表现:
await wrapper.get('button').trigger('click')
expect(wrapper.find('.result').exists()).toBe(true)
如果点击处理函数中还有异步操作,trigger 只能等待同步事件处理和 Vue 当前更新,不能等待网络 Promise。
诊断步骤:
await wrapper.get('button').trigger('click')
console.log(wrapper.html())
await flushPromises()
await nextTick()
expect(wrapper.find('.result').exists()).toBe(true)
如果第一次没有结果、第二次有结果,说明问题是外部异步任务;如果两次都没有结果,应检查事件是否触发、mock 是否 resolve、组件条件分支是否成立。
11.2 直接修改 wrapper.vm
失败写法:
wrapper.vm.loading = false
expect(wrapper.text()).toContain('完成')
它绕过了用户输入、事件、业务函数和公开 API,只证明测试可以修改实现内部。这样的测试无法发现按钮事件绑定错误,也无法证明真实数据流正确。
更接近行为的写法是:
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.get('[role="status"]').exists()).toBe(false)
若必须测试内部状态,应先确认该状态确实是组件公开的开发者 API,而不是为了方便断言才暴露出来的变量。
11.3 用快照替代行为验证
快照可以记录某次渲染树,但它不能自动说明:
- 用户点击后为何出现变化;
- 哪个事件被发出;
- 异步失败时状态是否正确;
- 旧请求是否覆盖新请求。
大快照还容易出现“更新快照即修复”的假象。组件测试中,针对角色、文本、属性、事件 payload 和状态转换的断言,通常比完整快照更能表达契约。
11.4 把网络请求真实发到外部环境
测试不应依赖真实网络:
const wrapper = mount(ResultsPanel, {
props: {
query: 'vue',
fetchResults: realProductionApi,
},
})
这种测试受网络、服务状态、数据变化和认证环境影响,失败时难以定位。组件测试应控制依赖结果;真实 API 的连通性和协议则由集成测试或端到端测试验证。
11.5 忽略未处理的 Promise 拒绝
如果测试中的 mock 返回拒绝 Promise,但组件没有捕获,测试可能出现未处理拒绝警告,甚至在不同运行器配置下表现不同。组件应明确处理失败:
try {
await load()
} catch (error) {
errorMessage.value = normalizeError(error)
}
测试也应等待拒绝真正执行:
await expect(fetchResults).not.toHaveBeenCalledWith(...)
await flushPromises()
不能在 Promise 尚未完成时断言错误 DOM 已经出现。
十二、一次完整的组件测试应该验证什么
以 SearchBox 为例,它的对外契约可以写成:
| 输入或动作 | 预期可观察结果 |
|---|---|
modelValue = "vue" |
输入框显示 vue |
disabled = true |
输入框和按钮不可操作 |
用户输入 vite |
发出 update:modelValue("vite") |
| 用户提交表单 | 发出 search(当前值) |
| 用户按 Enter | 走表单提交路径,而不是额外复制一套逻辑 |
以 ResultsPanel 为例,契约还包括异步状态:
| 条件 | 预期结果 |
|---|---|
| 初始请求未完成 | 显示 role="status" |
| 请求成功且有数据 | 显示结果列表 |
| 请求成功但为空 | 显示空状态 |
| 请求失败 | 显示 role="alert",结束 loading |
| 新请求先完成 | 显示新请求结果 |
| 旧请求后完成 | 不得覆盖新结果 |
这些断言共同覆盖了组件的主要故障路径,而不是仅仅证明模板曾经渲染过。
十三、组件测试与其他测试层次的边界
组件测试最适合验证:
- props 到 DOM 的映射;
- 用户事件到 emits 或状态变化的路径;
- loading、success、empty、error 等状态;
- 插槽和可访问性语义;
- 组件与依赖之间的调用契约;
- 并发异步任务的结果保护。
它不适合独自承担:
- 浏览器真实布局和视觉回归;
- 多页面路由完整跳转;
- 真实后端、认证和数据库集成;
- 不同浏览器的原生行为差异;
- 大规模业务流程的端到端验证。
因此,一个合理的测试分层是:
单元测试
验证纯函数、状态转换和数据处理
组件测试
验证 props、DOM、用户交互、emits、异步状态
集成测试
验证组件与路由、状态管理、HTTP mock、插件的组合
端到端测试
在真实浏览器中验证完整用户流程和部署环境
组件测试的核心不是覆盖每一行代码,而是把组件边界写成可以执行的协议:输入如何进入,状态如何变化,用户能观察到什么,组件向外发出什么,以及异步任务在成功、失败和乱序完成时是否仍然保持正确。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 单元测试:Vitest、Composable、时间、网络和稳定断言
- 下一篇:Vue 端到端测试:Playwright、登录态、网络、并行和失败证据
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论