测试
测试是确保 Vue.js 应用质量和稳定性的重要环节。本指南涵盖了单元测试、组件测试、集成测试和端到端测试的最佳实践。
测试环境搭建
Vitest 配置
javascript
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom',
// 全局配置
globals: true,
// 设置文件
setupFiles: ['./tests/setup.js'],
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.js'
]
},
// 测试文件匹配模式
include: [
'tests/**/*.{test,spec}.{js,ts}',
'src/**/__tests__/*.{test,spec}.{js,ts}'
]
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
测试设置文件
javascript
// tests/setup.js
import { vi } from 'vitest'
import { config } from '@vue/test-utils'
// 全局组件注册
config.global.components = {
// 注册全局组件
}
// 全局插件
config.global.plugins = [
// 注册插件
]
// Mock 全局对象
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}))
单元测试
工具函数测试
javascript
// src/utils/validators.js
export function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function validatePassword(password) {
return password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password)
}
export function formatCurrency(amount, currency = 'CNY') {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency
}).format(amount)
}
export function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
javascript
// tests/utils/validators.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { validateEmail, validatePassword, formatCurrency, debounce } from '@/utils/validators'
describe('validators', () => {
describe('validateEmail', () => {
it('should return true for valid email', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('user.name+tag@domain.co.uk')).toBe(true)
})
it('should return false for invalid email', () => {
expect(validateEmail('invalid-email')).toBe(false)
expect(validateEmail('test@')).toBe(false)
expect(validateEmail('@example.com')).toBe(false)
expect(validateEmail('')).toBe(false)
})
})
describe('validatePassword', () => {
it('should return true for valid password', () => {
expect(validatePassword('Password123')).toBe(true)
expect(validatePassword('MySecure1')).toBe(true)
})
it('should return false for invalid password', () => {
expect(validatePassword('short')).toBe(false) // 太短
expect(validatePassword('nouppercase1')).toBe(false) // 没有大写字母
expect(validatePassword('NoNumbers')).toBe(false) // 没有数字
expect(validatePassword('')).toBe(false) // 空字符串
})
})
describe('formatCurrency', () => {
it('should format currency correctly', () => {
expect(formatCurrency(1234.56)).toBe('¥1,234.56')
expect(formatCurrency(0)).toBe('¥0.00')
expect(formatCurrency(1000000)).toBe('¥1,000,000.00')
})
it('should support different currencies', () => {
expect(formatCurrency(100, 'USD')).toContain('100')
expect(formatCurrency(100, 'EUR')).toContain('100')
})
})
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should delay function execution', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 100)
debouncedFn()
expect(mockFn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('should cancel previous calls', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 100)
debouncedFn()
debouncedFn()
debouncedFn()
vi.advanceTimersByTime(100)
expect(mockFn).toHaveBeenCalledTimes(1)
})
})
})
Composables 测试
javascript
// src/composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
javascript
// tests/composables/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count, doubleCount } = useCounter()
expect(count.value).toBe(0)
expect(doubleCount.value).toBe(0)
})
it('should initialize with custom value', () => {
const { count, doubleCount } = useCounter(10)
expect(count.value).toBe(10)
expect(doubleCount.value).toBe(20)
})
it('should increment count', () => {
const { count, increment } = useCounter(5)
increment()
expect(count.value).toBe(6)
})
it('should decrement count', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('should reset count to initial value', () => {
const { count, increment, reset } = useCounter(10)
increment()
increment()
expect(count.value).toBe(12)
reset()
expect(count.value).toBe(10)
})
it('should update doubleCount when count changes', () => {
const { count, doubleCount, increment } = useCounter(0)
expect(doubleCount.value).toBe(0)
increment()
expect(doubleCount.value).toBe(2)
increment()
expect(doubleCount.value).toBe(4)
})
})
API 服务测试
javascript
// src/services/userService.js
import axios from 'axios'
const API_BASE_URL = 'https://api.example.com'
export class UserService {
async getUser(id) {
try {
const response = await axios.get(`${API_BASE_URL}/users/${id}`)
return response.data
} catch (error) {
throw new Error(`Failed to fetch user: ${error.message}`)
}
}
async createUser(userData) {
try {
const response = await axios.post(`${API_BASE_URL}/users`, userData)
return response.data
} catch (error) {
throw new Error(`Failed to create user: ${error.message}`)
}
}
async updateUser(id, userData) {
try {
const response = await axios.put(`${API_BASE_URL}/users/${id}`, userData)
return response.data
} catch (error) {
throw new Error(`Failed to update user: ${error.message}`)
}
}
async deleteUser(id) {
try {
await axios.delete(`${API_BASE_URL}/users/${id}`)
return true
} catch (error) {
throw new Error(`Failed to delete user: ${error.message}`)
}
}
}
export const userService = new UserService()
javascript
// tests/services/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { UserService } from '@/services/userService'
// Mock axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
describe('UserService', () => {
let userService
beforeEach(() => {
userService = new UserService()
vi.clearAllMocks()
})
describe('getUser', () => {
it('should fetch user successfully', async () => {
const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' }
mockedAxios.get.mockResolvedValue({ data: mockUser })
const result = await userService.getUser(1)
expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/users/1')
expect(result).toEqual(mockUser)
})
it('should throw error when request fails', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'))
await expect(userService.getUser(1)).rejects.toThrow('Failed to fetch user: Network error')
})
})
describe('createUser', () => {
it('should create user successfully', async () => {
const userData = { name: 'Jane Doe', email: 'jane@example.com' }
const mockResponse = { id: 2, ...userData }
mockedAxios.post.mockResolvedValue({ data: mockResponse })
const result = await userService.createUser(userData)
expect(mockedAxios.post).toHaveBeenCalledWith('https://api.example.com/users', userData)
expect(result).toEqual(mockResponse)
})
it('should throw error when creation fails', async () => {
const userData = { name: 'Jane Doe', email: 'jane@example.com' }
mockedAxios.post.mockRejectedValue(new Error('Validation error'))
await expect(userService.createUser(userData)).rejects.toThrow('Failed to create user: Validation error')
})
})
describe('updateUser', () => {
it('should update user successfully', async () => {
const userData = { name: 'John Smith' }
const mockResponse = { id: 1, name: 'John Smith', email: 'john@example.com' }
mockedAxios.put.mockResolvedValue({ data: mockResponse })
const result = await userService.updateUser(1, userData)
expect(mockedAxios.put).toHaveBeenCalledWith('https://api.example.com/users/1', userData)
expect(result).toEqual(mockResponse)
})
})
describe('deleteUser', () => {
it('should delete user successfully', async () => {
mockedAxios.delete.mockResolvedValue({})
const result = await userService.deleteUser(1)
expect(mockedAxios.delete).toHaveBeenCalledWith('https://api.example.com/users/1')
expect(result).toBe(true)
})
})
})
组件测试
基础组件测试
vue
<!-- src/components/BaseButton.vue -->
<template>
<button
:class="buttonClasses"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="loading-spinner"></span>
<slot v-else></slot>
</button>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['click'])
const buttonClasses = computed(() => {
return [
'base-button',
`base-button--${props.variant}`,
`base-button--${props.size}`,
{
'base-button--disabled': props.disabled,
'base-button--loading': props.loading
}
]
})
function handleClick(event) {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped>
.base-button {
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.base-button--primary {
background-color: #007bff;
color: white;
}
.base-button--secondary {
background-color: #6c757d;
color: white;
}
.base-button--danger {
background-color: #dc3545;
color: white;
}
.base-button--small {
padding: 4px 8px;
font-size: 12px;
}
.base-button--medium {
padding: 8px 16px;
font-size: 14px;
}
.base-button--large {
padding: 12px 24px;
font-size: 16px;
}
.base-button--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.base-button--loading {
cursor: wait;
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
javascript
// tests/components/BaseButton.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
it('should render with default props', () => {
const wrapper = mount(BaseButton, {
slots: {
default: 'Click me'
}
})
expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('base-button')
expect(wrapper.classes()).toContain('base-button--primary')
expect(wrapper.classes()).toContain('base-button--medium')
})
it('should apply variant classes correctly', () => {
const wrapper = mount(BaseButton, {
props: { variant: 'danger' },
slots: { default: 'Delete' }
})
expect(wrapper.classes()).toContain('base-button--danger')
})
it('should apply size classes correctly', () => {
const wrapper = mount(BaseButton, {
props: { size: 'large' },
slots: { default: 'Large Button' }
})
expect(wrapper.classes()).toContain('base-button--large')
})
it('should emit click event when clicked', async () => {
const wrapper = mount(BaseButton, {
slots: { default: 'Click me' }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')[0][0]).toBeInstanceOf(Event)
})
it('should not emit click event when disabled', async () => {
const wrapper = mount(BaseButton, {
props: { disabled: true },
slots: { default: 'Disabled' }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
expect(wrapper.classes()).toContain('base-button--disabled')
})
it('should not emit click event when loading', async () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
expect(wrapper.classes()).toContain('base-button--loading')
})
it('should show loading spinner when loading', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' }
})
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
expect(wrapper.text()).not.toBe('Loading') // slot content should be hidden
})
it('should validate variant prop', () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mount(BaseButton, {
props: { variant: 'invalid' },
slots: { default: 'Button' }
})
expect(consoleWarn).toHaveBeenCalled()
consoleWarn.mockRestore()
})
})
复杂组件测试
vue
<!-- 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="{ 'error': errors.name }"
@blur="validateField('name')"
>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input
id="email"
v-model="form.email"
type="email"
:class="{ 'error': errors.email }"
@blur="validateField('email')"
>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<div class="form-group">
<label for="age">年龄</label>
<input
id="age"
v-model.number="form.age"
type="number"
:class="{ 'error': errors.age }"
@blur="validateField('age')"
>
<span v-if="errors.age" class="error-message">{{ errors.age }}</span>
</div>
<div class="form-actions">
<BaseButton type="button" variant="secondary" @click="handleCancel">
取消
</BaseButton>
<BaseButton type="submit" :disabled="!isFormValid" :loading="loading">
{{ isEditing ? '更新' : '创建' }}
</BaseButton>
</div>
</form>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import BaseButton from './BaseButton.vue'
import { validateEmail } from '@/utils/validators'
const props = defineProps({
user: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'cancel'])
const form = ref({
name: props.user.name || '',
email: props.user.email || '',
age: props.user.age || null
})
const errors = ref({})
const isEditing = computed(() => !!props.user.id)
const isFormValid = computed(() => {
return form.value.name &&
form.value.email &&
form.value.age &&
Object.keys(errors.value).length === 0
})
// 监听 user prop 变化
watch(
() => props.user,
(newUser) => {
form.value = {
name: newUser.name || '',
email: newUser.email || '',
age: newUser.age || null
}
errors.value = {}
},
{ deep: true }
)
function validateField(field) {
const value = form.value[field]
switch (field) {
case 'name':
if (!value || value.trim().length < 2) {
errors.value.name = '姓名至少需要2个字符'
} else {
delete errors.value.name
}
break
case 'email':
if (!value) {
errors.value.email = '邮箱不能为空'
} else if (!validateEmail(value)) {
errors.value.email = '请输入有效的邮箱地址'
} else {
delete errors.value.email
}
break
case 'age':
if (!value) {
errors.value.age = '年龄不能为空'
} else if (value < 1 || value > 120) {
errors.value.age = '年龄必须在1-120之间'
} else {
delete errors.value.age
}
break
}
}
function validateForm() {
validateField('name')
validateField('email')
validateField('age')
return Object.keys(errors.value).length === 0
}
function handleSubmit() {
if (validateForm()) {
emit('submit', { ...form.value })
}
}
function handleCancel() {
emit('cancel')
}
</script>
javascript
// tests/components/UserForm.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserForm from '@/components/UserForm.vue'
import BaseButton from '@/components/BaseButton.vue'
// Mock validators
vi.mock('@/utils/validators', () => ({
validateEmail: vi.fn((email) => email.includes('@'))
}))
describe('UserForm', () => {
let wrapper
beforeEach(() => {
wrapper = mount(UserForm, {
global: {
components: {
BaseButton
}
}
})
})
it('should render form fields', () => {
expect(wrapper.find('#name').exists()).toBe(true)
expect(wrapper.find('#email').exists()).toBe(true)
expect(wrapper.find('#age').exists()).toBe(true)
})
it('should show "创建" button for new user', () => {
expect(wrapper.text()).toContain('创建')
})
it('should show "更新" button for existing user', async () => {
await wrapper.setProps({
user: { id: 1, name: 'John', email: 'john@example.com', age: 30 }
})
expect(wrapper.text()).toContain('更新')
})
it('should populate form with user data', async () => {
const user = { id: 1, name: 'John Doe', email: 'john@example.com', age: 30 }
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('#age').element.value).toBe('30')
})
it('should validate name field', async () => {
const nameInput = wrapper.find('#name')
// 测试空值
await nameInput.setValue('')
await nameInput.trigger('blur')
expect(wrapper.text()).toContain('姓名至少需要2个字符')
// 测试短名称
await nameInput.setValue('A')
await nameInput.trigger('blur')
expect(wrapper.text()).toContain('姓名至少需要2个字符')
// 测试有效名称
await nameInput.setValue('John Doe')
await nameInput.trigger('blur')
expect(wrapper.text()).not.toContain('姓名至少需要2个字符')
})
it('should validate email field', async () => {
const emailInput = wrapper.find('#email')
// 测试空值
await emailInput.setValue('')
await emailInput.trigger('blur')
expect(wrapper.text()).toContain('邮箱不能为空')
// 测试无效邮箱
await emailInput.setValue('invalid-email')
await emailInput.trigger('blur')
expect(wrapper.text()).toContain('请输入有效的邮箱地址')
// 测试有效邮箱
await emailInput.setValue('john@example.com')
await emailInput.trigger('blur')
expect(wrapper.text()).not.toContain('请输入有效的邮箱地址')
})
it('should validate age field', async () => {
const ageInput = wrapper.find('#age')
// 测试空值
await ageInput.setValue('')
await ageInput.trigger('blur')
expect(wrapper.text()).toContain('年龄不能为空')
// 测试无效年龄
await ageInput.setValue('0')
await ageInput.trigger('blur')
expect(wrapper.text()).toContain('年龄必须在1-120之间')
await ageInput.setValue('150')
await ageInput.trigger('blur')
expect(wrapper.text()).toContain('年龄必须在1-120之间')
// 测试有效年龄
await ageInput.setValue('30')
await ageInput.trigger('blur')
expect(wrapper.text()).not.toContain('年龄必须在1-120之间')
})
it('should disable submit button when form is invalid', async () => {
const submitButton = wrapper.findAllComponents(BaseButton).find(btn =>
btn.text().includes('创建')
)
expect(submitButton.props('disabled')).toBe(true)
// 填写有效数据
await wrapper.find('#name').setValue('John Doe')
await wrapper.find('#email').setValue('john@example.com')
await wrapper.find('#age').setValue('30')
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('#age').setValue('30')
// 提交表单
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('submit')).toHaveLength(1)
expect(wrapper.emitted('submit')[0][0]).toEqual({
name: 'John Doe',
email: 'john@example.com',
age: 30
})
})
it('should emit cancel event when cancel button is clicked', async () => {
const cancelButton = wrapper.findAllComponents(BaseButton).find(btn =>
btn.text().includes('取消')
)
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('should show loading state', async () => {
await wrapper.setProps({ loading: true })
const submitButton = wrapper.findAllComponents(BaseButton).find(btn =>
btn.text().includes('创建')
)
expect(submitButton.props('loading')).toBe(true)
})
})
集成测试
路由测试
javascript
// tests/integration/router.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import App from '@/App.vue'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{ path: '/', name: 'Home', component: Home },
{ path: '/about', name: 'About', component: About },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
]
describe('Router Integration', () => {
let router
let wrapper
beforeEach(async () => {
router = createRouter({
history: createWebHistory(),
routes
})
wrapper = mount(App, {
global: {
plugins: [router]
}
})
await router.isReady()
})
it('should navigate to home page', async () => {
await router.push('/')
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(Home).exists()).toBe(true)
})
it('should navigate to about page', async () => {
await router.push('/about')
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(About).exists()).toBe(true)
})
it('should show 404 page for invalid routes', async () => {
await router.push('/invalid-route')
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(NotFound).exists()).toBe(true)
})
it('should update current route', async () => {
await router.push('/about')
expect(router.currentRoute.value.name).toBe('About')
await router.push('/')
expect(router.currentRoute.value.name).toBe('Home')
})
})
状态管理测试
javascript
// tests/integration/store.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { userService } from '@/services/userService'
// Mock userService
vi.mock('@/services/userService')
describe('User Store Integration', () => {
let userStore
beforeEach(() => {
setActivePinia(createPinia())
userStore = useUserStore()
vi.clearAllMocks()
})
it('should fetch users successfully', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
]
userService.getUsers = vi.fn().mockResolvedValue(mockUsers)
await userStore.fetchUsers()
expect(userStore.users).toEqual(mockUsers)
expect(userStore.loading).toBe(false)
expect(userStore.error).toBeNull()
})
it('should handle fetch users error', async () => {
const errorMessage = 'Network error'
userService.getUsers = vi.fn().mockRejectedValue(new Error(errorMessage))
await userStore.fetchUsers()
expect(userStore.users).toEqual([])
expect(userStore.loading).toBe(false)
expect(userStore.error).toBe(errorMessage)
})
it('should create user successfully', async () => {
const newUser = { name: 'New User', email: 'new@example.com' }
const createdUser = { id: 3, ...newUser }
userService.createUser = vi.fn().mockResolvedValue(createdUser)
await userStore.createUser(newUser)
expect(userStore.users).toContain(createdUser)
expect(userService.createUser).toHaveBeenCalledWith(newUser)
})
it('should update user successfully', async () => {
const existingUser = { id: 1, name: 'John Doe', email: 'john@example.com' }
userStore.users = [existingUser]
const updatedData = { name: 'John Smith' }
const updatedUser = { ...existingUser, ...updatedData }
userService.updateUser = vi.fn().mockResolvedValue(updatedUser)
await userStore.updateUser(1, updatedData)
expect(userStore.users[0]).toEqual(updatedUser)
expect(userService.updateUser).toHaveBeenCalledWith(1, updatedData)
})
it('should delete user successfully', async () => {
const users = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
]
userStore.users = [...users]
userService.deleteUser = vi.fn().mockResolvedValue(true)
await userStore.deleteUser(1)
expect(userStore.users).toHaveLength(1)
expect(userStore.users[0].id).toBe(2)
expect(userService.deleteUser).toHaveBeenCalledWith(1)
})
})
端到端测试
Playwright 配置
javascript
// playwright.config.js
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI
}
})
E2E 测试示例
javascript
// tests/e2e/user-management.spec.js
import { test, expect } from '@playwright/test'
test.describe('User Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/users')
})
test('should display user list', async ({ page }) => {
await expect(page.locator('h1')).toContainText('用户管理')
await expect(page.locator('[data-testid="user-list"]')).toBeVisible()
})
test('should create new user', async ({ page }) => {
// 点击创建用户按钮
await page.click('[data-testid="create-user-btn"]')
// 填写表单
await page.fill('#name', 'Test User')
await page.fill('#email', 'test@example.com')
await page.fill('#age', '25')
// 提交表单
await page.click('[data-testid="submit-btn"]')
// 验证用户已创建
await expect(page.locator('[data-testid="user-list"]')).toContainText('Test User')
await expect(page.locator('[data-testid="success-message"]')).toContainText('用户创建成功')
})
test('should edit existing user', async ({ page }) => {
// 点击编辑按钮
await page.click('[data-testid="edit-user-1"]')
// 修改名称
await page.fill('#name', 'Updated User')
// 提交表单
await page.click('[data-testid="submit-btn"]')
// 验证用户已更新
await expect(page.locator('[data-testid="user-list"]')).toContainText('Updated User')
})
test('should delete user', async ({ page }) => {
// 点击删除按钮
await page.click('[data-testid="delete-user-1"]')
// 确认删除
await page.click('[data-testid="confirm-delete"]')
// 验证用户已删除
await expect(page.locator('[data-testid="user-1"]')).not.toBeVisible()
await expect(page.locator('[data-testid="success-message"]')).toContainText('用户删除成功')
})
test('should validate form fields', async ({ page }) => {
await page.click('[data-testid="create-user-btn"]')
// 提交空表单
await page.click('[data-testid="submit-btn"]')
// 验证错误消息
await expect(page.locator('.error-message')).toContainText('姓名至少需要2个字符')
// 输入无效邮箱
await page.fill('#email', 'invalid-email')
await page.blur('#email')
await expect(page.locator('.error-message')).toContainText('请输入有效的邮箱地址')
})
test('should search users', async ({ page }) => {
// 输入搜索关键词
await page.fill('[data-testid="search-input"]', 'John')
// 验证搜索结果
await expect(page.locator('[data-testid="user-list"] .user-item')).toHaveCount(1)
await expect(page.locator('[data-testid="user-list"]')).toContainText('John')
})
test('should paginate users', async ({ page }) => {
// 验证分页控件
await expect(page.locator('[data-testid="pagination"]')).toBeVisible()
// 点击下一页
await page.click('[data-testid="next-page"]')
// 验证页码变化
await expect(page.locator('[data-testid="current-page"]')).toContainText('2')
})
})
视觉回归测试
javascript
// tests/e2e/visual.spec.js
import { test, expect } from '@playwright/test'
test.describe('Visual Regression Tests', () => {
test('should match homepage screenshot', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png')
})
test('should match user form screenshot', async ({ page }) => {
await page.goto('/users/create')
await expect(page.locator('[data-testid="user-form"]')).toHaveScreenshot('user-form.png')
})
test('should match button variants', async ({ page }) => {
await page.goto('/components/buttons')
// 测试不同状态的按钮
await expect(page.locator('[data-testid="button-primary"]')).toHaveScreenshot('button-primary.png')
await expect(page.locator('[data-testid="button-secondary"]')).toHaveScreenshot('button-secondary.png')
await expect(page.locator('[data-testid="button-danger"]')).toHaveScreenshot('button-danger.png')
})
test('should match responsive layout', async ({ page }) => {
await page.goto('/')
// 桌面视图
await page.setViewportSize({ width: 1200, height: 800 })
await expect(page).toHaveScreenshot('desktop-layout.png')
// 平板视图
await page.setViewportSize({ width: 768, height: 1024 })
await expect(page).toHaveScreenshot('tablet-layout.png')
// 移动视图
await page.setViewportSize({ width: 375, height: 667 })
await expect(page).toHaveScreenshot('mobile-layout.png')
})
})
测试工具和辅助函数
测试工具函数
javascript
// tests/utils/test-utils.js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
// 创建测试路由器
export function createTestRouter(routes = []) {
return createRouter({
history: createWebHistory(),
routes
})
}
// 创建测试 Pinia 实例
export function createTestPinia() {
return createPinia()
}
// 挂载组件的辅助函数
export function mountComponent(component, options = {}) {
const router = createTestRouter()
const pinia = createTestPinia()
return mount(component, {
global: {
plugins: [router, pinia],
...options.global
},
...options
})
}
// 等待异步操作完成
export async function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0))
}
// 模拟用户输入
export async function typeInInput(wrapper, selector, text) {
const input = wrapper.find(selector)
await input.setValue(text)
await input.trigger('input')
await input.trigger('blur')
}
// 模拟表单提交
export async function submitForm(wrapper, formSelector = 'form') {
const form = wrapper.find(formSelector)
await form.trigger('submit')
await flushPromises()
}
// 等待元素出现
export async function waitForElement(wrapper, selector, timeout = 1000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (wrapper.find(selector).exists()) {
return wrapper.find(selector)
}
await new Promise(resolve => setTimeout(resolve, 10))
}
throw new Error(`Element ${selector} not found within ${timeout}ms`)
}
// 模拟 API 响应
export function mockApiResponse(data, delay = 0) {
return new Promise(resolve => {
setTimeout(() => resolve({ data }), delay)
})
}
// 模拟 API 错误
export function mockApiError(message, delay = 0) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), delay)
})
}
Mock 数据生成器
javascript
// tests/utils/mock-data.js
import { faker } from '@faker-js/faker'
// 生成模拟用户数据
export function createMockUser(overrides = {}) {
return {
id: faker.number.int({ min: 1, max: 1000 }),
name: faker.person.fullName(),
email: faker.internet.email(),
age: faker.number.int({ min: 18, max: 80 }),
avatar: faker.image.avatar(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
...overrides
}
}
// 生成模拟用户列表
export function createMockUsers(count = 10) {
return Array.from({ length: count }, () => createMockUser())
}
// 生成模拟产品数据
export function createMockProduct(overrides = {}) {
return {
id: faker.number.int({ min: 1, max: 1000 }),
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: parseFloat(faker.commerce.price()),
category: faker.commerce.department(),
image: faker.image.url(),
inStock: faker.datatype.boolean(),
...overrides
}
}
// 生成模拟 API 响应
export function createMockApiResponse(data, meta = {}) {
return {
data,
meta: {
total: Array.isArray(data) ? data.length : 1,
page: 1,
limit: 10,
...meta
},
success: true,
message: 'Success'
}
}
// 生成模拟错误响应
export function createMockErrorResponse(message = 'Something went wrong') {
return {
data: null,
error: {
message,
code: 'GENERIC_ERROR',
details: {}
},
success: false
}
}
测试最佳实践
测试组织
测试文件结构
tests/ ├── unit/ │ ├── components/ │ ├── composables/ │ ├── utils/ │ └── services/ ├── integration/ │ ├── router/ │ ├── store/ │ └── api/ ├── e2e/ │ ├── user-flows/ │ ├── visual/ │ └── performance/ └── utils/ ├── test-utils.js ├── mock-data.js └── setup.js
命名约定
- 测试文件:
ComponentName.test.js
- 测试套件:
describe('ComponentName', () => {})
- 测试用例:
it('should do something', () => {})
- 测试文件:
测试结构
javascriptdescribe('ComponentName', () => { // 设置和清理 beforeEach(() => {}) afterEach(() => {}) // 分组相关测试 describe('when condition', () => { it('should behave correctly', () => {}) }) })
测试策略
测试金字塔
- 70% 单元测试
- 20% 集成测试
- 10% 端到端测试
测试覆盖率目标
- 语句覆盖率:> 80%
- 分支覆盖率:> 70%
- 函数覆盖率:> 90%
- 行覆盖率:> 80%
测试优先级
- 核心业务逻辑
- 用户关键路径
- 错误处理
- 边界条件
编写高质量测试
测试应该是
- 快速的
- 独立的
- 可重复的
- 自验证的
- 及时的
测试命名
javascript// ✅ 好的命名 it('should display error message when email is invalid', () => {}) it('should disable submit button when form is invalid', () => {}) // ❌ 不好的命名 it('test email validation', () => {}) it('button test', () => {})
断言清晰
javascript// ✅ 清晰的断言 expect(wrapper.find('.error-message').text()).toBe('邮箱格式不正确') expect(wrapper.emitted('submit')).toHaveLength(1) // ❌ 模糊的断言 expect(wrapper.find('.error-message').exists()).toBe(true) expect(wrapper.emitted()).toBeTruthy()
持续集成
GitHub Actions 配置
yaml
# .github/workflows/test.yml
name: Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/
下一步
完善的测试策略是确保应用质量和稳定性的关键!