《Vue3 从入门到大神29篇》单元测试与 E2E 测试 —— 保障 Vue3 项目的质量

前言

很多 Vue 项目都有一个共同点:

✅ 功能能跑

❌ 一改就崩

❌ 不敢重构

❌ 上线提心吊胆

问题不在于代码写得不好,而在于:

缺少自动化测试。

在 Vue3 生态中,测试体系已经非常成熟:

测试类型 工具 作用
单元测试 Vitest 测函数、Composable
组件测试 Vue Test Utils + Vitest 测组件行为
E2E 测试 Cypress 测用户完整操作流程
覆盖率 c8 / Istanbul 评估测试完整性

这一篇,我们从零搭建一个企业级 Vue3 测试方案


一、为什么需要测试?

1️⃣ 没有测试的代价

  • 😰 修复一个 Bug,引入三个新 Bug

  • 😰 重构代码像拆炸弹

  • 😰 回归测试全靠人工点点点

  • 😰 上线前集体加班


2️⃣ 测试金字塔

复制代码
/\
       /  \   E2E 测试(少量)
      /----\
     /      \  集成测试(适量)
    /--------\
   /          \  单元测试(大量)
  /------------\

📌 原则

  • 单元测试:快、多、便宜

  • E2E 测试:慢、少、贵

  • 越往上,越接近真实用户


二、Vitest:Vue3 的官方测试伴侣

1️⃣ 为什么选 Vitest?

对比项 Jest Vitest
速度 较慢 ✅ 极快(ESM 原生)
Vite 集成 需配置 ✅ 开箱即用
Vue 支持 需插件 ✅ 原生支持
热更新
并发执行

📌 Vitest 是为 Vite 量身定制的测试框架。


2️⃣ 安装与配置

复制代码
pnpm add -D vitest @vue/test-utils jsdom @testing-library/vue

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setup.ts']
  }
})

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

3️⃣ 测试文件约定

复制代码
tests/
├── unit/           # 单元测试
│   └── useCounter.test.ts
├── components/     # 组件测试
│   └── Counter.spec.vue
├── e2e/            # E2E 测试
│   └── auth.cy.ts
└── setup.ts        # 测试环境配置

三、单元测试:Composable 测试

1️⃣ 测试 useCounter

复制代码
// composables/useCounter.ts
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const inc = () => count.value++
  const dec = () => count.value--

  return { count, inc, dec }
}

// tests/unit/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'

describe('useCounter', () => {
  it('should initialize with 0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('should increment count', () => {
    const { count, inc } = useCounter()
    inc()
    expect(count.value).toBe(1)
  })

  it('should decrement count', () => {
    const { count, dec } = useCounter(5)
    dec()
    expect(count.value).toBe(4)
  })
})

2️⃣ 测试异步逻辑

复制代码
// composables/useFetch.ts
import { ref } from 'vue'

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null)
  const loading = ref(false)

  const fetchData = async () => {
    loading.value = true
    const res = await fetch(url)
    data.value = await res.json()
    loading.value = false
  }

  return { data, loading, fetchData }
}

// tests/unit/useFetch.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useFetch } from '@/composables/useFetch'

// Mock fetch
global.fetch = vi.fn()

describe('useFetch', () => {
  it('should fetch data successfully', async () => {
    const mockData = { id: 1, name: 'Tom' }
    ;(fetch as any).mockResolvedValueOnce({
      json: async () => mockData
    })

    const { data, loading, fetchData } = useFetch('/api/user')
    await fetchData()

    expect(loading.value).toBe(false)
    expect(data.value).toEqual(mockData)
  })
})

四、组件测试:Vue Test Utils

1️⃣ 测试一个计数器组件

复制代码
<!-- components/Counter.vue -->
<template>
  <div>
    <button @click="dec">-</button>
    <span>{{ count }}</span>
    <button @click="inc">+</button>
  </div>
</template>

<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, inc, dec } = useCounter()
</script>

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

describe('Counter.vue', () => {
  it('renders initial count', () => {
    const wrapper = mount(Counter)
    expect(wrapper.text()).toContain('0')
  })

  it('increments when plus button clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.findAll('button')[1].trigger('click')
    expect(wrapper.text()).toContain('1')
  })

  it('decrements when minus button clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.findAll('button')[0].trigger('click')
    expect(wrapper.text()).toContain('-1')
  })
})

2️⃣ 测试 Props 和 Emits

复制代码
<!-- components/UserCard.vue -->
<template>
  <div @click="handleClick">
    {{ user.name }}
  </div>
</template>

<script setup lang="ts">
defineProps<{ user: { name: string } }>()
const emit = defineEmits<{ (e: 'select', name: string): void }>()

const handleClick = () => emit('select', 'Tom')
</script>

// tests/components/UserCard.spec.ts
import { mount } from '@vue/test-utils'
import UserCard from '@/components/UserCard.vue'

