Files
jiebanke/docs/测试文档.md

43 KiB
Raw Blame History

结伴客项目测试文档

1. 测试概述

1.1 测试目标

确保结伴客项目各个模块的功能正确性、性能稳定性、安全可靠性,为产品上线提供质量保障。

1.2 测试范围

  • 后端API服务:接口功能、性能、安全测试
  • 小程序应用:功能、兼容性、用户体验测试
  • 管理后台:功能、权限、数据一致性测试
  • 数据库:数据完整性、性能、备份恢复测试
  • 系统集成:各模块间集成测试

1.3 测试策略

graph TD
    A[测试策略] --> B[单元测试]
    A --> C[集成测试]
    A --> D[系统测试]
    A --> E[验收测试]
    
    B --> B1[代码覆盖率>80%]
    B --> B2[自动化执行]
    
    C --> C1[API集成测试]
    C --> C2[数据库集成测试]
    
    D --> D1[功能测试]
    D --> D2[性能测试]
    D --> D3[安全测试]
    
    E --> E1[用户验收测试]
    E --> E2[业务流程验证]
## 2. 测试环境

### 2.1 环境配置

| 环境类型 | 用途 | 配置 | 数据库 |
|---------|------|------|--------|
| 开发环境 | 开发调试 | 本地Docker | MySQL 8.0 |
| 测试环境 | 功能测试 | 测试服务器 | MySQL 8.0 |
| 预发布环境 | 集成测试 | 生产同配置 | MySQL 8.0 |
| 生产环境 | 线上服务 | 高可用集群 | MySQL 8.0主从 |

### 2.2 测试数据管理

