本章将深入探讨Vue.js应用的测试策略,包括单元测试、组件测试、集成测试和端到端测试,以及相关的工具和最佳实践。
8.1 测试基础
测试类型概述
// 测试金字塔结构
/*
E2E Tests (端到端测试)
↑
Integration Tests (集成测试)
↑
Unit Tests (单元测试)
↑
Static Analysis (静态分析)
*/
// 1. 静态分析:ESLint, TypeScript, Prettier
// 2. 单元测试:测试独立的函数和组件
// 3. 集成测试:测试组件间的交互
// 4. E2E测试:测试完整的用户流程
测试环境搭建
# 安装测试依赖
npm install --save-dev vitest @vue/test-utils jsdom
npm install --save-dev @testing-library/vue @testing-library/jest-dom
npm install --save-dev cypress @cypress/vue
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.js']
}
})
// src/test/setup.js
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/vue'
import * as matchers from '@testing-library/jest-dom/matchers'
// 扩展expect匹配器
expect.extend(matchers)
// 每个测试后清理
afterEach(() => {
cleanup()
})
// 全局测试配置
global.ResizeObserver = class ResizeObserver {
constructor(cb) {
this.cb = cb
}
observe() {
this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }], this)
}
unobserve() {}
disconnect() {}
}
// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "cypress open",
"test:e2e:headless": "cypress run"
}
}
8.2 单元测试
工具函数测试
// src/utils/format.js
export function formatCurrency(amount, currency = 'CNY') {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new Error('Amount must be a valid number')
}
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currency
}).format(amount)
}
export function formatDate(date, format = 'YYYY-MM-DD') {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) {
throw new Error('Invalid date')
}
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
switch (format) {
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`
case 'MM/DD/YYYY':
return `${month}/${day}/${year}`
case 'DD/MM/YYYY':
return `${day}/${month}/${year}`
default:
return `${year}-${month}-${day}`
}
}
export function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
export function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function deepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime())
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item))
}
if (typeof obj === 'object') {
const cloned = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
}
// src/utils/__tests__/format.test.js
import { describe, it, expect, vi } from 'vitest'
import {
formatCurrency,
formatDate,
debounce,
validateEmail,
deepClone
} from '../format'
describe('formatCurrency', () => {
it('should format number as currency', () => {
expect(formatCurrency(1234.56)).toBe('¥1,234.56')
expect(formatCurrency(0)).toBe('¥0.00')
expect(formatCurrency(1000000)).toBe('¥1,000,000.00')
})
it('should handle different currencies', () => {
expect(formatCurrency(100, 'USD')).toContain('$')
expect(formatCurrency(100, 'EUR')).toContain('€')
})
it('should throw error for invalid input', () => {
expect(() => formatCurrency('invalid')).toThrow('Amount must be a valid number')
expect(() => formatCurrency(NaN)).toThrow('Amount must be a valid number')
expect(() => formatCurrency(null)).toThrow('Amount must be a valid number')
})
})
describe('formatDate', () => {
it('should format date with default format', () => {
const date = new Date('2023-12-25')
expect(formatDate(date)).toBe('2023-12-25')
})
it('should format date with custom formats', () => {
const date = new Date('2023-12-25')
expect(formatDate(date, 'MM/DD/YYYY')).toBe('12/25/2023')
expect(formatDate(date, 'DD/MM/YYYY')).toBe('25/12/2023')
})
it('should handle string dates', () => {
expect(formatDate('2023-12-25')).toBe('2023-12-25')
expect(formatDate('2023/12/25')).toBe('2023-12-25')
})
it('should return empty string for falsy values', () => {
expect(formatDate(null)).toBe('')
expect(formatDate(undefined)).toBe('')
expect(formatDate('')).toBe('')
})
it('should throw error for invalid dates', () => {
expect(() => formatDate('invalid-date')).toThrow('Invalid date')
expect(() => formatDate('2023-13-45')).toThrow('Invalid date')
})
})
describe('debounce', () => {
it('should debounce function calls', async () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 100)
// 快速调用多次
debouncedFn('arg1')
debouncedFn('arg2')
debouncedFn('arg3')
// 函数还未被调用
expect(mockFn).not.toHaveBeenCalled()
// 等待debounce时间
await new Promise(resolve => setTimeout(resolve, 150))
// 函数应该只被调用一次,使用最后的参数
expect(mockFn).toHaveBeenCalledTimes(1)
expect(mockFn).toHaveBeenCalledWith('arg3')
})
it('should handle multiple separate calls', async () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 50)
debouncedFn('first')
await new Promise(resolve => setTimeout(resolve, 100))
debouncedFn('second')
await new Promise(resolve => setTimeout(resolve, 100))
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenNthCalledWith(1, 'first')
expect(mockFn).toHaveBeenNthCalledWith(2, 'second')
})
})
describe('validateEmail', () => {
it('should validate correct email addresses', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('user.name@domain.co.uk')).toBe(true)
expect(validateEmail('user+tag@example.org')).toBe(true)
})
it('should reject invalid email addresses', () => {
expect(validateEmail('invalid-email')).toBe(false)
expect(validateEmail('test@')).toBe(false)
expect(validateEmail('@example.com')).toBe(false)
expect(validateEmail('test..test@example.com')).toBe(false)
expect(validateEmail('')).toBe(false)
})
})
describe('deepClone', () => {
it('should clone primitive values', () => {
expect(deepClone(42)).toBe(42)
expect(deepClone('string')).toBe('string')
expect(deepClone(true)).toBe(true)
expect(deepClone(null)).toBe(null)
expect(deepClone(undefined)).toBe(undefined)
})
it('should clone arrays', () => {
const original = [1, 2, [3, 4]]
const cloned = deepClone(original)
expect(cloned).toEqual(original)
expect(cloned).not.toBe(original)
expect(cloned[2]).not.toBe(original[2])
})
it('should clone objects', () => {
const original = {
name: 'John',
age: 30,
address: {
city: 'New York',
country: 'USA'
}
}
const cloned = deepClone(original)
expect(cloned).toEqual(original)
expect(cloned).not.toBe(original)
expect(cloned.address).not.toBe(original.address)
})
it('should clone dates', () => {
const original = new Date('2023-12-25')
const cloned = deepClone(original)
expect(cloned).toEqual(original)
expect(cloned).not.toBe(original)
expect(cloned instanceof Date).toBe(true)
})
it('should handle complex nested structures', () => {
const original = {
users: [
{
id: 1,
name: 'John',
createdAt: new Date('2023-01-01'),
settings: {
theme: 'dark',
notifications: true
}
}
],
metadata: {
version: '1.0.0',
lastUpdated: new Date('2023-12-25')
}
}
const cloned = deepClone(original)
expect(cloned).toEqual(original)
expect(cloned).not.toBe(original)
expect(cloned.users[0]).not.toBe(original.users[0])
expect(cloned.users[0].settings).not.toBe(original.users[0].settings)
})
})
Composables测试
// src/composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
const isEven = computed(() => count.value % 2 === 0)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
function setValue(value) {
if (typeof value !== 'number') {
throw new Error('Value must be a number')
}
count.value = value
}
return {
count,
doubleCount,
isEven,
increment,
decrement,
reset,
setValue
}
}
// src/composables/__tests__/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count, doubleCount, isEven } = useCounter()
expect(count.value).toBe(0)
expect(doubleCount.value).toBe(0)
expect(isEven.value).toBe(true)
})
it('should initialize with custom value', () => {
const { count, doubleCount, isEven } = useCounter(5)
expect(count.value).toBe(5)
expect(doubleCount.value).toBe(10)
expect(isEven.value).toBe(false)
})
it('should increment count', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
increment()
expect(count.value).toBe(2)
})
it('should decrement count', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
decrement()
expect(count.value).toBe(3)
})
it('should reset to initial value', () => {
const { count, increment, reset } = useCounter(10)
increment()
increment()
expect(count.value).toBe(12)
reset()
expect(count.value).toBe(10)
})
it('should set value correctly', () => {
const { count, setValue } = useCounter()
setValue(42)
expect(count.value).toBe(42)
})
it('should throw error for invalid setValue input', () => {
const { setValue } = useCounter()
expect(() => setValue('invalid')).toThrow('Value must be a number')
expect(() => setValue(null)).toThrow('Value must be a number')
expect(() => setValue(undefined)).toThrow('Value must be a number')
})
it('should update computed values reactively', () => {
const { count, doubleCount, isEven, increment } = useCounter(0)
expect(doubleCount.value).toBe(0)
expect(isEven.value).toBe(true)
increment()
expect(doubleCount.value).toBe(2)
expect(isEven.value).toBe(false)
increment()
expect(doubleCount.value).toBe(4)
expect(isEven.value).toBe(true)
})
})
API服务测试
// src/services/userService.js
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('authToken')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export class UserService {
static async getUsers(params = {}) {
try {
const response = await api.get('/users', { params })
return response
} catch (error) {
throw new Error(`Failed to fetch users: ${error.message}`)
}
}
static async getUserById(id) {
if (!id) {
throw new Error('User ID is required')
}
try {
const response = await api.get(`/users/${id}`)
return response
} catch (error) {
if (error.response?.status === 404) {
throw new Error('User not found')
}
throw new Error(`Failed to fetch user: ${error.message}`)
}
}
static async createUser(userData) {
if (!userData || !userData.email) {
throw new Error('User email is required')
}
try {
const response = await api.post('/users', userData)
return response
} catch (error) {
if (error.response?.status === 409) {
throw new Error('User already exists')
}
throw new Error(`Failed to create user: ${error.message}`)
}
}
static async updateUser(id, userData) {
if (!id) {
throw new Error('User ID is required')
}
try {
const response = await api.put(`/users/${id}`, userData)
return response
} catch (error) {
if (error.response?.status === 404) {
throw new Error('User not found')
}
throw new Error(`Failed to update user: ${error.message}`)
}
}
static async deleteUser(id) {
if (!id) {
throw new Error('User ID is required')
}
try {
await api.delete(`/users/${id}`)
return true
} catch (error) {
if (error.response?.status === 404) {
throw new Error('User not found')
}
throw new Error(`Failed to delete user: ${error.message}`)
}
}
}
// src/services/__tests__/userService.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios'
import { UserService } from '../userService'
// Mock axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
})
// Mock window.location
const locationMock = {
href: ''
}
Object.defineProperty(window, 'location', {
value: locationMock,
writable: true
})
describe('UserService', () => {
const mockApi = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() }
}
}
beforeEach(() => {
mockedAxios.create.mockReturnValue(mockApi)
vi.clearAllMocks()
})
afterEach(() => {
vi.resetAllMocks()
})
describe('getUsers', () => {
it('should fetch users successfully', async () => {
const mockUsers = [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
]
mockApi.get.mockResolvedValue(mockUsers)
const result = await UserService.getUsers()
expect(mockApi.get).toHaveBeenCalledWith('/users', { params: {} })
expect(result).toEqual(mockUsers)
})
it('should fetch users with parameters', async () => {
const params = { page: 1, limit: 10, search: 'john' }
const mockUsers = [{ id: 1, name: 'John', email: 'john@example.com' }]
mockApi.get.mockResolvedValue(mockUsers)
const result = await UserService.getUsers(params)
expect(mockApi.get).toHaveBeenCalledWith('/users', { params })
expect(result).toEqual(mockUsers)
})
it('should handle fetch users error', async () => {
const errorMessage = 'Network Error'
mockApi.get.mockRejectedValue(new Error(errorMessage))
await expect(UserService.getUsers()).rejects.toThrow(
`Failed to fetch users: ${errorMessage}`
)
})
})
describe('getUserById', () => {
it('should fetch user by id successfully', async () => {
const mockUser = { id: 1, name: 'John', email: 'john@example.com' }
mockApi.get.mockResolvedValue(mockUser)
const result = await UserService.getUserById(1)
expect(mockApi.get).toHaveBeenCalledWith('/users/1')
expect(result).toEqual(mockUser)
})
it('should throw error for missing id', async () => {
await expect(UserService.getUserById()).rejects.toThrow(
'User ID is required'
)
await expect(UserService.getUserById(null)).rejects.toThrow(
'User ID is required'
)
})
it('should handle user not found error', async () => {
const error = new Error('Not Found')
error.response = { status: 404 }
mockApi.get.mockRejectedValue(error)
await expect(UserService.getUserById(999)).rejects.toThrow(
'User not found'
)
})
it('should handle other errors', async () => {
const errorMessage = 'Server Error'
mockApi.get.mockRejectedValue(new Error(errorMessage))
await expect(UserService.getUserById(1)).rejects.toThrow(
`Failed to fetch user: ${errorMessage}`
)
})
})
describe('createUser', () => {
it('should create user successfully', async () => {
const userData = { name: 'John', email: 'john@example.com' }
const mockCreatedUser = { id: 1, ...userData }
mockApi.post.mockResolvedValue(mockCreatedUser)
const result = await UserService.createUser(userData)
expect(mockApi.post).toHaveBeenCalledWith('/users', userData)
expect(result).toEqual(mockCreatedUser)
})
it('should throw error for missing email', async () => {
await expect(UserService.createUser()).rejects.toThrow(
'User email is required'
)
await expect(UserService.createUser({})).rejects.toThrow(
'User email is required'
)
await expect(UserService.createUser({ name: 'John' })).rejects.toThrow(
'User email is required'
)
})
it('should handle user already exists error', async () => {
const userData = { name: 'John', email: 'john@example.com' }
const error = new Error('Conflict')
error.response = { status: 409 }
mockApi.post.mockRejectedValue(error)
await expect(UserService.createUser(userData)).rejects.toThrow(
'User already exists'
)
})
})
describe('updateUser', () => {
it('should update user successfully', async () => {
const userData = { name: 'John Updated' }
const mockUpdatedUser = { id: 1, name: 'John Updated', email: 'john@example.com' }
mockApi.put.mockResolvedValue(mockUpdatedUser)
const result = await UserService.updateUser(1, userData)
expect(mockApi.put).toHaveBeenCalledWith('/users/1', userData)
expect(result).toEqual(mockUpdatedUser)
})
it('should throw error for missing id', async () => {
await expect(UserService.updateUser()).rejects.toThrow(
'User ID is required'
)
})
it('should handle user not found error', async () => {
const error = new Error('Not Found')
error.response = { status: 404 }
mockApi.put.mockRejectedValue(error)
await expect(UserService.updateUser(999, {})).rejects.toThrow(
'User not found'
)
})
})
describe('deleteUser', () => {
it('should delete user successfully', async () => {
mockApi.delete.mockResolvedValue()
const result = await UserService.deleteUser(1)
expect(mockApi.delete).toHaveBeenCalledWith('/users/1')
expect(result).toBe(true)
})
it('should throw error for missing id', async () => {
await expect(UserService.deleteUser()).rejects.toThrow(
'User ID is required'
)
})
it('should handle user not found error', async () => {
const error = new Error('Not Found')
error.response = { status: 404 }
mockApi.delete.mockRejectedValue(error)
await expect(UserService.deleteUser(999)).rejects.toThrow(
'User not found'
)
})
})
})
8.3 组件测试
基础组件测试
<!-- src/components/Button.vue -->
<template>
<button
:class="[
'btn',
`btn-${variant}`,
`btn-${size}`,
{
'btn-loading': loading,
'btn-disabled': disabled
}
]"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="btn-spinner"></span>
<slot v-if="!loading" />
<span v-if="loading">{{ loadingText }}</span>
</button>
</template>
<script>
export default {
name: 'Button',
props: {
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'success', 'danger', 'warning'].includes(value)
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
loadingText: {
type: String,
default: '加载中...'
}
},
emits: ['click'],
methods: {
handleClick(event) {
if (!this.disabled && !this.loading) {
this.$emit('click', event)
}
}
}
}
</script>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
user-select: none;
}
.btn:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
}
.btn-disabled,
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-loading {
cursor: wait;
}
.btn-spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
// src/components/__tests__/Button.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '../Button.vue'
describe('Button', () => {
it('should render with default props', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me'
}
})
expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('btn')
expect(wrapper.classes()).toContain('btn-primary')
expect(wrapper.classes()).toContain('btn-medium')
})
it('should render different variants', () => {
const variants = ['primary', 'secondary', 'success', 'danger', 'warning']
variants.forEach(variant => {
const wrapper = mount(Button, {
props: { variant },
slots: { default: 'Button' }
})
expect(wrapper.classes()).toContain(`btn-${variant}`)
})
})
it('should render different sizes', () => {
const sizes = ['small', 'medium', 'large']
sizes.forEach(size => {
const wrapper = mount(Button, {
props: { size },
slots: { default: 'Button' }
})
expect(wrapper.classes()).toContain(`btn-${size}`)
})
})
it('should handle disabled state', () => {
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: 'Button' }
})
expect(wrapper.classes()).toContain('btn-disabled')
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('should handle loading state', () => {
const wrapper = mount(Button, {
props: { loading: true, loadingText: 'Loading...' },
slots: { default: 'Button' }
})
expect(wrapper.classes()).toContain('btn-loading')
expect(wrapper.text()).toBe('Loading...')
expect(wrapper.find('.btn-spinner').exists()).toBe(true)
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('should emit click event when clicked', async () => {
const wrapper = mount(Button, {
slots: { default: 'Button' }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('should not emit click when disabled', async () => {
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: 'Button' }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('should not emit click when loading', async () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: 'Button' }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('should pass click event to handler', async () => {
const clickHandler = vi.fn()
const wrapper = mount(Button, {
slots: { default: 'Button' }
})
wrapper.vm.$on('click', clickHandler)
await wrapper.trigger('click')
expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event)
})
it('should validate variant prop', () => {
const validator = Button.props.variant.validator
expect(validator('primary')).toBe(true)
expect(validator('secondary')).toBe(true)
expect(validator('invalid')).toBe(false)
})
it('should validate size prop', () => {
const validator = Button.props.size.validator
expect(validator('small')).toBe(true)
expect(validator('medium')).toBe(true)
expect(validator('large')).toBe(true)
expect(validator('invalid')).toBe(false)
})
})
复杂组件测试
<!-- src/components/UserForm.vue -->
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label for="name">姓名 *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.name }"
@blur="validateField('name')"
>
<div v-if="errors.name" class="invalid-feedback">
{{ errors.name }}
</div>
</div>
<div class="form-group">
<label for="email">邮箱 *</label>
<input
id="email"
v-model="form.email"
type="email"
class="form-control"
:class="{ 'is-invalid': errors.email }"
@blur="validateField('email')"
>
<div v-if="errors.email" class="invalid-feedback">
{{ errors.email }}
</div>
</div>
<div class="form-group">
<label for="role">角色</label>
<select
id="role"
v-model="form.role"
class="form-control"
>
<option value="">请选择角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
<option value="guest">访客</option>
</select>
</div>
<div class="form-group">
<label>
<input
v-model="form.active"
type="checkbox"
>
激活用户
</label>
</div>
<div class="form-actions">
<Button
type="submit"
:loading="loading"
:disabled="!isFormValid"
variant="primary"
>
{{ isEditing ? '更新' : '创建' }}
</Button>
<Button
type="button"
variant="secondary"
@click="handleCancel"
>
取消
</Button>
</div>
</form>
</template>
<script>
import { ref, computed, watch } from 'vue'
import Button from './Button.vue'
import { validateEmail } from '@/utils/format'
export default {
name: 'UserForm',
components: {
Button
},
props: {
user: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
},
emits: ['submit', 'cancel'],
setup(props, { emit }) {
const form = ref({
name: '',
email: '',
role: '',
active: true
})
const errors = ref({})
const isEditing = computed(() => {
return props.user && props.user.id
})
const isFormValid = computed(() => {
return form.value.name.trim() &&
form.value.email.trim() &&
validateEmail(form.value.email) &&
Object.keys(errors.value).length === 0
})
// 监听用户数据变化
watch(
() => props.user,
(newUser) => {
if (newUser) {
form.value = {
name: newUser.name || '',
email: newUser.email || '',
role: newUser.role || '',
active: newUser.active !== false
}
}
},
{ immediate: true }
)
function validateField(field) {
const value = form.value[field]
switch (field) {
case 'name':
if (!value || !value.trim()) {
errors.value.name = '姓名不能为空'
} else if (value.trim().length < 2) {
errors.value.name = '姓名至少需要2个字符'
} else {
delete errors.value.name
}
break
case 'email':
if (!value || !value.trim()) {
errors.value.email = '邮箱不能为空'
} else if (!validateEmail(value)) {
errors.value.email = '请输入有效的邮箱地址'
} else {
delete errors.value.email
}
break
}
}
function validateForm() {
validateField('name')
validateField('email')
return Object.keys(errors.value).length === 0
}
function handleSubmit() {
if (validateForm()) {
emit('submit', { ...form.value })
}
}
function handleCancel() {
emit('cancel')
}
return {
form,
errors,
isEditing,
isFormValid,
validateField,
handleSubmit,
handleCancel
}
}
}
</script>
<style scoped>
.user-form {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.invalid-feedback {
display: block;
width: 100%;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #dc3545;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
</style>
// src/components/__tests__/UserForm.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserForm from '../UserForm.vue'
import Button from '../Button.vue'
// Mock utils
vi.mock('@/utils/format', () => ({
validateEmail: vi.fn((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
}))
describe('UserForm', () => {
let wrapper
beforeEach(() => {
wrapper = mount(UserForm, {
global: {
components: {
Button
}
}
})
})
it('should render form fields', () => {
expect(wrapper.find('#name').exists()).toBe(true)
expect(wrapper.find('#email').exists()).toBe(true)
expect(wrapper.find('#role').exists()).toBe(true)
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
})
it('should initialize with empty form', () => {
expect(wrapper.find('#name').element.value).toBe('')
expect(wrapper.find('#email').element.value).toBe('')
expect(wrapper.find('#role').element.value).toBe('')
expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true)
})
it('should populate form with user data', async () => {
const user = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'admin',
active: false
}
await wrapper.setProps({ user })
expect(wrapper.find('#name').element.value).toBe('John Doe')
expect(wrapper.find('#email').element.value).toBe('john@example.com')
expect(wrapper.find('#role').element.value).toBe('admin')
expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(false)
})
it('should show editing mode for existing user', async () => {
const user = { id: 1, name: 'John', email: 'john@example.com' }
await wrapper.setProps({ user })
const submitButton = wrapper.findComponent(Button)
expect(submitButton.text()).toBe('更新')
})
it('should show create mode for new user', () => {
const submitButton = wrapper.findComponent(Button)
expect(submitButton.text()).toBe('创建')
})
it('should validate required fields', async () => {
const nameInput = wrapper.find('#name')
const emailInput = wrapper.find('#email')
// 触发验证
await nameInput.trigger('blur')
await emailInput.trigger('blur')
expect(wrapper.find('.invalid-feedback').exists()).toBe(true)
expect(wrapper.text()).toContain('姓名不能为空')
expect(wrapper.text()).toContain('邮箱不能为空')
})
it('should validate email format', async () => {
const emailInput = wrapper.find('#email')
await emailInput.setValue('invalid-email')
await emailInput.trigger('blur')
expect(wrapper.text()).toContain('请输入有效的邮箱地址')
})
it('should validate name length', async () => {
const nameInput = wrapper.find('#name')
await nameInput.setValue('a')
await nameInput.trigger('blur')
expect(wrapper.text()).toContain('姓名至少需要2个字符')
})
it('should disable submit button when form is invalid', async () => {
const submitButton = wrapper.findComponent(Button)
expect(submitButton.props('disabled')).toBe(true)
})
it('should enable submit button when form is valid', async () => {
await wrapper.find('#name').setValue('John Doe')
await wrapper.find('#email').setValue('john@example.com')
const submitButton = wrapper.findComponent(Button)
expect(submitButton.props('disabled')).toBe(false)
})
it('should emit submit event with form data', async () => {
await wrapper.find('#name').setValue('John Doe')
await wrapper.find('#email').setValue('john@example.com')
await wrapper.find('#role').setValue('admin')
const form = wrapper.find('form')
await form.trigger('submit')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0][0]).toEqual({
name: 'John Doe',
email: 'john@example.com',
role: 'admin',
active: true
})
})
it('should emit cancel event', async () => {
const cancelButton = wrapper.findAllComponents(Button)[1]
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('should show loading state', async () => {
await wrapper.setProps({ loading: true })
const submitButton = wrapper.findComponent(Button)
expect(submitButton.props('loading')).toBe(true)
})
it('should handle checkbox changes', async () => {
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setChecked(false)
expect(checkbox.element.checked).toBe(false)
await checkbox.setChecked(true)
expect(checkbox.element.checked).toBe(true)
})
it('should clear errors when field becomes valid', async () => {
const nameInput = wrapper.find('#name')
// 先触发错误
await nameInput.trigger('blur')
expect(wrapper.text()).toContain('姓名不能为空')
// 输入有效值
await nameInput.setValue('John Doe')
await nameInput.trigger('blur')
expect(wrapper.text()).not.toContain('姓名不能为空')
})
})
本章小结
本章我们学习了Vue.js应用的测试策略:
- 测试基础:了解测试类型和环境搭建
- 单元测试:测试工具函数、Composables和API服务
- 组件测试:测试Vue组件的行为和交互
- 测试工具:掌握Vitest、Vue Test Utils等工具的使用
下一章预告
下一章我们将学习Vue.js的性能优化和部署策略,包括: - 性能分析和优化技巧 - 构建优化和代码分割 - 部署策略和CI/CD - 监控和错误追踪
练习题
基础练习
工具函数测试:
- 为日期格式化函数编写测试
- 测试表单验证函数
- 编写防抖函数的测试
组件测试:
- 测试输入组件的验证逻辑
- 测试模态框组件的显示隐藏
- 测试列表组件的排序功能
进阶练习
集成测试:
- 测试表单提交流程
- 测试用户登录流程
- 测试数据加载和错误处理
端到端测试:
- 编写用户注册流程的E2E测试
- 测试购物车功能
- 测试搜索和筛选功能
提示:良好的测试策略能够提高代码质量,减少bug,并为重构提供信心。