925 lines
22 KiB
Markdown
925 lines
22 KiB
Markdown
# 健身房管理系统前端测试规范文档
|
||
|
||
> 文档编号: GYM-FE-TEST-001
|
||
> 版本: v1.0
|
||
> 日期: 2026-03-04
|
||
> 作者: 张翔
|
||
> 状态: 初稿
|
||
|
||
---
|
||
|
||
## 文档修订历史
|
||
|
||
| 版本 | 日期 | 作者 | 修订内容 |
|
||
| ---- | ---------- | ---- | -------- |
|
||
| v1.0 | 2026-03-04 | 张翔 | 创建前端测试规范 |
|
||
|
||
---
|
||
|
||
## 参考文档
|
||
|
||
- 《健身房管理系统前端技术架构详细设计》 GYM-FE-ARCH-001
|
||
- Vue Test Utils
|
||
- Vitest
|
||
- Playwright
|
||
|
||
---
|
||
|
||
## 一、测试概述
|
||
|
||
### 1.1 测试目标
|
||
|
||
- **代码质量**:确保代码质量,减少bug
|
||
- **功能正确性**:验证功能符合需求
|
||
- **回归测试**:防止新代码破坏现有功能
|
||
- **文档作用**:测试用例作为代码的使用文档
|
||
- **重构信心**:为重构提供安全保障
|
||
|
||
### 1.2 测试金字塔
|
||
|
||
```
|
||
/\
|
||
/E2E\ 少量端到端测试
|
||
/------\ (关键业务流程)
|
||
/ \
|
||
/Integration\ 适量集成测试
|
||
/------------\ (API集成、状态管理)
|
||
/ \
|
||
/ Unit Tests \ 大量单元测试
|
||
/------------------\ (组件、工具函数、Hooks)
|
||
```
|
||
|
||
| 测试类型 | 数量比例 | 执行速度 | 成本 | 价值 |
|
||
|---------|---------|----------|------|------|
|
||
| **单元测试** | 70% | 快 | 低 | 高 |
|
||
| **集成测试** | 20% | 中 | 中 | 中 |
|
||
| **E2E测试** | 10% | 慢 | 高 | 高 |
|
||
|
||
### 1.3 测试覆盖率目标
|
||
|
||
| 指标 | 目标值 | 说明 |
|
||
|------|--------|------|
|
||
| **代码覆盖率** | ≥ 80% | 所有代码的测试覆盖率 |
|
||
| **分支覆盖率** | ≥ 75% | 条件分支的测试覆盖率 |
|
||
| **函数覆盖率** | ≥ 90% | 函数的测试覆盖率 |
|
||
| **语句覆盖率** | ≥ 85% | 语句的测试覆盖率 |
|
||
|
||
---
|
||
|
||
## 二、单元测试
|
||
|
||
### 2.1 测试框架
|
||
|
||
#### 2.1.1 Vitest配置
|
||
|
||
```typescript
|
||
// vitest.config.ts
|
||
import { defineConfig } from 'vitest/config'
|
||
import vue from '@vitejs/plugin-vue'
|
||
import { resolve } from 'path'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
test: {
|
||
globals: true,
|
||
environment: 'jsdom',
|
||
setupFiles: ['./src/test/setup.ts'],
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'json', 'html'],
|
||
exclude: [
|
||
'node_modules/',
|
||
'src/test/',
|
||
'**/*.d.ts',
|
||
'**/*.config.*',
|
||
'**/mockData',
|
||
'src/main.ts'
|
||
]
|
||
}
|
||
},
|
||
resolve: {
|
||
alias: {
|
||
'@': resolve(__dirname, './src')
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
#### 2.1.2 测试环境设置
|
||
|
||
```typescript
|
||
// src/test/setup.ts
|
||
import { vi } from 'vitest'
|
||
import { config } from '@vue/test-utils'
|
||
|
||
config.global.stubs = {
|
||
'router-link': true,
|
||
'router-view': true
|
||
}
|
||
|
||
// Mock localStorage
|
||
const localStorageMock = {
|
||
getItem: vi.fn(),
|
||
setItem: vi.fn(),
|
||
removeItem: vi.fn(),
|
||
clear: vi.fn(),
|
||
length: 0,
|
||
key: vi.fn()
|
||
}
|
||
|
||
global.localStorage = localStorageMock as any
|
||
```
|
||
|
||
### 2.2 组件测试
|
||
|
||
#### 2.2.1 基础组件测试
|
||
|
||
```typescript
|
||
// components/base/Button.spec.ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import { mount } from '@vue/test-utils'
|
||
import Button from '@/components/base/Button.vue'
|
||
|
||
describe('Button', () => {
|
||
it('renders correctly', () => {
|
||
const wrapper = mount(Button, {
|
||
slots: {
|
||
default: 'Click me'
|
||
}
|
||
})
|
||
expect(wrapper.text()).toBe('Click me')
|
||
})
|
||
|
||
it('emits click event', async () => {
|
||
const wrapper = mount(Button)
|
||
await wrapper.trigger('click')
|
||
expect(wrapper.emitted('click')).toBeTruthy()
|
||
})
|
||
|
||
it('applies type prop', () => {
|
||
const wrapper = mount(Button, {
|
||
props: { type: 'primary' }
|
||
})
|
||
expect(wrapper.classes()).toContain('button--primary')
|
||
})
|
||
|
||
it('disables button when disabled prop is true', () => {
|
||
const wrapper = mount(Button, {
|
||
props: { disabled: true }
|
||
})
|
||
expect(wrapper.attributes('disabled')).toBeDefined()
|
||
})
|
||
|
||
it('shows loading state when loading prop is true', () => {
|
||
const wrapper = mount(Button, {
|
||
props: { loading: true }
|
||
})
|
||
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
|
||
})
|
||
})
|
||
```
|
||
|
||
#### 2.2.2 业务组件测试
|
||
|
||
```typescript
|
||
// components/business/MemberCard.spec.ts
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import { mount } from '@vue/test-utils'
|
||
import MemberCard from '@/components/business/MemberCard.vue'
|
||
|
||
describe('MemberCard', () => {
|
||
const mockMember = {
|
||
id: 1,
|
||
name: '张三',
|
||
phone: '138****1234',
|
||
level: 3,
|
||
avatar: 'https://example.com/avatar.jpg'
|
||
}
|
||
|
||
it('renders member information correctly', () => {
|
||
const wrapper = mount(MemberCard, {
|
||
props: { member: mockMember }
|
||
})
|
||
|
||
expect(wrapper.find('.member-name').text()).toBe('张三')
|
||
expect(wrapper.find('.member-phone').text()).toBe('138****1234')
|
||
expect(wrapper.find('.member-avatar').attributes('src')).toBe('https://example.com/avatar.jpg')
|
||
})
|
||
|
||
it('emits click event when clicked', async () => {
|
||
const wrapper = mount(MemberCard, {
|
||
props: { member: mockMember }
|
||
})
|
||
|
||
await wrapper.trigger('click')
|
||
expect(wrapper.emitted('click')).toBeTruthy()
|
||
expect(wrapper.emitted('click')[0]).toEqual([mockMember])
|
||
})
|
||
|
||
it('shows level badge', () => {
|
||
const wrapper = mount(MemberCard, {
|
||
props: { member: mockMember }
|
||
})
|
||
|
||
expect(wrapper.find('.level-badge').exists()).toBe(true)
|
||
expect(wrapper.find('.level-badge').text()).toContain('VIP')
|
||
})
|
||
})
|
||
```
|
||
|
||
### 2.3 工具函数测试
|
||
|
||
```typescript
|
||
// utils/validator.spec.ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import { validatePhone, validateIdCard, validateEmail } from '@/utils/validator'
|
||
|
||
describe('Validator', () => {
|
||
describe('validatePhone', () => {
|
||
it('validates correct phone number', () => {
|
||
expect(validatePhone('13800138000')).toBe(true)
|
||
})
|
||
|
||
it('rejects invalid phone number', () => {
|
||
expect(validatePhone('12345')).toBe(false)
|
||
expect(validatePhone('1380013800')).toBe(false)
|
||
expect(validatePhone('138001380000')).toBe(false)
|
||
})
|
||
|
||
it('rejects empty string', () => {
|
||
expect(validatePhone('')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('validateIdCard', () => {
|
||
it('validates correct ID card', () => {
|
||
expect(validateIdCard('110101199003077892')).toBe(true)
|
||
})
|
||
|
||
it('rejects invalid ID card', () => {
|
||
expect(validateIdCard('123456')).toBe(false)
|
||
expect(validateIdCard('11010119900307789')).toBe(false)
|
||
})
|
||
|
||
it('rejects empty string', () => {
|
||
expect(validateIdCard('')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('validateEmail', () => {
|
||
it('validates correct email', () => {
|
||
expect(validateEmail('test@example.com')).toBe(true)
|
||
})
|
||
|
||
it('rejects invalid email', () => {
|
||
expect(validateEmail('test')).toBe(false)
|
||
expect(validateEmail('test@')).toBe(false)
|
||
expect(validateEmail('@example.com')).toBe(false)
|
||
})
|
||
|
||
it('rejects empty string', () => {
|
||
expect(validateEmail('')).toBe(false)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
### 2.4 Composables测试
|
||
|
||
```typescript
|
||
// composables/useAuth.spec.ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { setActivePinia, createPinia } from 'pinia'
|
||
|
||
describe('useAuth', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
})
|
||
|
||
it('initializes with empty user', () => {
|
||
const { user, isAuthenticated } = useAuth()
|
||
expect(user.value).toBeNull()
|
||
expect(isAuthenticated.value).toBe(false)
|
||
})
|
||
|
||
it('sets user on login', async () => {
|
||
const { user, isAuthenticated, login } = useAuth()
|
||
const mockUser = { id: 1, name: '张三' }
|
||
|
||
vi.spyOn(api, 'login').mockResolvedValue({ user: mockUser, token: 'mock-token' })
|
||
|
||
await login({ username: 'test', password: 'test' })
|
||
|
||
expect(user.value).toEqual(mockUser)
|
||
expect(isAuthenticated.value).toBe(true)
|
||
})
|
||
|
||
it('clears user on logout', () => {
|
||
const { user, isAuthenticated, logout } = useAuth()
|
||
|
||
logout()
|
||
|
||
expect(user.value).toBeNull()
|
||
expect(isAuthenticated.value).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
### 2.5 Store测试
|
||
|
||
```typescript
|
||
// stores/auth.spec.ts
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||
import { setActivePinia, createPinia } from 'pinia'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
describe('AuthStore', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
it('initializes with default state', () => {
|
||
const store = useAuthStore()
|
||
expect(store.token).toBe('')
|
||
expect(store.user).toBeNull()
|
||
expect(store.isAuthenticated).toBe(false)
|
||
})
|
||
|
||
it('sets token and user on login', async () => {
|
||
const store = useAuthStore()
|
||
const mockResponse = {
|
||
token: 'mock-token',
|
||
user: { id: 1, name: '张三' }
|
||
}
|
||
|
||
vi.spyOn(api, 'login').mockResolvedValue(mockResponse)
|
||
|
||
await store.login({ username: 'test', password: 'test' })
|
||
|
||
expect(store.token).toBe('mock-token')
|
||
expect(store.user).toEqual(mockResponse.user)
|
||
expect(store.isAuthenticated).toBe(true)
|
||
})
|
||
|
||
it('clears state on logout', () => {
|
||
const store = useAuthStore()
|
||
|
||
store.logout()
|
||
|
||
expect(store.token).toBe('')
|
||
expect(store.user).toBeNull()
|
||
expect(store.isAuthenticated).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 三、集成测试
|
||
|
||
### 3.1 API集成测试
|
||
|
||
```typescript
|
||
// api/modules/member.spec.ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { memberApi } from '@/api/modules/member'
|
||
import request from '@/api/request'
|
||
|
||
describe('Member API', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
describe('getList', () => {
|
||
it('fetches member list successfully', async () => {
|
||
const mockResponse = {
|
||
list: [
|
||
{ id: 1, name: '张三', phone: '138****1234' },
|
||
{ id: 2, name: '李四', phone: '139****5678' }
|
||
],
|
||
total: 2
|
||
}
|
||
|
||
vi.spyOn(request, 'get').mockResolvedValue(mockResponse)
|
||
|
||
const result = await memberApi.getList({ page: 1, pageSize: 10 })
|
||
|
||
expect(result).toEqual(mockResponse)
|
||
expect(request.get).toHaveBeenCalledWith('/member/list', {
|
||
params: { page: 1, pageSize: 10 }
|
||
})
|
||
})
|
||
|
||
it('handles API error', async () => {
|
||
const mockError = new Error('Network error')
|
||
vi.spyOn(request, 'get').mockRejectedValue(mockError)
|
||
|
||
await expect(memberApi.getList({ page: 1, pageSize: 10 }))
|
||
.rejects.toThrow('Network error')
|
||
})
|
||
})
|
||
|
||
describe('getDetail', () => {
|
||
it('fetches member detail successfully', async () => {
|
||
const mockMember = { id: 1, name: '张三', phone: '138****1234' }
|
||
vi.spyOn(request, 'get').mockResolvedValue(mockMember)
|
||
|
||
const result = await memberApi.getDetail(1)
|
||
|
||
expect(result).toEqual(mockMember)
|
||
expect(request.get).toHaveBeenCalledWith('/member/1')
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
### 3.2 路由集成测试
|
||
|
||
```typescript
|
||
// router/index.spec.ts
|
||
import { describe, it, expect, beforeEach } from 'vitest'
|
||
import { createRouter, createWebHistory } from 'vue-router'
|
||
import routes from '@/router'
|
||
|
||
describe('Router', () => {
|
||
let router: any
|
||
|
||
beforeEach(() => {
|
||
router = createRouter({
|
||
history: createWebHistory(),
|
||
routes
|
||
})
|
||
})
|
||
|
||
it('navigates to member list', async () => {
|
||
await router.push('/member/list')
|
||
expect(router.currentRoute.value.path).toBe('/member/list')
|
||
})
|
||
|
||
it('requires authentication for protected routes', async () => {
|
||
await router.push('/member/profile')
|
||
expect(router.currentRoute.value.path).toBe('/login')
|
||
})
|
||
|
||
it('redirects to 404 for unknown routes', async () => {
|
||
await router.push('/unknown-route')
|
||
expect(router.currentRoute.value.path).toBe('/404')
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 四、E2E测试
|
||
|
||
### 4.1 Playwright配置
|
||
|
||
```typescript
|
||
// playwright.config.ts
|
||
import { defineConfig, devices } from '@playwright/test'
|
||
|
||
export default defineConfig({
|
||
testDir: './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'] }
|
||
}
|
||
],
|
||
webServer: {
|
||
command: 'npm run dev',
|
||
url: 'http://localhost:5173',
|
||
reuseExistingServer: !process.env.CI
|
||
}
|
||
})
|
||
```
|
||
|
||
### 4.2 会员端E2E测试
|
||
|
||
```typescript
|
||
// e2e/member.spec.ts
|
||
import { test, expect } from '@playwright/test'
|
||
|
||
test.describe('Member Login', () => {
|
||
test('should login successfully with valid credentials', async ({ page }) => {
|
||
await page.goto('/')
|
||
await page.click('text=登录')
|
||
|
||
await page.fill('[data-testid="phone-input"]', '13800138000')
|
||
await page.fill('[data-testid="code-input"]', '123456')
|
||
await page.click('[data-testid="login-button"]')
|
||
|
||
await expect(page).toHaveURL('/home')
|
||
await expect(page.locator('[data-testid="user-avatar"]')).toBeVisible()
|
||
})
|
||
|
||
test('should show error with invalid credentials', async ({ page }) => {
|
||
await page.goto('/login')
|
||
|
||
await page.fill('[data-testid="phone-input"]', '13800138000')
|
||
await page.fill('[data-testid="code-input"]', '000000')
|
||
await page.click('[data-testid="login-button"]')
|
||
|
||
await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
|
||
await expect(page.locator('[data-testid="error-message"]')).toContainText('验证码错误')
|
||
})
|
||
})
|
||
|
||
test.describe('Course Booking', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/login')
|
||
await page.fill('[data-testid="phone-input"]', '13800138000')
|
||
await page.fill('[data-testid="code-input"]', '123456')
|
||
await page.click('[data-testid="login-button"]')
|
||
await page.waitForURL('/home')
|
||
})
|
||
|
||
test('should book a course successfully', async ({ page }) => {
|
||
await page.goto('/booking/list')
|
||
await page.click('[data-testid="course-card"]:first-child')
|
||
await page.click('[data-testid="book-button"]')
|
||
|
||
await expect(page.locator('[data-testid="success-modal"]')).toBeVisible()
|
||
await expect(page.locator('[data-testid="success-modal"]')).toContainText('预约成功')
|
||
})
|
||
|
||
test('should show error when course is full', async ({ page }) => {
|
||
await page.goto('/booking/list')
|
||
await page.click('[data-testid="course-card"][data-full="true"]')
|
||
await page.click('[data-testid="book-button"]')
|
||
|
||
await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
|
||
await expect(page.locator('[data-testid="error-message"]')).toContainText('课程已满')
|
||
})
|
||
})
|
||
```
|
||
|
||
### 4.3 管理后台E2E测试
|
||
|
||
```typescript
|
||
// e2e/admin.spec.ts
|
||
import { test, expect } from '@playwright/test'
|
||
|
||
test.describe('Admin Dashboard', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/admin/login')
|
||
await page.fill('[data-testid="username-input"]', 'admin')
|
||
await page.fill('[data-testid="password-input"]', 'password123')
|
||
await page.click('[data-testid="login-button"]')
|
||
await page.waitForURL('/admin/dashboard')
|
||
})
|
||
|
||
test('should display member statistics', async ({ page }) => {
|
||
await expect(page.locator('[data-testid="total-members"]')).toBeVisible()
|
||
await expect(page.locator('[data-testid="active-members"]')).toBeVisible()
|
||
await expect(page.locator('[data-testid="new-members"]')).toBeVisible()
|
||
})
|
||
|
||
test('should navigate to member list', async ({ page }) => {
|
||
await page.click('[data-testid="member-menu"]')
|
||
await page.click('[data-testid="member-list-link"]')
|
||
|
||
await expect(page).toHaveURL('/admin/member/list')
|
||
await expect(page.locator('[data-testid="member-table"]')).toBeVisible()
|
||
})
|
||
|
||
test('should create new member', async ({ page }) => {
|
||
await page.goto('/admin/member/list')
|
||
await page.click('[data-testid="add-member-button"]')
|
||
|
||
await page.fill('[data-testid="name-input"]', '张三')
|
||
await page.fill('[data-testid="phone-input"]', '13800138000')
|
||
await page.click('[data-testid="submit-button"]')
|
||
|
||
await expect(page.locator('[data-testid="success-message"]')).toBeVisible()
|
||
await expect(page.locator('[data-testid="member-table"]')).toContainText('张三')
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 五、测试最佳实践
|
||
|
||
### 5.1 测试编写原则
|
||
|
||
#### 5.1.1 AAA模式
|
||
|
||
```typescript
|
||
// Arrange(准备)
|
||
const wrapper = mount(Component, {
|
||
props: { value: 10 }
|
||
})
|
||
|
||
// Act(执行)
|
||
await wrapper.trigger('click')
|
||
|
||
// Assert(断言)
|
||
expect(wrapper.emitted('click')).toBeTruthy()
|
||
```
|
||
|
||
#### 5.1.2 测试独立性
|
||
|
||
```typescript
|
||
// Bad: 测试之间有依赖
|
||
let wrapper: any
|
||
|
||
beforeEach(() => {
|
||
wrapper = mount(Component)
|
||
})
|
||
|
||
it('test 1', () => {
|
||
wrapper.setData({ count: 1 })
|
||
})
|
||
|
||
it('test 2', () => {
|
||
// 依赖test 1的结果
|
||
expect(wrapper.vm.count).toBe(1)
|
||
})
|
||
|
||
// Good: 每个测试独立
|
||
it('test 1', () => {
|
||
const wrapper = mount(Component)
|
||
wrapper.setData({ count: 1 })
|
||
expect(wrapper.vm.count).toBe(1)
|
||
})
|
||
|
||
it('test 2', () => {
|
||
const wrapper = mount(Component)
|
||
wrapper.setData({ count: 2 })
|
||
expect(wrapper.vm.count).toBe(2)
|
||
})
|
||
```
|
||
|
||
#### 5.1.3 测试可读性
|
||
|
||
```typescript
|
||
// Bad: 难以理解
|
||
it('works', () => {
|
||
const w = mount(C, { p: { a: 1 } })
|
||
w.vm.b = 2
|
||
expect(w.vm.c).toBe(3)
|
||
})
|
||
|
||
// Good: 清晰易懂
|
||
it('calculates sum correctly', () => {
|
||
const wrapper = mount(Calculator, {
|
||
props: { a: 1 }
|
||
})
|
||
wrapper.vm.b = 2
|
||
expect(wrapper.vm.sum).toBe(3)
|
||
})
|
||
```
|
||
|
||
### 5.2 Mock使用
|
||
|
||
#### 5.2.1 Mock API请求
|
||
|
||
```typescript
|
||
import { vi } from 'vitest'
|
||
import { memberApi } from '@/api/modules/member'
|
||
|
||
describe('MemberService', () => {
|
||
it('fetches member list', async () => {
|
||
const mockData = { list: [], total: 0 }
|
||
vi.spyOn(memberApi, 'getList').mockResolvedValue(mockData)
|
||
|
||
const result = await memberApi.getList({ page: 1, pageSize: 10 })
|
||
|
||
expect(result).toEqual(mockData)
|
||
expect(memberApi.getList).toHaveBeenCalledWith({ page: 1, pageSize: 10 })
|
||
})
|
||
})
|
||
```
|
||
|
||
#### 5.2.2 Mock组件
|
||
|
||
```typescript
|
||
import { mount } from '@vue/test-utils'
|
||
import ParentComponent from '@/components/ParentComponent.vue'
|
||
|
||
describe('ParentComponent', () => {
|
||
it('renders child component', () => {
|
||
const wrapper = mount(ParentComponent, {
|
||
global: {
|
||
stubs: {
|
||
ChildComponent: {
|
||
template: '<div>Mock Child</div>'
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
expect(wrapper.html()).toContain('Mock Child')
|
||
})
|
||
})
|
||
```
|
||
|
||
### 5.3 异步测试
|
||
|
||
```typescript
|
||
// 测试异步操作
|
||
it('handles async operation', async () => {
|
||
const wrapper = mount(Component)
|
||
|
||
await wrapper.find('.async-button').trigger('click')
|
||
|
||
// 等待异步操作完成
|
||
await wrapper.vm.$nextTick()
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
|
||
expect(wrapper.vm.data).toBe('loaded')
|
||
})
|
||
|
||
// 使用waitFor
|
||
it('waits for element to appear', async ({ page }) => {
|
||
await page.goto('/')
|
||
await page.click('.load-button')
|
||
|
||
await page.waitForSelector('.loaded-content')
|
||
await expect(page.locator('.loaded-content')).toBeVisible()
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 六、测试覆盖率
|
||
|
||
### 6.1 生成覆盖率报告
|
||
|
||
```bash
|
||
# 运行测试并生成覆盖率报告
|
||
npm run test:coverage
|
||
|
||
# 查看覆盖率报告
|
||
open coverage/index.html
|
||
```
|
||
|
||
### 6.2 覆盖率配置
|
||
|
||
```typescript
|
||
// vitest.config.ts
|
||
export default defineConfig({
|
||
test: {
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'json', 'html'],
|
||
lines: 85,
|
||
functions: 90,
|
||
branches: 75,
|
||
statements: 85,
|
||
exclude: [
|
||
'node_modules/',
|
||
'src/test/',
|
||
'**/*.d.ts',
|
||
'**/*.config.*',
|
||
'**/mockData',
|
||
'src/main.ts'
|
||
]
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 6.3 覆盖率目标
|
||
|
||
| 类型 | 目标 | 说明 |
|
||
|------|------|------|
|
||
| **Lines** | ≥ 85% | 代码行覆盖率 |
|
||
| **Functions** | ≥ 90% | 函数覆盖率 |
|
||
| **Branches** | ≥ 75% | 分支覆盖率 |
|
||
| **Statements** | ≥ 85% | 语句覆盖率 |
|
||
|
||
---
|
||
|
||
## 七、CI/CD集成
|
||
|
||
### 7.1 GitHub Actions配置
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
name: Test
|
||
|
||
on:
|
||
push:
|
||
branches: [ main, develop ]
|
||
pull_request:
|
||
branches: [ main, develop ]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
|
||
strategy:
|
||
matrix:
|
||
node-version: [18.x, 20.x]
|
||
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Use Node.js ${{ matrix.node-version }}
|
||
uses: actions/setup-node@v3
|
||
with:
|
||
node-version: ${{ matrix.node-version }}
|
||
|
||
- name: Install dependencies
|
||
run: npm ci
|
||
|
||
- name: Run unit tests
|
||
run: npm run test:unit
|
||
|
||
- name: Run E2E tests
|
||
run: npm run test:e2e
|
||
|
||
- name: Upload coverage
|
||
uses: codecov/codecov-action@v3
|
||
with:
|
||
files: ./coverage/coverage-final.json
|
||
```
|
||
|
||
### 7.2 测试命令
|
||
|
||
```json
|
||
// package.json
|
||
{
|
||
"scripts": {
|
||
"test": "vitest",
|
||
"test:unit": "vitest run",
|
||
"test:coverage": "vitest run --coverage",
|
||
"test:e2e": "playwright test",
|
||
"test:e2e:ui": "playwright test --ui",
|
||
"test:e2e:headed": "playwright test --headed"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 八、测试检查清单
|
||
|
||
### 8.1 单元测试检查清单
|
||
|
||
- [ ] 组件渲染正确
|
||
- [ ] Props传递正确
|
||
- [ ] 事件触发正确
|
||
- [ ] 计算属性计算正确
|
||
- [ ] 方法执行正确
|
||
- [ ] 生命周期钩子执行正确
|
||
- [ ] 边界情况处理正确
|
||
- [ ] 错误处理完善
|
||
|
||
### 8.2 集成测试检查清单
|
||
|
||
- [ ] API调用正确
|
||
- [ ] 状态管理正确
|
||
- [ ] 路由导航正确
|
||
- [ ] 组件通信正确
|
||
- [ ] 数据流正确
|
||
- [ ] 错误处理完善
|
||
|
||
### 8.3 E2E测试检查清单
|
||
|
||
- [ ] 关键业务流程覆盖
|
||
- [ ] 用户操作流程正确
|
||
- [ ] 页面跳转正确
|
||
- [ ] 数据提交正确
|
||
- [ ] 错误提示正确
|
||
- [ ] 加载状态正确
|
||
|
||
---
|
||
|
||
## 九、总结
|
||
|
||
本文档详细描述了健身房管理系统前端的测试规范,包括:
|
||
|
||
1. **测试概述**:测试目标、测试金字塔、测试覆盖率目标
|
||
2. **单元测试**:测试框架、组件测试、工具函数测试、Composables测试、Store测试
|
||
3. **集成测试**:API集成测试、路由集成测试
|
||
4. **E2E测试**:Playwright配置、会员端E2E测试、管理后台E2E测试
|
||
5. **测试最佳实践**:测试编写原则、Mock使用、异步测试
|
||
6. **测试覆盖率**:生成覆盖率报告、覆盖率配置、覆盖率目标
|
||
7. **CI/CD集成**:GitHub Actions配置、测试命令
|
||
8. **测试检查清单**:单元测试检查清单、集成测试检查清单、E2E测试检查清单
|
||
|
||
通过遵循本文档的测试规范,可以确保代码质量、减少bug、提高系统稳定性。
|