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

Vue 测试体系:Vitest、Vue Test Utils、组件测试和端到端测试

Vue 应用的测试不是“给每个文件写一个测试”这么简单。一个真实请求通常会经过浏览器、路由、组件、Composable、网络客户端和后端;不同层次的测试观察到的系统边界不同,因此能够发现的故障也不同。

可以先把一次用户操作抽象成一条因果链:

用户操作
  ↓
浏览器事件与路由
  ↓
Vue 组件模板、响应式状态、生命周期
  ↓
Composable 与 API 客户端
  ↓
网络服务或后端系统
  ↓
响应数据、错误状态、日志与页面反馈

Vitest 主要负责在 Node 测试进程中执行 JavaScript/TypeScript 测试;Vue Test Utils(简称 VTU)负责把 Vue 组件挂载到测试环境并操作其 DOM;组件测试验证组件边界;端到端测试(End-to-End Test,简称 E2E)则从真实浏览器和用户可见行为出发,验证多个系统层次是否能够协同工作。

这几个概念互相有关,但不能互相替代:

层次 典型工具 主要观察对象 典型故障
单元测试 Vitest 纯函数、Composable、工具模块 边界条件、状态转换、异常分支
组件测试 Vitest + Vue Test Utils Vue 组件的输入、输出、DOM 和事件 条件渲染、交互、组件契约
集成测试 Vitest + VTU + MSW 等 多个前端模块协作 API 适配、路由、状态管理协作
端到端测试 Playwright 等 真实浏览器中的完整用户流程 构建、路由、资源、网络、浏览器行为

测试层次越高,越接近用户,但启动成本、执行时间和故障定位成本通常也越高。一个可靠的测试体系不是只选择其中一种,而是让每一层承担适合自己的验证责任。

一、Vitest 在 Vue 工具链中的位置

1. Vitest 是什么

Vitest 是面向现代 Vite 工具链的测试运行器。它负责:

  1. 发现测试文件;
  2. 转换 TypeScript、Vue 单文件组件和现代 JavaScript;
  3. 执行测试;
  4. 提供断言、Mock、Fake Timer 等测试 API;
  5. 输出测试结果和覆盖率报告。

Vitest 与 Vite 共享相同的模块转换和配置生态,因此 Vue 项目通常不需要再维护一套完全独立的转换配置。这是常见实现上的集成优势,但并不意味着 Vitest 等同于浏览器:默认情况下,测试代码仍然运行在 Node 环境中。

一个测试的基本结构可以表示为:

Test=Arrange+Act+Assert\text{Test} = \text{Arrange} + \text{Act} + \text{Assert}

其中:

  • Arrange:准备输入、初始状态和依赖;
  • Act:执行函数调用或用户操作;
  • Assert:验证输出、状态、DOM 或副作用。

例如,测试一个加法函数:

// src/utils/add.ts
export function add(a: number, b: number): number {
  return a + b
}
// src/utils/add.test.ts
import { describe, expect, it } from 'vitest'
import { add } from './add'

describe('add', () => {
  it('returns the sum of two numbers', () => {
    // Arrange
    const a = 2
    const b = 3

    // Act
    const result = add(a, b)

    // Assert
    expect(result).toBe(5)
  })
})

这里 add 是确定性函数。对相同的输入,它应返回相同的输出:

f(2,3)=5f(2, 3) = 5

单元测试的价值不只是证明“正常输入能工作”,还在于显式记录函数的输入域、输出域和边界。例如,如果业务规定不能出现负数,就应先确定函数契约:

// src/utils/percentage.ts
export function clampPercentage(value: number): number {
  return Math.min(100, Math.max(0, value))
}
import { describe, expect, it } from 'vitest'
import { clampPercentage } from './percentage'

describe('clampPercentage', () => {
  it.each([
    [-10, 0],
    [0, 0],
    [50, 50],
    [100, 100],
    [120, 100],
  ])('clamps %s to %s', (input, expected) => {
    expect(clampPercentage(input)).toBe(expected)
  })
})

这些测试验证了不变量:

0clampPercentage(x)1000 \leq \operatorname{clampPercentage}(x) \leq 100

以及当输入已经位于合法区间时,函数保持原值不变:

0x100clampPercentage(x)=x0 \leq x \leq 100 \Rightarrow \operatorname{clampPercentage}(x)=x

