第 14 章 · 前端测试:Vitest 与 Testing Library
本章目标:区分 单元测试 / 组件测试 / E2E;配置 Vitest 并分别使用 Vue Test Utils 与 React Testing Library;为 CourseCard、登录表单、API 编写可维护测试;理解覆盖率与 CI 门禁;了解 TDD 简要流程。
学时建议:3~4 小时
前置:本模块 ch01~ch05、ch12~ch13(表单与 axios)。
14.1 测试金字塔与选型
┌─────────┐
│ E2E │ Playwright / Cypress — 少量关键路径
┌┴─────────┴┐
│ 组件测试 │ Vitest + Testing Library — 本章重点
┌┴───────────┴┐
│ 单元测试 │ 纯函数、utils、zod schema
└───────────────┘
| 类型 | 测什么 | 速度 | 维护成本 | 贤紫后台示例 |
|---|---|---|---|---|
| 单元 | 无副作用函数 | 极快 | 低 | formatPrice、productSchema |
| 组件 | UI 交互与渲染 | 快 | 中 | CourseCard、登录表单 |
| 集成 | 多模块协作 | 中 | 中高 | 列表页 + MSW |
| E2E | 真实浏览器全流程 | 慢 | 高 | 登录→下单(ch16 答辩演示) |
行业案例 · 贤紫优选商城:2025 Q2 起商户后台 PR 合并门槛为 单元+组件覆盖率 ≥ 60%(核心api/、stores/≥ 75%),回归测试时间从人工 2 小时降至 CI 6 分钟。
原则:多写组件与单元,E2E 只覆盖「登录、下单、退款」等金路径。
14.2 Vitest 配置(Vue / React 通用)
npm install -D vitest @vitest/coverage-v8 jsdom
# Vue
npm install -D @vue/test-utils @vitejs/plugin-vue
# React
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // 或 react
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx,vue}'],
exclude: ['src/main.ts', 'src/mocks/**'],
thresholds: {
lines: 60,
functions: 60,
branches: 50,
},
},
},
})
// package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
// src/test/setup.ts(React)
import '@testing-library/jest-dom/vitest'
// Vue 可在此 global.stubs、pinia 等
| 配置项 | 说明 |
|---|---|
environment: 'jsdom' | 模拟 DOM,组件测试必需 |
globals: true | 免每文件 import { describe, it, expect } |
setupFiles | 全局 mock、jest-dom 匹配器 |
14.3 单元测试:纯函数与 Zod schema
// src/utils/formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'
describe('formatPrice', () => {
it('formats CNY with two decimals', () => {
expect(formatPrice(1999)).toBe('¥19.99')
})
it('handles zero', () => {
expect(formatPrice(0)).toBe('¥0.00')
})
})
// src/schemas/product.test.ts
import { productStep1Schema } from './product'
describe('productStep1Schema', () => {
it('rejects negative price', () => {
const result = productStep1Schema.safeParse({
name: '测试商品',
price: -1,
sku: 'SKU-001',
})
expect(result.success).toBe(false)
})
it('accepts valid payload', () => {
const result = productStep1Schema.safeParse({
name: '贤紫优选茶叶',
price: 88,
sku: 'XZ-TEA-001',
})
expect(result.success).toBe(true)
})
})
单元测试特征:不 import 组件、不 mount DOM、毫秒级执行。
14.4 Vue:测试 CourseCard
<!-- src/components/CourseCard.vue -->
<script setup lang="ts">
defineProps<{
title: string
level: string
hours: string
onEnroll?: () => void
}>()
</script>
<template>
<article class="course-card" data-testid="course-card">
<h3>{{ title }}</h3>
<span class="level">{{ level }}</span>
<span class="hours">{{ hours }}</span>
<button type="button" @click="onEnroll?.()">立即选课</button>
</article>
</template>
// src/components/CourseCard.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import CourseCard from './CourseCard.vue'
describe('CourseCard', () => {
const baseProps = {
title: 'Vue 3 入门',
level: '入门',
hours: '约 4 小时',
}
it('renders title and meta', () => {
const wrapper = mount(CourseCard, { props: baseProps })
expect(wrapper.get('[data-testid="course-card"]').text()).toContain('Vue 3 入门')
expect(wrapper.find('.level').text()).toBe('入门')
})
it('calls onEnroll when button clicked', async () => {
const onEnroll = vi.fn()
const wrapper = mount(CourseCard, { props: { ...baseProps, onEnroll } })
await wrapper.get('button').trigger('click')
expect(onEnroll).toHaveBeenCalledOnce()
})
})
| Vue Test Utils API | 用途 |
|---|---|
mount | 挂载组件 |
get / find | 查询 DOM |
trigger('click') | 模拟事件 |
vi.fn() | Mock 回调 |
查询优先级:getByRole > getByLabelText > data-testid(与 RTL 一致)。
14.5 React:测试 CourseCard
与 Vue 版断言一致:渲染标题/等级;userEvent.click 按钮后 onEnroll 被调用一次。优先 getByRole('heading') 与 getByRole('button')。
RTL 哲学:从用户可见行为断言,不测内部 state。