```yaml
# 测试数据配置
test_data:
  users:
    - username: "test_user_001"
      email: "test001@example.com"
      role: "user"
    - username: "test_admin_001"
      email: "admin001@example.com"
      role: "admin"
  
  trips:
    - title: "测试旅行001"
      destination: "北京"
      start_date: "2024-06-01"
      end_date: "2024-06-07"
  
  animals:
    - name: "测试动物001"
      type: "cat"
      location: "北京动物园"

3. 测试计划

3.1 测试阶段

gantt
    title 测试计划时间线
    dateFormat  YYYY-MM-DD
    section 单元测试
    后端单元测试    :ut1, 2024-03-01, 7d
    前端单元测试    :ut2, 2024-03-01, 7d
    
    section 集成测试
    API集成测试     :it1, after ut1, 5d
    数据库集成测试   :it2, after ut1, 3d
    
    section 系统测试
    功能测试       :st1, after it1, 10d
    性能测试       :st2, after it1, 7d
    安全测试       :st3, after it1, 5d
    
    section 验收测试
    用户验收测试    :at1, after st1, 7d

3.2 测试用例设计

3.2.1 后端API测试用例

// 用户注册接口测试用例
describe('用户注册API', () => {
  test('正常注册流程', async () => {
    const userData = {
      username: 'testuser001',
      email: 'test@example.com',
      password: 'Test123456',
      phone: '13800138000'
    };
    
    const response = await request(app)
      .post('/api/auth/register')
      .send(userData)
      .expect(200);
    
    expect(response.body.code).toBe(0);
    expect(response.body.data.user.username).toBe(userData.username);
  });
  
  test('重复邮箱注册', async () => {
    const response = await request(app)
      .post('/api/auth/register')
      .send({
        username: 'testuser002',
        email: 'test@example.com', // 重复邮箱
        password: 'Test123456'
      })
      .expect(400);
    
    expect(response.body.code).toBe(40001);
    expect(response.body.message).toContain('邮箱已存在');
  });
});

3.2.2 小程序功能测试用例

// 小程序页面测试
describe('旅行结伴页面', () => {
  test('页面正常加载', async () => {
    const page = await miniProgram.reLaunch('/pages/trip/list');
    await page.waitFor(2000);
    
    const title = await page.$('.page-title');
    expect(await title.text()).toBe('旅行结伴');
  });
  
  test('创建旅行结伴', async () => {
    const page = await miniProgram.navigateTo('/pages/trip/create');
    
    await page.setData({
      'form.title': '测试旅行',
      'form.destination': '北京',
      'form.startDate': '2024-06-01',
      'form.endDate': '2024-06-07'
    });
    
    await page.tap('.submit-btn');
    await page.waitFor(1000);
    
    expect(page.path).toBe('/pages/trip/detail');
  });
});

4. 性能测试

4.1 性能指标

指标类型 目标值 测试方法
接口响应时间 < 500ms JMeter压测
页面加载时间 < 2s Lighthouse
并发用户数 1000+ 压力测试
数据库查询 < 100ms SQL性能分析
内存使用率 < 80% 系统监控

4.2 JMeter压测脚本

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="结伴客API压测">
      <elementProp name="TestPlan.arguments" elementType="Arguments" guiclass="ArgumentsPanel">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
      <stringProp name="TestPlan.user_define_classpath"></stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
    </TestPlan>
    
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="用户登录压测">
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">100</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.num_threads">50</stringProp>
        <stringProp name="ThreadGroup.ramp_time">10</stringProp>
      </ThreadGroup>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

4.3 Artillery性能测试

# artillery-config.yml
config:
  target: 'http://localhost:3000'
  phases:
    - duration: 60
      arrivalRate: 10
    - duration: 120
      arrivalRate: 50
    - duration: 60
      arrivalRate: 100

scenarios:
  - name: "用户注册登录流程"
    flow:
      - post:
          url: "/api/auth/register"
          json:
            username: "test_{{ $randomString() }}"
            email: "{{ $randomString() }}@test.com"
            password: "Test123456"
      - post:
          url: "/api/auth/login"
          json:
            email: "{{ email }}"
            password: "Test123456"

5. 安全测试

5.1 安全测试范围

  • 身份认证安全JWT令牌、密码加密
  • 授权控制:角色权限、接口鉴权
  • 数据安全SQL注入、XSS攻击
  • 传输安全HTTPS、数据加密
  • 系统安全:文件上传、敏感信息泄露

5.2 OWASP ZAP安全扫描

# zap-baseline-scan.yml
version: '3'
services:
  zap:
    image: owasp/zap2docker-stable
    command: zap-baseline.py -t http://host.docker.internal:3000 -r zap-report.html
    volumes:
      - ./reports:/zap/wrk/:rw

5.3 安全测试用例

// SQL注入测试
describe('SQL注入防护测试', () => {
  test('用户名SQL注入', async () => {
    const maliciousInput = "admin'; DROP TABLE users; --";
    
    const response = await request(app)
      .post('/api/auth/login')
      .send({
        username: maliciousInput,
        password: 'password'
      });
    
    // 应该返回错误而不是执行SQL
    expect(response.status).toBe(400);
    expect(response.body.message).toContain('参数格式错误');
  });
});

// XSS攻击测试
describe('XSS防护测试', () => {
  test('评论内容XSS过滤', async () => {
    const xssPayload = '<script>alert("XSS")</script>';
    
    const response = await request(app)
      .post('/api/comments')
      .set('Authorization', `Bearer ${token}`)
      .send({
        content: xssPayload,
        tripId: 1
      });
    
    expect(response.body.data.content).not.toContain('<script>');
  });
});

6. 测试报告

6.1 测试报告模板

# 结伴客项目测试报告

## 测试概要
- **测试版本**: v1.0.0
- **测试时间**: 2024-03-01 ~ 2024-03-15
- **测试环境**: 测试环境
- **测试人员**: 测试团队

## 测试结果统计
- **总用例数**: 500
- **通过用例**: 485
- **失败用例**: 15
- **通过率**: 97%

## 缺陷统计
- **严重缺陷**: 0
- **一般缺陷**: 8
- **轻微缺陷**: 7
- **建议优化**: 12

## 性能测试结果
- **平均响应时间**: 245ms
- **最大并发用户**: 1200
- **系统稳定性**: 99.8%

## 安全测试结果
- **高危漏洞**: 0
- **中危漏洞**: 2
- **低危漏洞**: 5

## 测试结论
系统整体质量良好,满足上线要求。

6.2 自动化测试报告

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  reporters: [
    'default',
    ['jest-html-reporters', {
      publicPath: './test-reports',
      filename: 'test-report.html'
    }]
  ]
};

7. 总结

7.1 测试策略总结

  • 全面覆盖:从单元测试到端到端测试的完整覆盖
  • 自动化优先80%以上测试用例实现自动化
  • 持续集成集成到CI/CD流程中
  • 质量保证:建立完善的质量门禁机制

7.2 测试工具链

  • 单元测试Jest, Vitest
  • 集成测试Supertest, TestContainers
  • E2E测试Playwright, Cypress
  • 性能测试JMeter, Artillery
  • 安全测试OWASP ZAP, SonarQube
  • 测试管理TestRail, Jira

7.3 持续改进

  • 定期回顾测试策略和流程
  • 持续优化测试用例和自动化脚本
  • 加强团队测试技能培训
  • 建立测试最佳实践知识库

4. 性能测试 (Performance Testing)

  • 目标: 验证系统性能指标
  • 工具: JMeter, K6, Artillery
  • 覆盖: 负载、压力、并发

🧪 测试策略

测试类型

功能测试

// 示例:用户登录功能测试
describe('用户登录功能', () => {
  test('正确的用户名和密码应该登录成功', async () => {
    const loginData = {
      username: 'testuser',
      password: 'password123'
    }
    
    const response = await request(app)
      .post('/api/auth/login')
      .send(loginData)
      .expect(200)
    
    expect(response.body).toHaveProperty('token')
    expect(response.body.user.username).toBe('testuser')
  })
  
  test('错误的密码应该返回401错误', async () => {
    const loginData = {
      username: 'testuser',
      password: 'wrongpassword'
    }
    
    const response = await request(app)
      .post('/api/auth/login')
      .send(loginData)
      .expect(401)
    
    expect(response.body.message).toBe('用户名或密码错误')
  })
})

安全测试

// 示例SQL注入防护测试
describe('安全测试', () => {
  test('应该防止SQL注入攻击', async () => {
    const maliciousInput = "'; DROP TABLE users; --"
    
    const response = await request(app)
      .get(`/api/animals/search?name=${maliciousInput}`)
      .expect(400)
    
    // 验证数据库表仍然存在
    const userCount = await User.count()
    expect(userCount).toBeGreaterThan(0)
  })
  
  test('应该防止XSS攻击', async () => {
    const xssPayload = '<script>alert("xss")</script>'
    
    const response = await request(app)
      .post('/api/animals')
      .send({ name: xssPayload, description: 'test' })
      .expect(400)
    
    expect(response.body.message).toContain('输入包含非法字符')
  })
})

性能测试

// 示例API响应时间测试
describe('性能测试', () => {
  test('动物列表API响应时间应小于2秒', async () => {
    const startTime = Date.now()
    
    const response = await request(app)
      .get('/api/animals')
      .expect(200)
    
    const responseTime = Date.now() - startTime
    expect(responseTime).toBeLessThan(2000)
  })
  
  test('并发请求处理能力', async () => {
    const promises = Array.from({ length: 100 }, () =>
      request(app).get('/api/animals').expect(200)
    )
    
    const startTime = Date.now()
    await Promise.all(promises)
    const totalTime = Date.now() - startTime
    
    expect(totalTime).toBeLessThan(10000) // 100个并发请求在10秒内完成
  })
})

📝 测试用例设计

用户认证模块测试用例

用户注册测试

Feature: 用户注册
  作为一个新用户
  我想要注册账户
  以便使用系统功能

  Scenario: 成功注册新用户
    Given 我在注册页面
    When 我输入有效的用户信息
      | 字段 | |
      | 用户名 | testuser123 |
      | 邮箱 | test@example.com |
      | 密码 | Password123! |
      | 确认密码 | Password123! |
    And 我点击注册按钮
    Then 我应该看到注册成功消息
    And 我应该收到验证邮件

  Scenario: 用户名已存在
    Given 系统中已存在用户名为"existinguser"的用户
    When 我尝试注册用户名为"existinguser"的账户
    Then 我应该看到"用户名已存在"的错误消息

  Scenario: 密码强度不足
    Given 我在注册页面
    When 我输入弱密码"123456"
    Then 我应该看到密码强度提示
    And 注册按钮应该被禁用

动物管理测试用例

Feature: 动物信息管理
  作为管理员
  我想要管理动物信息
  以便为用户提供准确的动物数据

  Scenario: 添加新动物
    Given 我以管理员身份登录
    When 我填写动物信息表单
      | 字段 | |
      | 名称 | 小白 |
      | 物种 | |
      | 年龄 | 2岁 |
      | 性别 | 雌性 |
      | 描述 | 温顺可爱的小狗 |
    And 我上传动物照片
    And 我点击保存按钮
    Then 动物信息应该被成功保存
    And 我应该在动物列表中看到新添加的动物

  Scenario: 搜索动物
    Given 系统中有多个动物记录
    When 我在搜索框中输入"小白"
    And 我点击搜索按钮
    Then 我应该看到包含"小白"的搜索结果
    And 结果应该按相关性排序

API接口测试用例

动物API测试

describe('动物API测试', () => {
  let authToken
  let testAnimalId

  beforeAll(async () => {
    // 获取认证token
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({ username: 'admin', password: 'admin123' })
    
    authToken = loginResponse.body.token
  })

  describe('GET /api/animals', () => {
    test('应该返回动物列表', async () => {
      const response = await request(app)
        .get('/api/animals')
        .expect(200)

      expect(response.body).toHaveProperty('data')
      expect(response.body).toHaveProperty('total')
      expect(response.body).toHaveProperty('page')
      expect(Array.isArray(response.body.data)).toBe(true)
    })

    test('应该支持分页参数', async () => {
      const response = await request(app)
        .get('/api/animals?page=1&limit=5')
        .expect(200)

      expect(response.body.data.length).toBeLessThanOrEqual(5)
      expect(response.body.page).toBe(1)
    })

    test('应该支持搜索功能', async () => {
      const response = await request(app)
        .get('/api/animals?search=狗')
        .expect(200)

      response.body.data.forEach(animal => {
        expect(
          animal.name.includes('狗') || 
          animal.species.includes('狗') || 
          animal.description.includes('狗')
        ).toBe(true)
      })
    })
  })

  describe('POST /api/animals', () => {
    test('管理员应该能够创建动物', async () => {
      const animalData = {
        name: '测试动物',
        species: '狗',
        breed: '金毛',
        gender: 'male',
        age_months: 24,
        description: '测试用动物'
      }

      const response = await request(app)
        .post('/api/animals')
        .set('Authorization', `Bearer ${authToken}`)
        .send(animalData)
        .expect(201)

      expect(response.body.data).toHaveProperty('id')
      expect(response.body.data.name).toBe(animalData.name)
      
      testAnimalId = response.body.data.id
    })

    test('未认证用户不能创建动物', async () => {
      const animalData = {
        name: '测试动物',
        species: '狗'
      }

      await request(app)
        .post('/api/animals')
        .send(animalData)
        .expect(401)
    })

    test('应该验证必填字段', async () => {
      const response = await request(app)
        .post('/api/animals')
        .set('Authorization', `Bearer ${authToken}`)
        .send({})
        .expect(400)

      expect(response.body.errors).toContain('name is required')
      expect(response.body.errors).toContain('species is required')
    })
  })

  describe('PUT /api/animals/:id', () => {
    test('应该能够更新动物信息', async () => {
      const updateData = {
        name: '更新后的名称',
        description: '更新后的描述'
      }

      const response = await request(app)
        .put(`/api/animals/${testAnimalId}`)
        .set('Authorization', `Bearer ${authToken}`)
        .send(updateData)
        .expect(200)

      expect(response.body.data.name).toBe(updateData.name)
      expect(response.body.data.description).toBe(updateData.description)
    })

    test('更新不存在的动物应该返回404', async () => {
      await request(app)
        .put('/api/animals/999999')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: '测试' })
        .expect(404)
    })
  })

  describe('DELETE /api/animals/:id', () => {
    test('应该能够删除动物', async () => {
      await request(app)
        .delete(`/api/animals/${testAnimalId}`)
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200)

      // 验证动物已被删除
      await request(app)
        .get(`/api/animals/${testAnimalId}`)
        .expect(404)
    })
  })
})

🎭 前端测试

组件测试

Vue组件测试

// AnimalCard.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import AnimalCard from '@/components/AnimalCard.vue'

describe('AnimalCard组件', () => {
  const mockAnimal = {
    id: 1,
    name: '小白',
    species: '狗',
    age_months: 24,
    gender: 'female',
    description: '可爱的小狗',
    images: ['https://example.com/image1.jpg'],
    status: 'available'
  }

  it('应该正确渲染动物信息', () => {
    const wrapper = mount(AnimalCard, {
      props: { animal: mockAnimal }
    })

    expect(wrapper.find('.animal-name').text()).toBe('小白')
    expect(wrapper.find('.animal-species').text()).toBe('狗')
    expect(wrapper.find('.animal-age').text()).toContain('2岁')
    expect(wrapper.find('.animal-description').text()).toBe('可爱的小狗')
  })

  it('应该显示动物图片', () => {
    const wrapper = mount(AnimalCard, {
      props: { animal: mockAnimal }
    })

    const img = wrapper.find('.animal-image')
    expect(img.exists()).toBe(true)
    expect(img.attributes('src')).toBe(mockAnimal.images[0])
    expect(img.attributes('alt')).toBe(mockAnimal.name)
  })

  it('点击认领按钮应该触发事件', async () => {
    const wrapper = mount(AnimalCard, {
      props: { animal: mockAnimal }
    })

    const adoptButton = wrapper.find('.adopt-button')
    await adoptButton.trigger('click')

    expect(wrapper.emitted('adopt')).toBeTruthy()
    expect(wrapper.emitted('adopt')[0]).toEqual([mockAnimal.id])
  })

  it('已认领的动物不应该显示认领按钮', () => {
    const adoptedAnimal = { ...mockAnimal, status: 'adopted' }
    const wrapper = mount(AnimalCard, {
      props: { animal: adoptedAnimal }
    })

    expect(wrapper.find('.adopt-button').exists()).toBe(false)
    expect(wrapper.find('.adopted-label').exists()).toBe(true)
  })

  it('应该处理图片加载错误', async () => {
    const wrapper = mount(AnimalCard, {
      props: { animal: mockAnimal }
    })

    const img = wrapper.find('.animal-image')
    await img.trigger('error')

    expect(wrapper.find('.image-placeholder').exists()).toBe(true)
  })
})

页面测试

// AnimalList.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import AnimalList from '@/pages/AnimalList.vue'
import { useAnimalStore } from '@/stores/animal'

// Mock API
vi.mock('@/api/animal', () => ({
  getAnimals: vi.fn(() => Promise.resolve({
    data: [
      { id: 1, name: '小白', species: '狗' },
      { id: 2, name: '小黑', species: '猫' }
    ],
    total: 2,
    page: 1
  }))
}))

describe('AnimalList页面', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('应该加载并显示动物列表', async () => {
    const wrapper = mount(AnimalList)
    
    // 等待异步加载完成
    await wrapper.vm.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 100))

    expect(wrapper.findAll('.animal-card')).toHaveLength(2)
    expect(wrapper.find('.animal-card').text()).toContain('小白')
  })

  it('应该支持搜索功能', async () => {
    const wrapper = mount(AnimalList)
    
    const searchInput = wrapper.find('.search-input')
    await searchInput.setValue('小白')
    await searchInput.trigger('input')

    // 验证搜索参数被传递
    const animalStore = useAnimalStore()
    expect(animalStore.searchParams.keyword).toBe('小白')
  })

  it('应该支持筛选功能', async () => {
    const wrapper = mount(AnimalList)
    
    const speciesFilter = wrapper.find('.species-filter')
    await speciesFilter.setValue('狗')
    await speciesFilter.trigger('change')

    const animalStore = useAnimalStore()
    expect(animalStore.searchParams.species).toBe('狗')
  })

  it('应该处理加载状态', () => {
    const wrapper = mount(AnimalList)
    
    // 模拟加载状态
    const animalStore = useAnimalStore()
    animalStore.loading = true

    expect(wrapper.find('.loading-spinner').exists()).toBe(true)
  })

  it('应该处理错误状态', async () => {
    // Mock API错误
    vi.mocked(getAnimals).mockRejectedValueOnce(new Error('网络错误'))
    
    const wrapper = mount(AnimalList)
    await wrapper.vm.$nextTick()

    expect(wrapper.find('.error-message').exists()).toBe(true)
    expect(wrapper.find('.error-message').text()).toContain('加载失败')
  })
})

E2E测试

Playwright E2E测试

// e2e/animal-adoption.spec.js
import { test, expect } from '@playwright/test'

test.describe('动物认领流程', () => {
  test.beforeEach(async ({ page }) => {
    // 登录用户
    await page.goto('/login')
    await page.fill('[data-testid="username"]', 'testuser')
    await page.fill('[data-testid="password"]', 'password123')
    await page.click('[data-testid="login-button"]')
    await expect(page).toHaveURL('/')
  })

  test('完整的动物认领流程', async ({ page }) => {
    // 1. 浏览动物列表
    await page.goto('/animals')
    await expect(page.locator('.animal-card')).toHaveCount.greaterThan(0)

    // 2. 搜索特定动物
    await page.fill('[data-testid="search-input"]', '小白')
    await page.click('[data-testid="search-button"]')
    await expect(page.locator('.animal-card')).toContainText('小白')

    // 3. 查看动物详情
    await page.click('.animal-card:first-child')
    await expect(page).toHaveURL(/\/animals\/\d+/)
    await expect(page.locator('.animal-detail')).toBeVisible()

    // 4. 申请认领
    await page.click('[data-testid="adopt-button"]')
    await expect(page).toHaveURL(/\/adoption\/apply/)

    // 5. 填写认领申请表
    await page.fill('[data-testid="applicant-name"]', '张三')
    await page.fill('[data-testid="applicant-phone"]', '13800138000')
    await page.fill('[data-testid="applicant-email"]', 'zhangsan@example.com')
    await page.fill('[data-testid="applicant-address"]', '北京市朝阳区')
    await page.selectOption('[data-testid="housing-type"]', 'apartment')
    await page.fill('[data-testid="adoption-reason"]', '我很喜欢小动物,希望给它一个温暖的家')

    // 6. 提交申请
    await page.click('[data-testid="submit-application"]')
    await expect(page.locator('.success-message')).toContainText('申请提交成功')

    // 7. 查看申请状态
    await page.goto('/user/adoptions')
    await expect(page.locator('.adoption-application')).toContainText('审核中')
  })

  test('动物搜索和筛选功能', async ({ page }) => {
    await page.goto('/animals')

    // 测试搜索功能
    await page.fill('[data-testid="search-input"]', '狗')
    await page.click('[data-testid="search-button"]')
    
    const animalCards = page.locator('.animal-card')
    const count = await animalCards.count()
    
    for (let i = 0; i < count; i++) {
      const card = animalCards.nth(i)
      const text = await card.textContent()
      expect(text).toMatch(/狗|犬/)
    }

    // 测试筛选功能
    await page.selectOption('[data-testid="species-filter"]', '猫')
    await page.waitForLoadState('networkidle')
    
    const catCards = page.locator('.animal-card')
    const catCount = await catCards.count()
    
    for (let i = 0; i < catCount; i++) {
      const card = catCards.nth(i)
      const text = await card.textContent()
      expect(text).toContain('猫')
    }
  })

  test('响应式设计测试', async ({ page }) => {
    // 测试桌面端
    await page.setViewportSize({ width: 1200, height: 800 })
    await page.goto('/animals')
    await expect(page.locator('.animal-grid')).toHaveClass(/grid-cols-3/)

    // 测试平板端
    await page.setViewportSize({ width: 768, height: 1024 })
    await page.reload()
    await expect(page.locator('.animal-grid')).toHaveClass(/grid-cols-2/)

    // 测试移动端
    await page.setViewportSize({ width: 375, height: 667 })
    await page.reload()
    await expect(page.locator('.animal-grid')).toHaveClass(/grid-cols-1/)
    await expect(page.locator('.mobile-nav')).toBeVisible()
  })
})

🚀 性能测试

负载测试

JMeter测试计划

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="结伴客性能测试">
<stringProp name="TestPlan.comments">结伴客系统性能测试计划</stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
      <elementProp name="TestPlan.arguments" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="用户定义的变量">
        <collectionProp name="Arguments.arguments">
          <elementProp name="BASE_URL" elementType="Argument">
            <stringProp name="Argument.name">BASE_URL</stringProp>
            <stringProp name="Argument.value">http://localhost:3001</stringProp>
          </elementProp>
        </collectionProp>
      </elementProp>
    </TestPlan>
    
    <hashTree>
      <!-- 动物列表API负载测试 -->
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="动物列表API负载测试">
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">10</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.num_threads">100</stringProp>
        <stringProp name="ThreadGroup.ramp_time">60</stringProp>
      </ThreadGroup>
      
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="获取动物列表">
          <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
            <collectionProp name="Arguments.arguments">
              <elementProp name="page" elementType="HTTPArgument">
                <boolProp name="HTTPArgument.always_encode">false</boolProp>
                <stringProp name="Argument.value">1</stringProp>
                <stringProp name="Argument.name">page</stringProp>
              </elementProp>
              <elementProp name="limit" elementType="HTTPArgument">
                <boolProp name="HTTPArgument.always_encode">false</boolProp>
                <stringProp name="Argument.value">20</stringProp>
                <stringProp name="Argument.name">limit</stringProp>
              </elementProp>
            </collectionProp>
          </elementProp>
          <stringProp name="HTTPSampler.domain">${BASE_URL}</stringProp>
          <stringProp name="HTTPSampler.path">/api/animals</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
        </HTTPSamplerProxy>
        
        <!-- 响应时间断言 -->
        <DurationAssertion guiclass="DurationAssertionGui" testclass="DurationAssertion" testname="响应时间断言">
          <stringProp name="DurationAssertion.duration">2000</stringProp>
        </DurationAssertion>
        
        <!-- 响应状态断言 -->
        <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="响应状态断言">
          <collectionProp name="Asserion.test_strings">
            <stringProp>200</stringProp>
          </collectionProp>
          <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
          <boolProp name="Assertion.assume_success">false</boolProp>
          <intProp name="Assertion.test_type">1</intProp>
        </ResponseAssertion>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

K6性能测试脚本

// performance/load-test.js
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Rate } from 'k6/metrics'

// 自定义指标
const errorRate = new Rate('errors')

// 测试配置
export const options = {
  stages: [
    { duration: '2m', target: 10 },   // 预热阶段
    { duration: '5m', target: 50 },   // 负载增加
    { duration: '10m', target: 100 }, // 稳定负载
    { duration: '5m', target: 200 },  // 峰值负载
    { duration: '2m', target: 0 },    // 负载下降
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000'], // 95%的请求响应时间小于2秒
    http_req_failed: ['rate<0.1'],     // 错误率小于10%
    errors: ['rate<0.1'],              // 自定义错误率小于10%
  },
}

const BASE_URL = 'http://localhost:3001'

export default function () {
  // 测试动物列表API
  const animalsResponse = http.get(`${BASE_URL}/api/animals?page=1&limit=20`)
  
  const animalsCheck = check(animalsResponse, {
    '动物列表状态码为200': (r) => r.status === 200,
    '动物列表响应时间<2s': (r) => r.timings.duration < 2000,
    '动物列表包含数据': (r) => JSON.parse(r.body).data.length > 0,
  })
  
  errorRate.add(!animalsCheck)

  // 测试动物详情API
  if (animalsCheck) {
    const animals = JSON.parse(animalsResponse.body).data
    if (animals.length > 0) {
      const randomAnimal = animals[Math.floor(Math.random() * animals.length)]
      
      const detailResponse = http.get(`${BASE_URL}/api/animals/${randomAnimal.id}`)
      
      const detailCheck = check(detailResponse, {
        '动物详情状态码为200': (r) => r.status === 200,
        '动物详情响应时间<1s': (r) => r.timings.duration < 1000,
        '动物详情包含ID': (r) => JSON.parse(r.body).data.id === randomAnimal.id,
      })
      
      errorRate.add(!detailCheck)
    }
  }

  // 测试搜索API
  const searchResponse = http.get(`${BASE_URL}/api/animals?search=狗&page=1&limit=10`)
  
  const searchCheck = check(searchResponse, {
    '搜索状态码为200': (r) => r.status === 200,
    '搜索响应时间<3s': (r) => r.timings.duration < 3000,
  })
  
  errorRate.add(!searchCheck)

  sleep(1) // 模拟用户思考时间
}

// 测试完成后的处理
export function handleSummary(data) {
  return {
    'performance-report.html': htmlReport(data),
    'performance-summary.json': JSON.stringify(data),
  }
}

function htmlReport(data) {
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <title>性能测试报告</title>
      <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .metric { margin: 10px 0; padding: 10px; border: 1px solid #ddd; }
        .pass { background-color: #d4edda; }
        .fail { background-color: #f8d7da; }
      </style>
    </head>
    <body>
      <h1>结伴客性能测试报告</h1>
      <h2>测试概要</h2>
      <div class="metric">
        <strong>总请求数:</strong> ${data.metrics.http_reqs.count}
      </div>
      <div class="metric">
        <strong>平均响应时间:</strong> ${data.metrics.http_req_duration.avg.toFixed(2)}ms
      </div>
      <div class="metric">
        <strong>95%响应时间:</strong> ${data.metrics.http_req_duration['p(95)'].toFixed(2)}ms
      </div>
      <div class="metric ${data.metrics.http_req_failed.rate < 0.1 ? 'pass' : 'fail'}">
        <strong>错误率:</strong> ${(data.metrics.http_req_failed.rate * 100).toFixed(2)}%
      </div>
    </body>
    </html>
  `
}