2. Vue 项目的基本配置

一个使用 Vue 3、TypeScript 和 Vite 的项目通常需要 Vue 插件和 Vitest 配置。

npm install -D vitest jsdom @vitejs/plugin-vue

如果项目尚未包含 Vue:

npm install vue

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    include: ['src/**/*.test.ts'],
  },
})

这里有三个重要配置:

  • environment: 'jsdom':在测试进程中提供常见的 DOM API;
  • globals: true:允许直接使用 describeitexpect,而不必每个文件都导入;
  • include:限定测试文件范围,避免把构建产物或其他脚本误当成测试。

jsdom 不是完整浏览器。它通常能够模拟 documentHTMLElement 和事件系统,但不等于 Chromium、Firefox 或 WebKit。因此,以下能力不能仅靠 jsdom 证明:

  • 真实 CSS 布局和像素渲染;
  • 浏览器原生导航和跨域行为;
  • 真实字体、网络协议和资源加载;
  • 浏览器特有 API;
  • 多标签页、权限、Service Worker 等行为。

如果不需要 DOM,例如纯函数或不依赖生命周期的 Composable,可以使用更轻量的 Node 环境:

// arithmetic.test.ts
import { describe, expect, it } from 'vitest'

describe('node environment', () => {
  it('runs without a browser DOM', () => {
    expect(typeof window).toBe('undefined')
  })
})

也可以在文件头部覆盖环境:

/**
 * @vitest-environment node
 */

环境的选择应由被测代码决定:访问 DOM 的代码需要 jsdom 或真实浏览器;纯逻辑使用 Node 环境更直接。

package.json 中可以加入:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}

其中:

  • npm run test 执行一次后退出,适合 CI;
  • npm run test:watch 监听文件变化,适合开发;
  • npm run test:coverage 需要额外安装覆盖率提供者,例如 @vitest/coverage-v8

覆盖率只是“哪些代码位置被执行过”的统计,不等于测试质量。一个断言很弱的测试也可能执行大量代码,却没有验证业务结果。

3. Vitest 的 Mock、Spy 与 Fake Timer

测试经常需要控制外部依赖。例如,被测函数调用了 API 客户端:

// src/api/user.ts
export async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)

  if (!response.ok) {
    throw new Error(`request failed: ${response.status}`)
  }

  return response.json() as Promise<{ id: string; name: string }>
}

如果单元测试直接调用真实网络,测试会依赖服务器、网络状态和数据环境。可以 Mock fetch

import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchUser } from './user'

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

describe('fetchUser', () => {
  it('returns user data when request succeeds', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ id: 'u1', name: 'Ada' }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      }),
    )

    await expect(fetchUser('u1')).resolves.toEqual({
      id: 'u1',
      name: 'Ada',
    })
  })

  it('throws when request fails', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(null, { status: 500 }),
    )

    await expect(fetchUser('u1')).rejects.toThrow('request failed: 500')
  })
})

这里要区分三种操作:

  • vi.fn():创建一个可记录调用信息的 Mock 函数;
  • vi.spyOn(object, 'method'):观察或替换一个已有方法;
  • vi.mock('module'):替换模块导入,影响范围通常更大。

Mock 的核心风险是:测试可能只验证了 Mock 的返回值,而没有验证真实依赖的行为。例如,Mock 的 API 返回字段拼写正确,并不能证明后端实际返回同样的字段。这个问题应由接口契约测试、集成测试或 E2E 测试补足,而不是无限增加 Mock。

对时间相关逻辑,可以使用 Fake Timer:

import { afterEach, describe, expect, it, vi } from 'vitest'

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

describe('debounce behavior', () => {
  it('runs after the delay', async () => {
    vi.useFakeTimers()

    const callback = vi.fn()
    setTimeout(callback, 300)

    expect(callback).not.toHaveBeenCalled()

    await vi.advanceTimersByTimeAsync(299)
    expect(callback).not.toHaveBeenCalled()

    await vi.advanceTimersByTimeAsync(1)
    expect(callback).toHaveBeenCalledTimes(1)
  })
})

advanceTimersByTimeAsync 的作用是推进虚拟时钟,并等待由定时器触发的异步任务。若测试对象同时使用 Promise、Vue 更新队列或其他异步任务,仅推进时间可能仍然不够,还需要等待相应的 Promise 或 Vue 更新。

