前言
在软件开发中,测试常常被视为“有时间再做”的奢侈品。然而,当项目规模扩大、团队人员变动、需求频繁变更时,没有测试的代码库会逐渐变成难以维护的"遗留系统"。
为什么需要测试?
一个没有测试的项目会怎样?
场景1:重构时的不安全感
当我们改完某个模块的代码后,怎么知道有没有破坏原有功能呢?此时我们只能手动点击测试,但只要漏掉一个边界情况就出 Bug。
场景2:新人接手代码
当团队来了新人后,第一件事是需要熟悉项目的代码。但如果没有测试,就没有相关的文档;想要理解一个函数的边界情况就会很困难。
场景3:上线前的焦虑
每次发布都要花好几个小时手工测试一遍。
测试的投资回报率
| 阶段 | 没有测试 | 有测试 | 收益 |
|---|---|---|---|
| 开发阶段 | 手动测试每个功能 | 保存时自动运行 | 节省 30% 时间 |
| 重构阶段 | 不敢改代码 | 改完运行测试 | 重构效率提升 200% |
| Code Review | reviewer 手动验证 | 看测试用例理解 | 时间缩短 50% |
| 上线阶段 | 每次提心吊胆 | 测试通过就上线 | 信心 100% |
测试策略金字塔
/\
/ \ E2E 测试 (少量)
/ \ 模拟真实用户操作,成本高
/------\
/ \ 组件测试 (适量)
/ \ 测试组件交互和渲染
/------------\
/ \ 单元测试 (大量)
/ \ 测试函数和组合式函数,速度快
原则:底层测试越多,上层测试越少
单元测试:60-70%
组件测试:20-30%
E2E 测试:5-10%
Vitest 快速上手
为什么选择 Vitest?
Vitest 可以与 Vite 的无缝集成,同一套配置、同一套插件、同一套别名。
Jest 的痛点
- 需要配置 babel-jest、vue-jest、jest-serializer-vue
- 与 Vite 的别名、插件不共享
- 速度慢,尤其是冷启动
Vitest 的优势
- 与 Vite 共享配置,零配置迁移
- 多线程并发执行,速度快
- 支持 ES Module 开箱即用
- 与 Jest 几乎相同的 API
安装 Vitest
# 安装 Vitest
npm install --save-dev vitest
# 安装 Vue 测试工具
npm install --save-dev @vue/test-utils
# 安装 jsdom(浏览器环境模拟)
npm install --save-dev jsdom
Vite 配置集成
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true, // 启用全局 API(describe, it, expect)
environment: 'jsdom', // 模拟浏览器环境
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], // 测试文件匹配模式
coverage: { // 测试覆盖率配置
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,ts,vue}'],
exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts']
},
testTimeout: 5000, // 测试超时时间
setupFiles: ['./test/setup.ts'] // 全局 setup 文件
}
})
添加测试脚本
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
第一个测试
假设我们有这样一个函数:
// src/utils/math.js
export function add(a, b) {
return a + b
}
其对应的测试:
// src/utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add } from './math'
describe('math.js', () => {
it('1 + 1 应该等于 2', () => {
expect(add(1, 1)).toBe(2)
})
it('负数相加', () => {
expect(add(-1, -2)).toBe(-3)
})
})
测试组合式函数
最简单的组合式函数
我们先看一个简单的组合式函数:
// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return { count, increment, decrement, reset }
}
其对应的测试:
// composables/__tests__/useCounter.spec.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('初始值应该是0', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('可以设置初始值', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('增加1', () => {
const { count, increment } = useCounter(5)
increment()
expect(count.value).toBe(6)
})
it('减少1', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('重置', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})
})
带 computed 的组合式函数
我们再来看一个带 computed 的组合式函数:
// composables/useDouble.js
import { ref, computed } from 'vue'
export function useDouble(initialValue = 0) {
const value = ref(initialValue)
const double = computed(() => value.value * 2)
const setValue = (newValue) => value.value = newValue
return { value, double, setValue }
}
其对应的测试:
// composables/__tests__/useDouble.spec.js
import { describe, it, expect } from 'vitest'
import { useDouble } from '../useDouble'
describe('useDouble', () => {
it('double 应该是 value 的两倍', () => {
const { value, double } = useDouble(3)
expect(double.value).toBe(6)
})
it('value 变化时 double 也跟着变', () => {
const { value, double, setValue } = useDouble(3)
setValue(5)
expect(double.value).toBe(10)
value.value = 7
expect(double.value).toBe(14)
})
})
带生命周期的组合式函数
我们再来看一个带生命周期的组合式函数:
// composables/useWindowWidth.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowWidth() {
const width = ref(window.innerWidth)
const updateWidth = () => {
width.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', updateWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
return { width }
}
其对应的测试:
// composables/__tests__/useWindowWidth.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useWindowWidth } from '../useWindowWidth'
// 辅助函数:让生命周期钩子执行
function withSetup(composable) {
let result
const app = createApp({
setup() {
result = composable()
return () => {}
}
})
app.mount(document.createElement('div'))
return [result, app]
}
describe('useWindowWidth', () => {
it('初始宽度是当前窗口宽度', () => {
window.innerWidth = 1024
const [result, app] = withSetup(() => useWindowWidth())
expect(result.width.value).toBe(1024)
app.unmount()
})
it('窗口大小变化时更新宽度', () => {
window.innerWidth = 1024
const [result, app] = withSetup(() => useWindowWidth())
// 模拟窗口变化
window.innerWidth = 800
window.dispatchEvent(new Event('resize'))
expect(result.width.value).toBe(800)
app.unmount()
})
it('组件卸载时移除监听器', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const [result, app] = withSetup(() => useWindowWidth())
app.unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
})
})
测试组件
安装 Vue Test Utils
npm install --save-dev @vue/test-utils
最简单的组件
我们来看一个最简单的组件:
<!-- Counter.vue -->
<template>
<div>
<span class="count">{{ count }}</span>
<button @click="increment">增加</button>
<button @click="decrement">减少</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
</script>
其对应的测试:
// __tests__/Counter.spec.js
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('点击增加按钮后变成 1', async () => {
const wrapper = mount(Counter)
await wrapper.find('button:first-child').trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})
it('点击减少按钮后变成 -1', async () => {
const wrapper = mount(Counter)
await wrapper.find('button:last-child').trigger('click')
expect(wrapper.find('.count').text()).toBe('-1')
})
})
带 Props 的组件
我们再来看一个带 Props 的组件:
<!-- Greeting.vue -->
<template>
<div>
<h1>Hello, {{ name }}!</h1>
<p v-if="showMessage">欢迎使用我们的应用</p>
</div>
</template>
<script setup>
defineProps({
name: String,
showMessage: Boolean
})
</script>
其对应的测试:
// __tests__/Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from '../Greeting.vue'
describe('Greeting', () => {
it('显示名字', () => {
const wrapper = mount(Greeting, {
props: { name: '张三' }
})
expect(wrapper.text()).toContain('Hello, 张三!')
})
it('showMessage 为 true 时显示欢迎语', () => {
const wrapper = mount(Greeting, {
props: { name: '张三', showMessage: true }
})
expect(wrapper.text()).toContain('欢迎使用我们的应用')
})
it('showMessage 为 false 时不显示欢迎语', () => {
const wrapper = mount(Greeting, {
props: { name: '张三', showMessage: false }
})
expect(wrapper.text()).not.toContain('欢迎使用我们的应用')
})
})
带事件的组件
我们再来看一个带事件的组件:
<!-- SubmitButton.vue -->
<template>
<button @click="handleClick" :disabled="disabled">
{{ text }}
</button>
</template>
<script setup>
const props = defineProps({
text: String,
disabled: Boolean
})
const emit = defineEmits(['submit'])
const handleClick = () => {
emit('submit', 'button clicked')
}
</script>
其对应的测试:
// __tests__/SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from '../SubmitButton.vue'
describe('SubmitButton', () => {
it('点击时触发 submit 事件', async () => {
const wrapper = mount(SubmitButton, {
props: { text: '提交' }
})
await wrapper.trigger('click')
// 检查事件是否触发
expect(wrapper.emitted()).toHaveProperty('submit')
// 检查事件参数
expect(wrapper.emitted('submit')[0]).toEqual(['button clicked'])
})
it('禁用时点击不触发事件', async () => {
const wrapper = mount(SubmitButton, {
props: { text: '提交', disabled: true }
})
await wrapper.trigger('click')
expect(wrapper.emitted('submit')).toBeUndefined()
})
})
Mock 外部依赖
为什么要 Mock?
真实开发中,组件往往依赖:
- API 请求
- 组合式函数
- 第三方库
测试时我们也许不能发送真实请求给后端,因此需要使用 Mock 模拟真实数据。
Mock 组合式函数
我们先来看一个 Mock 组合式函数:
<!-- UserProfile.vue -->
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error.message }}</div>
<div v-else>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</div>
</template>
<script setup>
import { useUser } from '@/composables/useUser'
const props = defineProps({ userId: Number })
const { user, loading, error } = useUser(props.userId)
</script>
其对应的测试:
// __tests__/UserProfile.spec.js
import { mount } from '@vue/test-utils'
import { vi } from 'vitest'
// 先 Mock 模块
vi.mock('@/composables/useUser')
// 再导入(Mock 后的版本)
import { useUser } from '@/composables/useUser'
import UserProfile from '../UserProfile.vue'
describe('UserProfile', () => {
it('加载中显示 loading', () => {
// 设置 Mock 返回值
useUser.mockReturnValue({
user: ref(null),
loading: ref(true),
error: ref(null)
})
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
expect(wrapper.text()).toContain('加载中...')
})
it('加载成功显示用户信息', () => {
useUser.mockReturnValue({
user: ref({ name: '张三', email: 'zhangsan@example.com' }),
loading: ref(false),
error: ref(null)
})
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('zhangsan@example.com')
})
it('加载失败显示错误', () => {
useUser.mockReturnValue({
user: ref(null),
loading: ref(false),
error: ref({ message: '用户不存在' })
})
const wrapper = mount(UserProfile, {
props: { userId: 999 }
})
expect(wrapper.text()).toContain('错误: 用户不存在')
})
})
Mock API 请求
我们再来看一个 Mock API 请求:
// composables/useApi.js
import { ref } from 'vue'
export function useApi() {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const fetchData = async (url) => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return { data, loading, error, fetchData }
}
其对应的测试:
// __tests__/useApi.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useApi } from '../useApi'
describe('useApi', () => {
it('请求成功', async () => {
// Mock fetch
const mockData = { id: 1, name: '测试' }
global.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue(mockData)
})
const { fetchData, data, loading, error } = useApi()
// 初始状态
expect(loading.value).toBe(false)
expect(data.value).toBe(null)
// 请求中
const promise = fetchData('/api/test')
expect(loading.value).toBe(true)
// 等待请求完成
await promise
expect(loading.value).toBe(false)
expect(data.value).toEqual(mockData)
expect(error.value).toBe(null)
})
it('请求失败', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('网络错误'))
const { fetchData, data, loading, error } = useApi()
await fetchData('/api/test')
expect(loading.value).toBe(false)
expect(data.value).toBe(null)
expect(error.value).toBeInstanceOf(Error)
})
})
测试最佳实践
测试行为,而非实现细节
不好的测试:关注内部实现细节
it('calls validateEmail function', () => {
const validateSpy = vi.spyOn(LoginForm.methods, 'validateEmail')
// ... 测试
expect(validateSpy).toHaveBeenCalled()
})
好的测试:关注用户可见的行为
it('shows error message when email is invalid', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[type="email"]').setValue('invalid-email')
await wrapper.find('button[type="submit"]').trigger('click')
expect(wrapper.text()).toContain('请输入有效的邮箱地址')
})
测试公共 API,而非私有状态
// composables/useCounter.ts
export function useCounter() {
const count = ref(0) // 内部状态
const increment = () => count.value++
// ✅ 测试公共 API
return {
count, // 只读状态(通过 ref 暴露)
increment // 方法
}
}
// ✅ 好的测试
it('increments count when increment is called', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
测试边界情况和错误场景
it('handles empty list', () => {
const wrapper = mount(ProductList, {
props: { products: [] }
})
expect(wrapper.text()).toContain('暂无商品')
})
it('handles extremely long text', () => {
const longText = 'a'.repeat(1000)
const wrapper = mount(ProductCard, {
props: { title: longText }
})
// 测试是否被截断或换行
})
it('handles API timeout', async () => {
vi.mocked(fetch).mockImplementationOnce(
() => new Promise(resolve => setTimeout(resolve, 10000))
)
const { fetchData, loading } = useApi()
const promise = fetchData('/api/test')
expect(loading.value).toBe(true)
// 模拟超时
await expect(promise).rejects.toThrow('Timeout')
})
测试描述要清晰
不好的测试描述
it('works correctly')
it('handles state')
it('test button')
好的测试描述
it('提交按钮在表单提交时禁用')
it('密码少于6位时显示错误')
it('登录成功后跳转到首页')
保持测试独立
describe('UserStore', () => {
beforeEach(() => {
// 每个测试前重置状态
vi.clearAllMocks()
localStorage.clear()
})
it('test 1', () => {})
it('test 2', () => {}) // 不受 test 1 影响
})
测试覆盖率
配置测试覆盖率
// vite.config.js
export default {
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{js,ts,vue}'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/main.ts',
'src/router/**'
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
}
}
}
查看覆盖率
npm run test:coverage
# 输出:
File | % Stmts | % Branch | % Funcs | % Lines
------------------|---------|----------|---------|--------
src/composables/ | 85.71 | 75.00 | 90.00 | 85.71
src/components/ | 72.50 | 66.67 | 80.00 | 72.50
src/utils/ | 100.00 | 100.00 | 100.00 | 100.00
常见问题与解决方案
问题一:组合式函数中的生命周期不执行
// ❌ 错误:直接调用
const result = useWindowWidth()
// ✅ 正确:使用 withSetup
const [result, app] = withSetup(() => useWindowWidth())
问题二:异步测试超时
// ❌ 错误:没有等待异步操作
it('fetches data', () => {
const { fetchData } = useApi()
fetchData('/api/test')
expect(data.value).toBeDefined() // 可能还没返回
})
// ✅ 正确:等待异步操作
it('fetches data', async () => {
const { fetchData, data } = useApi()
await fetchData('/api/test')
expect(data.value).toBeDefined()
})
问题三:测试 DOM 更新
// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
wrapper.vm.count++
expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})
// ✅ 正确:使用 nextTick 或 async
it('updates count', async () => {
wrapper.vm.count++
await nextTick()
expect(wrapper.find('.count').text()).toBe('1')
})
问题四:Mock 没有被正确应用
// ❌ 错误:import 在 mock 之前
import { useUser } from './useUser'
vi.mock('./useUser') // 太晚了,模块已经加载
// ✅ 正确:mock 在 import 之前
vi.mock('./useUser')
import { useUser } from './useUser'
测试的最佳实践
测试优先级
- 核心业务逻辑(组合式函数)→ 必须测试
- 关键用户路径(组件)→ 必须测试
- 错误边界 → 必须测试
- UI 细节 → 可选
检查清单
- 每个组合式函数都有单元测试
- 每个关键组件都有组件测试
- 测试覆盖了成功和失败两种情况
- 测试描述清晰,说明预期行为
- Mock 了外部依赖
- 测试可以独立运行
- CI 中自动运行测试
结语
测试不是为了 100% 覆盖率,而是为了重构时的信心。 一个没有测试的项目,重构就是重写;一个有测试的项目,重构是优化。代码是写给人看的,测试是写给未来的自己看的!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!