it('emits select event when clicked', async () => {
  const wrapper = mount(UserCard, {
    props: {
      user: { name: 'Tom' }
    }
  })

  await wrapper.trigger('click')
  expect(wrapper.emitted('select')?.[0]).toEqual(['Tom'])
})

五、Pinia 测试

1️⃣ 测试 Store

复制代码
// stores/user.ts
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as { name: string } | null
  }),
  actions: {
    setUser(user: { name: string }) {
      this.user = user
    }
  }
})

// tests/stores/user.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'

describe('User Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('sets user correctly', () => {
    const store = useUserStore()
    store.setUser({ name: 'Tom' })
    expect(store.user?.name).toBe('Tom')
  })
})

2️⃣ 组件中使用 Store 的测试

复制代码
// tests/components/UserProfile.spec.ts
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import UserProfile from '@/components/UserProfile.vue'
import { useUserStore } from '@/stores/user'

it('displays user name from store', () => {
  const pinia = createPinia()
  const wrapper = mount(UserProfile, {
    global: {
      plugins: [pinia]
    }
  })

  const store = useUserStore()
  store.setUser({ name: 'Tom' })

  expect(wrapper.text()).toContain('Tom')
})

六、E2E 测试:Cypress

1️⃣ 安装与配置

复制代码
pnpm add -D cypress

// package.json
{
  "scripts": {
    "cy:open": "cypress open",
    "cy:run": "cypress run"
  }
}

2️⃣ 登录流程测试

复制代码
// cypress/e2e/auth.cy.ts
describe('Authentication', () => {
  it('should login successfully', () => {
    cy.visit('/login')

    cy.get('[data-testid=username]').type('admin')
    cy.get('[data-testid=password]').type('123456')
    cy.get('[data-testid=submit]').click()

    cy.url().should('include', '/dashboard')
    cy.contains('Welcome, admin').should('be.visible')
  })

  it('should show error on invalid credentials', () => {
    cy.visit('/login')
    cy.get('[data-testid=submit]').click()
    cy.contains('用户名或密码错误').should('be.visible')
  })
})

3️⃣ 测试数据属性(推荐 ✅)

复制代码
<input data-testid="username" />
<button data-testid="submit" />

📌 比 class / id 更稳定


七、测试覆盖率

1️⃣ 生成覆盖率报告

复制代码
pnpm test:coverage

2️⃣ 覆盖率配置

复制代码
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.spec.ts',
        '**/*.test.ts'
      ]
    }
  }
})

八、测试最佳实践

实践 说明
AAA 模式 Arrange(准备)→ Act(执行)→ Assert(断言)
测试行为,不是实现 关注输入输出,不关注内部逻辑
避免测试私有方法 通过公有 API 间接测试
使用 data-testid 提高选择器稳定性
测试隔离 每个测试独立,不依赖其他测试
模拟外部依赖 API、定时器、WebSocket

九、常见误区

误区 真相
测试覆盖率 100% 成本高,收益递减
测试越多越好 维护成本会上升
E2E 测试替代单元测试 两者互补,不可替代
测试拖慢开发 长期来看节省调试时间

十、面试高频问答

Q1:单元测试和 E2E 测试的区别?

单元测试测函数/组件,E2E 测完整用户流程。

Q2:为什么 Vue3 推荐 Vitest?

因为 Vite 原生支持,速度更快,配置更简单。

Q3:如何测试 Vue 组件?

使用 Vue Test Utils 挂载组件,模拟用户交互,断言结果。


十一、总结(工程级)

  • 测试是项目质量的护城河

  • Vitest 是 Vue3 单元测试的首选

  • 组件测试关注行为和交互

  • E2E 测试保障核心业务流程

  • 测试不是为了找 Bug,而是为了防止 Bug


📢 下期预告

👉 第 30 篇:Vue3 生态全景图及未来趋势(SSR/Nuxt)

相关推荐
爱勇宝2 小时前
AI 时代,更稀缺的是「提出好问题」还是「判断好答案」?
前端·人工智能·后端
李伟_Li慢慢2 小时前
three.js 中的WebGPU 有几成熟了?
前端·three.js
阿酷tony2 小时前
纯HTML5播放器带倍速、带画质切换的播放器
前端·html·html5
ShiXZ2132 小时前
指令集-NPM 常用指令速查手册
前端·npm·node.js
এ慕ོ冬℘゜2 小时前
前端核心知识体系进阶:从安全机制、网络协议到原生 JS 实战全解析
前端·网络协议·安全
前端H2 小时前
Biome & Rolldown:Rust 工具链接管前端基建
开发语言·前端·rust
柒和远方2 小时前
从等待到流式:LLM 流式输出的原理、协议与工程实践
javascript·llm
forever_world2 小时前
# 💧 从前端视角,彻底搞懂 AI 流式输出 —— 从"接根水管"到 Vue 3 实战
前端·vue.js
灯澜忆梦2 小时前
GO---map函数
开发语言·前端·后端·golang