二、Vue Test Utils 的职责与挂载模型

1. Vue Test Utils 是什么

Vue Test Utils 是 Vue 官方测试工具链中的组件测试库。它提供:

  • mount:完整挂载组件;
  • shallowMount:对子组件进行浅渲染;
  • wrapper:访问组件根节点、DOM、事件和组件实例;
  • setProps:更新组件 Props;
  • setValue:模拟表单值变化;
  • trigger:触发 DOM 事件;
  • emitted:读取组件发出的事件;
  • global:配置插件、路由、状态管理、依赖注入和错误处理。

安装:

npm install -D @vue/test-utils

组件测试不是简单地调用组件内部函数,而是建立一个测试挂载环境:

mount(Component, options)
  ↓
创建 Vue 应用实例
  ↓
创建组件实例
  ↓
执行 setup()
  ↓
建立响应式依赖和生命周期
  ↓
渲染模板到 jsdom
  ↓
返回 VueWrapper

因此,组件测试能够验证组件的输入和可观察输出,但也会受到测试环境限制。组件中使用的插件、路由、状态管理或注入依赖,必须在挂载时提供。

2. 一个完整的组件示例

下面实现一个数量选择组件:

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

const props = withDefaults(
  defineProps<{
    initial?: number
    min?: number
  }>(),
  {
    initial: 1,
    min: 1,
  },
)

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

const value = ref(Math.max(props.initial, props.min))

function updateValue(nextValue: number) {
  const next = Math.max(nextValue, props.min)

  if (next !== value.value) {
    value.value = next
    emit('change', next)
  }
}

function increment() {
  updateValue(value.value + 1)
}

function decrement() {
  updateValue(value.value - 1)
}
</script>

<template>
  <div>
    <button
      type="button"
      aria-label="decrement"
      @click="decrement"
    >
      -
    </button>

    <input
      :value="value"
      type="number"
      aria-label="quantity"
      @input="updateValue(Number(($event.target as HTMLInputElement).value))"
    >

    <button
      type="button"
      aria-label="increment"
      @click="increment"
    >
      +
    </button>
  </div>
</template>

这个组件的外部契约包括:

  1. initial 决定初始数量;
  2. min 决定允许的最小数量;
  3. 用户操作不能把数量降低到 min 以下;
  4. 数值发生变化时发出 change 事件;
  5. 数值没有变化时不重复发出事件。

第 3 条是状态不变量:

valueminvalue \geq min

updateValue 先计算:

next=max(nextValue,min)next = \max(nextValue, min)

然后只有当 next \ne value 时才更新状态并发出事件。这个顺序很重要。如果先无条件发事件,即使用户在最小值处继续点击减少,也会产生“状态没有变化但事件发生了”的错误信号。

3. 使用 Vue Test Utils 测试组件契约

// src/components/QuantityPicker.test.ts
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import QuantityPicker from './QuantityPicker.vue'

describe('QuantityPicker', () => {
  it('renders the initial value', () => {
    const wrapper = mount(QuantityPicker, {
      props: {
        initial: 3,
      },
    })

    expect(wrapper.get('input').element).toHaveValue(3)
  })

  it('increments and emits the new value', async () => {
    const wrapper = mount(QuantityPicker, {
      props: {
        initial: 2,
      },
    })

    await wrapper.get('button[aria-label="increment"]').trigger('click')

    expect(wrapper.get('input').element).toHaveValue(3)
    expect(wrapper.emitted('change')).toEqual([[3]])
  })

  it('does not go below the minimum', async () => {
    const wrapper = mount(QuantityPicker, {
      props: {
        initial: 1,
        min: 1,
      },
    })

    await wrapper.get('button[aria-label="decrement"]').trigger('click')

    expect(wrapper.get('input').element).toHaveValue(1)
    expect(wrapper.emitted('change')).toBeUndefined()
  })

  it('accepts a value from the input', async () => {
    const wrapper = mount(QuantityPicker)

    await wrapper.get('input').setValue('5')

    expect(wrapper.get('input').element).toHaveValue(5)
    expect(wrapper.emitted('change')).toEqual([[5]])
  })
})

