docs: reorganize documentation structure
This commit is contained in:
@@ -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、提高系统稳定性。
|
||||
Reference in New Issue
Block a user