docs: reorganize documentation structure

This commit is contained in:
张翔
2026-03-05 13:48:13 +08:00
parent 349b0a754f
commit 104fa7e7c8
59 changed files with 22859 additions and 916 deletions
+924
View File
@@ -0,0 +1,924 @@
# 健身房管理系统前端测试规范文档
> 文档编号: 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、提高系统稳定性。