这些测试分别验证了四条不同的因果路径:

  1. initial Props → 初始 DOM;
  2. 点击按钮 → 响应式状态更新 → DOM 更新 → change 事件;
  3. 边界输入 → 被 min 截断 → 状态不变 → 不发事件;
  4. 输入框事件 → 字符串转换为数字 → 状态更新。

triggersetValue 通常返回 Promise,是因为 Vue 的响应式更新并不一定在当前同步调用栈中立即完成。await 允许测试等待 Vue 更新队列刷新。省略 await 的典型表现是:断言读取到旧 DOM,测试出现偶发失败或稳定失败。

4. wrapper 应该观察什么

Vue Test Utils 的 wrapper 提供了多种观察方式:

const wrapper = mount(QuantityPicker)

wrapper.html()
wrapper.text()
wrapper.get('input')
wrapper.find('button')
wrapper.emitted('change')
wrapper.exists()

优先验证用户可观察行为:

expect(wrapper.get('input').element).toHaveValue(3)
expect(wrapper.emitted('change')).toEqual([[3]])

而不应把组件内部的 ref 名称当作主要契约:

// 不推荐作为主要测试方式
expect((wrapper.vm as any).value).toBe(3)

内部状态测试不是绝对禁止,但它会把测试绑定到实现细节。组件后来可能从 ref 改成 computed、Pinia Store 或 Composable,而用户行为没有变化;此时契约测试应该继续通过,内部实现测试可能需要重写。

5. mountshallowMount

mount 会渲染子组件:

const wrapper = mount(ParentComponent)

它适合验证父子组件真实协作,例如:

  • 父组件传给子组件的 Props;
  • 子组件发出的事件是否被父组件处理;
  • 子组件的内容是否影响父组件页面。

shallowMount 会把子组件替换为浅层占位组件:

import { shallowMount } from '@vue/test-utils'

const wrapper = shallowMount(ParentComponent)

它适合在父组件测试中隔离复杂子组件,例如图表、富文本编辑器或第三方 UI 组件。但浅挂载有一个边界:它无法证明真实子组件的模板、生命周期和事件行为。如果父组件依赖子组件的真实 DOM 结构,使用浅挂载可能掩盖集成错误。

选择方式可以写成一个因果判断:

  • 若问题是“父组件是否把输入传给子组件并处理事件”,浅挂载通常足够;
  • 若问题是“这一整块用户界面是否真实可操作”,使用完整挂载;
  • 若问题是“多个页面、路由和后端是否能完成流程”,使用 E2E。

三、异步更新、生命周期与 Composable 测试

1. Vue 更新队列不是同步赋值

在 Vue 中,修改响应式状态后,DOM 更新通常会被批量安排到后续更新周期。例如:

const wrapper = mount(QuantityPicker, {
  props: { initial: 1 },
})

const input = wrapper.get('input')
await wrapper.get('button[aria-label="increment"]').trigger('click')

trigger 的 Promise 解决后,相关 Vue DOM 更新通常已经完成。对于不由 VTU 事件方法直接等待的场景,可以使用:

import { nextTick } from 'vue'

await nextTick()

如果组件在事件处理器中还会发起 Promise 请求,则需要等待 Promise:

import { flushPromises } from '@vue/test-utils'

await wrapper.get('button').trigger('click')
await flushPromises()

两者解决的问题不同:

  • nextTick():等待 Vue 的响应式 DOM 更新;
  • flushPromises():尽量清空当前已排队的 Promise 回调。

如果流程是“点击 → 设置 loading → 请求完成 → 设置 data → DOM 更新”,完整测试通常需要同时考虑两类队列:

await wrapper.get('button').trigger('click')
await flushPromises()
await nextTick()

具体是否需要最后一次 nextTick,取决于 Promise 回调中修改状态的时机和 VTU 版本实现;测试的核心不是机械地堆叠等待,而是明确等待哪一个异步边界。

2. Composable 的测试边界

Composable 是封装响应式状态、计算属性、侦听器或生命周期逻辑的函数。例如:

// src/composables/useCounter.ts
import { computed, ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const doubled = computed(() => count.value * 2)

  function increment() {
    count.value += 1
  }

  return {
    count,
    doubled,
    increment,
  }
}

如果 Composable 不依赖组件实例生命周期,可以直接测试:

import { describe, expect, it } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('updates count and derived state', () => {
    const counter = useCounter(2)

    expect(counter.count.value).toBe(2)
    expect(counter.doubled.value).toBe(4)

    counter.increment()

    expect(counter.count.value).toBe(3)
    expect(counter.doubled.value).toBe(6)
  })
})

这里 countRef<number>,所以在组件外部读取时需要使用 .value。模板会自动解包 Ref,但普通 TypeScript 代码不会。

如果 Composable 使用 onMountedonUnmountedwatch 且需要组件实例,就应该通过一个宿主组件测试它。以一个带清理逻辑的监听器为例:

// src/composables/useWindowWidth.ts
import { onMounted, onUnmounted, ref } from 'vue'

export function useWindowWidth() {
  const width = ref(0)

  function update() {
    width.value = window.innerWidth
  }

  onMounted(() => {
    update()
    window.addEventListener('resize', update)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', update)
  })

  return { width }
}

测试宿主组件:

// src/composables/useWindowWidth.test.ts
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useWindowWidth } from './useWindowWidth'

const Host = defineComponent({
  setup() {
    return useWindowWidth()
  },
  template: '<div>{{ width }}</div>',
})

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

describe('useWindowWidth', () => {
  it('reads and updates window width', async () => {
    vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1024)

    const wrapper = mount(Host)

    expect(wrapper.text()).toBe('1024')

    vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
    window.dispatchEvent(new Event('resize'))

    expect(wrapper.text()).toBe('1280')

    wrapper.unmount()
  })

  it('removes the event listener on unmount', () => {
    const removeEventListener = vi.spyOn(window, 'removeEventListener')

    const wrapper = mount(Host)
    wrapper.unmount()

    expect(removeEventListener).toHaveBeenCalledWith(
      'resize',
      expect.any(Function),
    )
  })
})

这个测试验证的不只是返回值,还验证了资源生命周期:

mountaddEventListener\text{mount} \Rightarrow \text{addEventListener}

unmountremoveEventListener\text{unmount} \Rightarrow \text{removeEventListener}

如果忘记清理监听器,组件反复挂载后会积累多个回调,表现为一次窗口变化触发多次更新、内存持续增长或卸载组件后仍然执行逻辑。Composable 的测试因此应覆盖“创建副作用”和“销毁副作用”两个方向。

四、组件测试中的依赖、路由、插件和错误

1. 为组件提供全局依赖

组件可能依赖插件、全局组件、注入值或路由。VTU 通过 global 配置挂载环境。

例如,组件依赖注入值:

// src/components/Greeting.vue
<script setup lang="ts">
import { inject } from 'vue'

const userName = inject<string>('userName')
</script>

<template>
  <p>Hello, {{ userName }}</p>
</template>

测试时提供注入:

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

const wrapper = mount(Greeting, {
  global: {
    provide: {
      userName: 'Ada',
    },
  },
})

expect(wrapper.text()).toBe('Hello, Ada')

组件真正需要的是 provide/inject 契约,而不是测试中额外创造一个无关的全局变量。

使用 Vue Router 的组件需要测试路由行为时,应安装测试路由实例,而不是只把 $router 强行伪造出来。伪造适合验证“是否调用了 push”,但不能验证路由匹配、参数解析或导航守卫。

类似地,Pinia 组件应安装测试用 Pinia,并为每个测试建立隔离的 Store 实例,避免一个测试修改的状态泄漏到下一个测试。

2. 测试 Vue 错误处理

Vue 组件中的渲染错误、生命周期错误和事件处理错误可能被 Vue 捕获并交给全局错误处理器。可以在挂载时提供 errorHandler 观察错误:

import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'

const BrokenComponent = defineComponent({
  setup() {
    throw new Error('render failed')
  },
  template: '<div />',
})

describe('Vue error handling', () => {
  it('reports component errors to the configured handler', () => {
    const errorHandler = vi.fn()

    mount(BrokenComponent, {
      global: {
        config: {
          errorHandler,
        },
      },
    })

    expect(errorHandler).toHaveBeenCalled()
    expect(errorHandler.mock.calls[0][0]).toBeInstanceOf(Error)
  })
})

测试错误处理时应区分三个目标:

  1. 错误是否被捕获;
  2. 用户是否看到降级 UI;
  3. 日志或诊断系统是否收到足够上下文。