🔧 测试工具配置

Jest配置

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/tests'],
  testMatch: [
    '**/__tests__/**/*.js',
    '**/?(*.)+(spec|test).js'
  ],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/config/**',
    '!src/migrations/**'
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
  testTimeout: 10000,
  verbose: true,
  collectCoverage: true,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
}

Vitest配置

// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setup.js'],
    coverage: {
      provider: 'c8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.js'
      ]
    }
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
})

Playwright配置

// playwright.config.js
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'],
    ['json', { outputFile: 'test-results/results.json' }]
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] }
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] }
    }
  ],
  webServer: {
    command: 'npm run dev',
    port: 3000,
    reuseExistingServer: !process.env.CI
  }
})

📊 测试报告

覆盖率报告

// 生成覆盖率报告脚本
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')

function generateCoverageReport() {
  console.log('生成测试覆盖率报告...')
  
  // 运行测试并生成覆盖率
  execSync('npm run test:coverage', { stdio: 'inherit' })
  
  // 读取覆盖率数据
  const coverageFile = path.join(__dirname, 'coverage/coverage-summary.json')
  const coverage = JSON.parse(fs.readFileSync(coverageFile, 'utf8'))
  
  // 生成HTML报告
  const htmlReport = generateHtmlReport(coverage)
  fs.writeFileSync('coverage-report.html', htmlReport)
  
  console.log('覆盖率报告已生成: coverage-report.html')
}

function generateHtmlReport(coverage) {
  const total = coverage.total
  
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <title>测试覆盖率报告</title>
      <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .summary { background: #f5f5f5; padding: 20px; border-radius: 5px; }
        .metric { display: inline-block; margin: 10px; padding: 10px; border: 1px solid #ddd; }
        .high { background-color: #d4edda; }
        .medium { background-color: #fff3cd; }
        .low { background-color: #f8d7da; }
      </style>
    </head>
    <body>
      <h1>结伴客测试覆盖率报告</h1>
      <div class="summary">
        <h2>总体覆盖率</h2>
        <div class="metric ${getColorClass(total.lines.pct)}">
          <strong>行覆盖率:</strong> ${total.lines.pct}%
        </div>
        <div class="metric ${getColorClass(total.functions.pct)}">
          <strong>函数覆盖率:</strong> ${total.functions.pct}%
        </div>
        <div class="metric ${getColorClass(total.branches.pct)}">
          <strong>分支覆盖率:</strong> ${total.branches.pct}%
        </div>
        <div class="metric ${getColorClass(total.statements.pct)}">
          <strong>语句覆盖率:</strong> ${total.statements.pct}%
        </div>
      </div>
      
      <h2>详细信息</h2>
      <p>总行数: ${total.lines.total}</p>
      <p>已覆盖行数: ${total.lines.covered}</p>
      <p>未覆盖行数: ${total.lines.skipped}</p>
      
      <h2>建议</h2>
      <ul>
        ${total.lines.pct < 80 ? '<li>行覆盖率低于80%,需要增加测试用例</li>' : ''}
        ${total.functions.pct < 80 ? '<li>函数覆盖率低于80%,需要测试更多函数</li>' : ''}
        ${total.branches.pct < 80 ? '<li>分支覆盖率低于80%,需要测试更多分支条件</li>' : ''}
      </ul>
    </body>
    </html>
  `
}

function getColorClass(percentage) {
  if (percentage >= 80) return 'high'
  if (percentage >= 60) return 'medium'
  return 'low'
}

generateCoverageReport()

🔄 CI/CD集成

GitHub Actions测试工作流

# .github/workflows/test.yml
name: 测试工作流

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: jiebanke_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
      
      redis:
        image: redis:6
        ports:
          - 6379:6379
        options: >-
          --health-cmd="redis-cli ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
    - uses: actions/checkout@v3
    
    - name: 设置Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: 安装依赖
      run: |
        cd backend
        npm ci
    
    - name: 运行数据库迁移
      run: |
        cd backend
        npm run migrate
      env:
        DB_HOST: localhost
        DB_PORT: 3306
        DB_NAME: jiebanke_test
        DB_USER: root
        DB_PASS: root
    
    - name: 运行单元测试
      run: |
        cd backend
        npm run test:coverage
      env:
        NODE_ENV: test
        DB_HOST: localhost
        DB_PORT: 3306
        DB_NAME: jiebanke_test
        DB_USER: root
        DB_PASS: root
        REDIS_HOST: localhost
        REDIS_PORT: 6379
    
    - name: 上传覆盖率报告
      uses: codecov/codecov-action@v3
      with:
        file: ./backend/coverage/lcov.info
        flags: backend
        name: backend-coverage

  frontend-tests:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: 设置Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: 安装依赖
      run: |
        cd frontend
        npm ci
    
    - name: 运行前端测试
      run: |
        cd frontend
        npm run test:coverage
    
    - name: 上传覆盖率报告
      uses: codecov/codecov-action@v3
      with:
        file: ./frontend/coverage/lcov.info
        flags: frontend
        name: frontend-coverage

  e2e-tests:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: 设置Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: 安装依赖
      run: npm ci
    
    - name: 安装Playwright
      run: npx playwright install --with-deps
    
    - name: 启动应用
      run: |
        npm run build
        npm run start &
        sleep 30
    
    - name: 运行E2E测试
      run: npx playwright test
    
    - name: 上传测试报告
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30

  performance-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    
    steps:
    - uses: actions/checkout@v3
    
    - name: 设置Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: 安装K6
      run: |
        sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
        echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
        sudo apt-get update
        sudo apt-get install k6
    
    - name: 启动应用
      run: |
        npm run build
        npm run start &
        sleep 30
    
    - name: 运行性能测试
      run: k6 run performance/load-test.js
    
    - name: 上传性能报告
      uses: actions/upload-artifact@v3
      with:
        name: performance-report
        path: performance-report.html

📚 总结

本测试文档全面覆盖了结伴客项目的测试策略和实施方案,包括:

测试体系特点

  1. 全面覆盖: 从单元测试到E2E测试的完整测试金字塔
  2. 自动化程度高: CI/CD集成自动运行测试和生成报告
  3. 质量保证: 代码覆盖率要求和性能指标监控
  4. 多维度测试: 功能、性能、安全、兼容性全方位测试

关键测试工具

  • Jest/Vitest: 单元测试和集成测试
  • Playwright: 端到端测试
  • K6/JMeter: 性能测试
  • GitHub Actions: CI/CD自动化

质量指标

  • 代码覆盖率 ≥ 80%
  • API响应时间 < 2秒
  • 系统可用性 99.9%
  • 错误率 < 0.1%

持续改进

  1. 定期审查和更新测试用例
  2. 监控测试执行时间和稳定性
  3. 根据业务变化调整测试策略
  4. 培训团队成员测试最佳实践

通过完善的测试体系,确保结伴客项目的高质量交付和稳定运行。


文档版本: v1.0.0
最后更新: 2024年1月15日
维护人员: 测试团队