Vue 基础体系 · 第 50/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 组件测试:挂载、用户交互、异步更新和契约验证

组件测试不是把组件“渲染出来,再检查几个文本”这么简单。一个 Vue 组件同时包含输入、状态、DOM 输出、事件、插槽、生命周期和异步任务;测试需要验证这些边界之间的因果关系:

输入+环境状态变化DOM / 事件输出\text{输入} + \text{环境} \rightarrow \text{状态变化} \rightarrow \text{DOM / 事件输出}

本文使用 Vue 3、Composition API、TypeScript、Vite、Vitest 和 Vue Test Utils,重点说明四件事:

  1. 如何正确挂载组件,并理解挂载过程创建了什么。
  2. 如何模拟用户交互,而不是直接修改组件内部状态。
  3. 如何等待 Vue 的异步更新、外部 Promise 和定时器。
  4. 如何把组件测试写成契约验证,避免测试被内部实现绑架。

一、测试环境:Vue 组件测试到底运行在哪里

Vue 组件测试通常不在真实浏览器中执行,而是在 Node.js 中配合 DOM 模拟环境运行。Vite 负责项目构建和模块解析,Vitest 使用 Vite 的配置与转换能力执行测试;Vue Test Utils 则提供 mountfindtrigger 等组件测试 API。

一个最小配置如下:

npm install -D vitest @vue/test-utils jsdom

假设项目已经安装了 vuetypescript@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' 的含义是:测试代码可以访问类似浏览器的 documentHTMLElement 和事件系统。它并不等价于真实浏览器:

  • 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 mountshallowMount 的差异

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>

这里的 modelValueupdate: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')

通常包含两个步骤:

  1. 设置 DOM 元素的 value
  2. 触发 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" 的结果。

这是一种竞态条件。请求的发出顺序是:

ABA \prec B

但响应顺序可能是:

BAB \prec A

若不做保护,最终状态由最后完成的请求决定,而不是由最新查询决定。

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('已显示')
  })
})

这里的顺序是:

  1. useFakeTimers 接管测试中的计时器;
  2. advanceTimersByTimeAsync(300) 让 300ms 定时器到期;
  3. nextTick 等待定时器回调触发的 Vue DOM 更新。

不要在使用 fake timers 后忘记恢复:

afterEach(() => {
  vi.useRealTimers()
})

否则后续测试可能继承假的计时器,表现为 Promise、动画或超时逻辑异常。若回调中同时产生外部 Promise,还需要在推进计时器后再执行 flushPromises()

七、契约验证:测试组件对外承诺什么

7.1 组件契约的形式化表示

组件可以抽象为一个边界:

C=(I,O,E)C = (I, O, E)

其中:

  • II:输入,包括 props、插槽、依赖和用户事件;
  • OO:输出,包括 DOM、emits、可访问性属性和错误状态;
  • EE:环境行为,包括网络请求、时间、路由、存储和插件。

对给定输入 II 和环境 EE,组件应满足某个可观察条件:

observe(C,I,E)P\text{observe}(C, I, E) \models P

其中 PP 是契约断言。例如:

  • modelValue = "vue" 时,输入框应显示 "vue"
  • 用户输入 "vite" 时,应发出 update:modelValue("vite")
  • 请求未完成时,应存在 role="status"
  • 请求失败时,应存在 role="alert"
  • 新请求完成后,旧请求不能覆盖结果。

契约测试关注 P 是否成立,而不是组件内部用了 refcomputed 还是某个函数名。

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')

通常优先选择:

  1. 可访问角色,如 role="status"role="alert"
  2. 表单标签和按钮文本;
  3. 对测试确有必要的 data-testid
  4. 最后才是 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 defineExposewrapper.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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。