只断言“调用了 console.error”通常不够,因为生产环境可能通过日志 SDK、错误上报服务或应用级错误边界处理错误。错误处理能力还具有版本和运行环境差异,具体行为应以当前 Vue 和 VTU 版本为准,不能把测试环境中捕获到的日志等同于生产环境中的完整错误链路。

五、组件测试应该验证状态机,而不是只验证截图

一个交互组件通常可以看成状态机。以异步搜索组件为例,其状态可以是:

idle
  └─ submit ──> loading
                  ├─ success ──> success
                  └─ failure ──> error

更完整的状态集合为:

S={idle,loading,success,error}S=\{\text{idle},\text{loading},\text{success},\text{error}\}

输入事件为:

E={submit,resolve,reject,retry}E=\{\text{submit},\text{resolve},\text{reject},\text{retry}\}

测试不应只检查最终的 success,还要验证非法转换不会发生。例如,在 loading 状态下再次提交是否被禁止,失败后是否显示错误,重试是否回到 loading

这类测试可以通过 Mock API 控制每条故障路径:

const request = vi.fn()

request.mockResolvedValueOnce({ items: ['Vue'] })

// 第一次请求:成功
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()

expect(wrapper.get('[role="status"]').text()).toContain('Vue')
expect(request).toHaveBeenCalledTimes(1)

测试失败路径:

request.mockRejectedValueOnce(new Error('network failed'))

await wrapper.get('form').trigger('submit.prevent')
await flushPromises()

expect(wrapper.get('[role="alert"]').text()).toContain('network failed')

但不要把所有异步请求都通过 vi.mock 替换成返回固定对象。如果目标是验证真实 API 响应的字段映射,应该使用更接近网络边界的 Mock 方案,例如在测试服务器层拦截请求。这样仍然不访问真实后端,但可以保留 URL、HTTP 方法、请求体和响应格式。

六、端到端测试:从真实浏览器验证完整流程

1. E2E 测试的定义

端到端测试验证的是用户从入口到结果的完整路径。它通常包括:

  1. 启动应用;
  2. 在真实浏览器中打开页面;
  3. 通过可见 UI 执行操作;
  4. 经过路由、组件、状态和网络层;
  5. 断言页面结果或浏览器状态。

它与组件测试的边界不同:

组件测试:
mount(Component)
  → jsdom
  → 组件输入、DOM、事件

E2E:
启动 Vite 应用
  → 真实浏览器
  → 页面导航、资源加载、用户操作、网络请求

Playwright 是常见的 E2E 工具。安装:

npm install -D @playwright/test
npx playwright install

playwright install 会安装浏览器运行文件,首次执行可能需要网络和较大的磁盘空间。

2. Playwright 配置 Vite 开发服务器

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://127.0.0.1:4173',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  webServer: {
    command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
})

这个配置选择先构建再通过 Vite Preview 启动,验证的是生产构建产物,而不是开发服务器的即时转换结果。两者的故障范围不同:

  • vite dev 更接近本地开发过程;
  • vite build && vite preview 能发现构建阶段的模块、资源路径和生产配置问题;
  • 它仍不等于真实生产环境,因为 CDN、反向代理、身份认证和后端服务可能不同。

package.json

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

3. 一个完整的 E2E 示例

假设应用首页显示前面的数量选择器:

// e2e/quantity-picker.spec.ts
import { test, expect } from '@playwright/test'

test('user can increase quantity', async ({ page }) => {
  await page.goto('/')

  const quantity = page.getByRole('spinbutton', { name: 'quantity' })
  const increment = page.getByRole('button', { name: 'increment' })

  await expect(quantity).toHaveValue('1')

  await increment.click()

  await expect(quantity).toHaveValue('2')
})

这个测试的输入、动作和预期结果是明确的:

  • 输入:打开首页;
  • 动作:点击用户可见的 increment 按钮;
  • 结果:数量输入框显示 2

getByRolegetByLabel 依据可访问性语义定位元素,比依赖 CSS 类名更接近用户和辅助技术看到的界面。CSS 类名常因样式重构变化,而按钮的角色和可访问名称通常属于更稳定的交互契约。

如果测试网络失败路径,可以在浏览器层路由拦截:

