Files
gym-manage/docs/design/前端测试规范.md
T
2026-03-05 13:48:13 +08:00

22 KiB
Raw Blame History

健身房管理系统前端测试规范文档

文档编号: 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测试检查清单

  • 关键业务流程覆盖
  • 用户操作流程正确
  • 页面跳转正确
  • 数据提交正确
  • 错误提示正确
  • 加载状态正确

九、总结

本文档详细描述了健身房管理系统前端的测试规范,包括:

  1. 测试概述:测试目标、测试金字塔、测试覆盖率目标
  2. 单元测试:测试框架、组件测试、工具函数测试、Composables测试、Store测试
  3. 集成测试API集成测试、路由集成测试
  4. E2E测试Playwright配置、会员端E2E测试、管理后台E2E测试
  5. 测试最佳实践:测试编写原则、Mock使用、异步测试
  6. 测试覆盖率:生成覆盖率报告、覆盖率配置、覆盖率目标
  7. CI/CD集成GitHub Actions配置、测试命令
  8. 测试检查清单:单元测试检查清单、集成测试检查清单、E2E测试检查清单

通过遵循本文档的测试规范,可以确保代码质量、减少bug、提高系统稳定性。