22 KiB
22 KiB
健身房管理系统前端测试规范文档
文档编号: 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配置
// 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 测试环境设置
// 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 基础组件测试
// 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 业务组件测试
// 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 工具函数测试
// 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测试
// 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测试
// 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集成测试
// 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 路由集成测试
// 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配置
// 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测试
// 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测试
// 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模式
// Arrange(准备)
const wrapper = mount(Component, {
props: { value: 10 }
})
// Act(执行)
await wrapper.trigger('click')
// Assert(断言)
expect(wrapper.emitted('click')).toBeTruthy()
5.1.2 测试独立性
// 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 测试可读性
// 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请求
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组件
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 异步测试
// 测试异步操作
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 生成覆盖率报告
# 运行测试并生成覆盖率报告
npm run test:coverage
# 查看覆盖率报告
open coverage/index.html
6.2 覆盖率配置
// 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配置
# .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 测试命令
// 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测试检查清单
- 关键业务流程覆盖
- 用户操作流程正确
- 页面跳转正确
- 数据提交正确
- 错误提示正确
- 加载状态正确
九、总结
本文档详细描述了健身房管理系统前端的测试规范,包括:
- 测试概述:测试目标、测试金字塔、测试覆盖率目标
- 单元测试:测试框架、组件测试、工具函数测试、Composables测试、Store测试
- 集成测试:API集成测试、路由集成测试
- E2E测试:Playwright配置、会员端E2E测试、管理后台E2E测试
- 测试最佳实践:测试编写原则、Mock使用、异步测试
- 测试覆盖率:生成覆盖率报告、覆盖率配置、覆盖率目标
- CI/CD集成:GitHub Actions配置、测试命令
- 测试检查清单:单元测试检查清单、集成测试检查清单、E2E测试检查清单
通过遵循本文档的测试规范,可以确保代码质量、减少bug、提高系统稳定性。