test('shows an error when the API fails', async ({ page }) => {
  await page.route('**/api/items**', async route => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'server error' }),
    })
  })

  await page.goto('/items')

  await expect(page.getByRole('alert')).toContainText('server error')
})

这类测试比组件层的函数 Mock 更接近真实请求:页面仍然会发起 HTTP 请求,浏览器仍然经过请求处理流程,应用需要自己完成错误解析和界面降级。

但路由拦截也不是后端契约测试。它验证的是“前端对这个响应如何处理”,不能证明真实后端一定会返回该状态码、字段和响应头。

4. E2E 的并发与隔离

E2E 测试可能并行执行。若多个测试共享同一用户、数据库记录或文件目录,就会出现竞态:

测试 A:删除订单 100
测试 B:读取订单 100

执行顺序不同,测试结果就不同。要避免这种不确定性,需要为每个测试准备独立数据,或使用唯一标识:

id=prefix+workerIndex+randomSuffixid = prefix + workerIndex + randomSuffix

例如,测试数据可以按 Playwright Worker 分配独立用户。具体数据管理方式取决于后端,但原则是:一个测试不应依赖另一个测试留下的状态。

浏览器上下文通常应按测试隔离。不要把登录状态、LocalStorage 或 Cookie 无条件地在测试之间共享,否则一个测试的认证、语言或实验开关可能改变另一个测试的结果。为了减少登录成本,可以复用经过验证的认证状态文件,但仍需确保测试数据彼此独立。

七、三类测试如何协作

一个“提交表单”的功能,可以拆成以下测试责任:

单元测试

测试表单校验函数:

export function validateEmail(value: string): string | null {
  if (!value.includes('@')) {
    return '邮箱格式不正确'
  }

  return null
}

验证空字符串、缺少 @、合法地址等输入边界。

Composable 或服务测试

验证:

  • 提交时状态从 idle 变成 loading
  • 成功后得到数据;
  • 失败后记录错误;
  • 取消请求或卸载时清理副作用。

组件测试

验证:

  • 无效表单显示错误;
  • 按钮在加载时禁用;
  • 成功和失败状态正确渲染;
  • 组件发出预期事件。

E2E 测试

验证:

  • 用户能从真实页面找到表单;
  • 路由和静态资源正常;
  • 浏览器请求到达预期服务;
  • 提交成功后用户进入正确页面;
  • 生产构建后的完整流程仍然成立。

可以把测试选择写成故障定位问题:

纯计算错误       → 单元测试
响应式状态错误   → Composable / 组件测试
模板交互错误     → 组件测试
路由或资源错误   → E2E
真实服务契约错误 → 集成测试 / 契约测试
浏览器行为错误   → E2E

如果一个测试必须启动完整浏览器才能发现一个简单的边界计算错误,测试反馈会变慢;如果只测试组件内部状态,却没有任何浏览器流程测试,构建配置、路由和资源错误又可能直到发布后才暴露。

八、常见失败表现与诊断方法

1. 测试中找不到 windowdocument

失败通常类似:

ReferenceError: document is not defined

原因是测试运行在 Node 环境。解决方式不是在每个测试里手写假的 document,而是为需要 DOM 的测试配置 jsdom

test: {
  environment: 'jsdom',
}

如果只有少数文件需要 DOM,可以使用文件级环境标记,避免所有测试都承担 DOM 模拟成本。

2. 组件挂载时报插件错误

例如组件调用了 router.push,但测试没有安装 Router:

TypeError: Cannot read properties of undefined

诊断步骤:

  1. 查看组件 setup 中使用了哪些注入、插件或全局属性;
  2. 判断测试要验证真实导航还是只验证调用;
  3. 若验证真实导航,挂载真实的测试路由;
  4. 若只验证调用,可以注入最小的稳定接口,但不要伪造与测试目标无关的行为。

3. 断言读取到旧 DOM

常见原因是缺少异步等待:

await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('完成')

如果点击后还会发起异步请求,需要继续等待请求 Promise:

await wrapper.get('button').trigger('click')
await flushPromises()

expect(wrapper.text()).toContain('完成')

如果仍然失败,应检查真实时序:是 Vue 更新未完成,还是请求 Mock 没有 resolve,还是组件在错误分支中没有修改状态。盲目增加多个 nextTick 只能掩盖时序问题。

4. 测试之间互相影响

