# 健身房管理系统前端测试规范文档 > 文档编号: 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: '
Mock Child
' } } } }) 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、提高系统稳定性。