常见来源包括:

  • Mock 没有恢复;
  • Pinia Store 复用;
  • 修改了全局 window 属性;
  • 定时器没有恢复;
  • 组件没有 unmount
  • 共享了可变模块级变量。

测试生命周期钩子:

import { afterEach, beforeEach, vi } from 'vitest'

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

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

restoreAllMocks 主要恢复 Spy 替换的方法;如果使用了模块 Mock,还要根据测试设计清理 Mock 状态或重新隔离模块。测试隔离不是“每个 it 中都写清理代码”这么简单,而是要识别被修改的状态属于函数、模块、全局对象、浏览器上下文还是后端数据。

5. E2E 找不到元素

错误可能是:

locator.click: Target page, context or browser has been closed

或:

waiting for getByRole('button', { name: 'submit' })

诊断顺序应包括:

  1. 页面是否成功加载;
  2. URL 是否正确;
  3. 元素是否在条件渲染分支中;
  4. accessible name 是否与实际文本一致;
  5. 请求是否让页面一直处于 loading;
  6. 是否需要等待一个明确的业务结果,而不是固定等待时间。

不推荐:

await page.waitForTimeout(2000)

固定等待只能延迟测试,不能证明页面完成了业务动作。更可靠的是等待可观察条件:

await expect(page.getByRole('status')).toHaveText('保存成功')

如果 E2E 失败,Playwright 的 Trace、截图和视频可以帮助确认是导航失败、元素不可见、请求错误还是断言错误。开启 trace: 'retain-on-failure' 后,应在 CI 中保存这些产物,但要注意其中可能包含用户数据、Token 或业务敏感信息,发布到构建系统时需要控制访问权限。

九、测试代码的版本敏感点和边界

Vue Test Utils、Vitest、jsdom 和 Playwright 都会持续演进。以下内容尤其需要以项目实际版本为准:

  • Vue Test Utils 对异步更新、浅挂载占位组件和错误捕获的细节;
  • Vitest 的 Mock 清理、模块隔离和覆盖率提供者;
  • jsdom 对浏览器 API 的支持程度;
  • Playwright 的浏览器版本和定位器行为;
  • Vite 的配置字段以及开发服务器、预览服务器差异。

因此,安装依赖后应使用项目锁定的版本运行测试,而不是假设全局最新版本行为一致。对底层实现敏感的断言也应保持克制。例如,测试不应依赖某个 Vue 内部组件实例字段、某个编译生成的 CSS 类名,或某个尚未稳定的实验性 API。

规范保证、常见实现和经验建议也应分开:

  • Vue 的响应式更新和生命周期语义属于框架行为;
  • jsdom 对 DOM 的模拟属于测试环境实现;
  • 使用 getByRole、减少实现细节断言属于工程经验;
  • 某个测试执行时间阈值、并行数量或覆盖率百分比不应在没有测量依据时被当作通用事实。

十、一个可执行的测试检查路径

在本地可以按由近及远的顺序执行:

npm run test
npm run test:coverage
npm run build
npm run test:e2e

每一步验证的内容不同:

  1. npm run test:单元测试、Composable 测试和组件测试是否通过;
  2. npm run test:coverage:哪些代码路径没有被执行,帮助发现遗漏;
  3. npm run build:TypeScript、Vue 编译和生产构建是否成功;
  4. npm run test:e2e:构建后的应用能否在真实浏览器中完成用户流程。

如果单元测试通过但构建失败,问题可能在类型检查、模板编译或资源导入;如果构建通过但 E2E 失败,问题可能在路由基路径、静态资源路径、浏览器行为、网络服务或页面时序;如果只有 CI 失败,还要检查浏览器安装、环境变量、时区、并发数据和容器权限。

测试体系的核心不是追求某个数字,而是让每条重要业务路径都有匹配的观察边界:

可靠性逻辑覆盖+组件契约+系统流程+故障路径验证\text{可靠性} \approx \text{逻辑覆盖} + \text{组件契约} + \text{系统流程} + \text{故障路径验证}

这个表达式不是可直接计算的工程公式,而是一个设计原则:单元测试负责快速、精确地验证逻辑;Vue Test Utils 负责验证组件与响应式 UI;E2E 负责验证真实浏览器中的关键路径。三者组合后,测试才既能快速反馈局部错误,也能发现跨层次集成失败。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。