2025-09-21 21:12:27 +08:00
|
|
|
|
# 解班客项目测试文档
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
## 1. 测试概述
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 1.1 测试目标
|
|
|
|
|
|
确保解班客项目各个模块的功能正确性、性能稳定性、安全可靠性,为产品上线提供质量保障。
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 1.2 测试范围
|
|
|
|
|
|
- **后端API服务**:接口功能、性能、安全测试
|
|
|
|
|
|
- **小程序应用**:功能、兼容性、用户体验测试
|
|
|
|
|
|
- **管理后台**:功能、权限、数据一致性测试
|
|
|
|
|
|
- **数据库**:数据完整性、性能、备份恢复测试
|
|
|
|
|
|
- **系统集成**:各模块间集成测试
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 1.3 测试策略
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
```mermaid
|
|
|
|
|
|
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 环境配置
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
| 环境类型 | 用途 | 配置 | 数据库 |
|
|
|
|
|
|
|---------|------|------|--------|
|
|
|
|
|
|
| 开发环境 | 开发调试 | 本地Docker | MySQL 8.0 |
|
|
|
|
|
|
| 测试环境 | 功能测试 | 测试服务器 | MySQL 8.0 |
|
|
|
|
|
|
| 预发布环境 | 集成测试 | 生产同配置 | MySQL 8.0 |
|
|
|
|
|
|
| 生产环境 | 线上服务 | 高可用集群 | MySQL 8.0主从 |
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 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 测试阶段
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
|
|
|
|
|
```mermaid
|
2025-09-21 21:12:27 +08:00
|
|
|
|
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测试用例
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 用户注册接口测试用例
|
|
|
|
|
|
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 小程序功能测试用例
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 小程序页面测试
|
|
|
|
|
|
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
|
|
|
|
|
|
<?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性能测试
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
# 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安全扫描
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
# 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 安全测试用例
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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'
|
|
|
|
|
|
});
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
// 应该返回错误,而不是执行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 测试报告模板
|
|
|
|
|
|
|
|
|
|
|
|
```markdown
|
|
|
|
|
|
# 解班客项目测试报告
|
|
|
|
|
|
|
|
|
|
|
|
## 测试概要
|
|
|
|
|
|
- **测试版本**: 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 自动化测试报告
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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'
|
|
|
|
|
|
}]
|
|
|
|
|
|
]
|
|
|
|
|
|
};
|
2025-09-20 16:15:59 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
## 7. 总结
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 7.1 测试策略总结
|
|
|
|
|
|
- **全面覆盖**:从单元测试到端到端测试的完整覆盖
|
|
|
|
|
|
- **自动化优先**:80%以上测试用例实现自动化
|
|
|
|
|
|
- **持续集成**:集成到CI/CD流程中
|
|
|
|
|
|
- **质量保证**:建立完善的质量门禁机制
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 7.2 测试工具链
|
|
|
|
|
|
- **单元测试**:Jest, Vitest
|
|
|
|
|
|
- **集成测试**:Supertest, TestContainers
|
|
|
|
|
|
- **E2E测试**:Playwright, Cypress
|
|
|
|
|
|
- **性能测试**:JMeter, Artillery
|
|
|
|
|
|
- **安全测试**:OWASP ZAP, SonarQube
|
|
|
|
|
|
- **测试管理**:TestRail, Jira
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
2025-09-21 21:12:27 +08:00
|
|
|
|
### 7.3 持续改进
|
|
|
|
|
|
- 定期回顾测试策略和流程
|
|
|
|
|
|
- 持续优化测试用例和自动化脚本
|
|
|
|
|
|
- 加强团队测试技能培训
|
|
|
|
|
|
- 建立测试最佳实践知识库
|
2025-09-20 16:15:59 +08:00
|
|
|
|
|
|
|
|
|
|
#### 4. 性能测试 (Performance Testing)
|
|
|
|
|
|
- **目标**: 验证系统性能指标
|
|
|
|
|
|
- **工具**: JMeter, K6, Artillery
|
|
|
|
|
|
- **覆盖**: 负载、压力、并发
|
|
|
|
|
|
|
|
|
|
|
|
## 🧪 测试策略
|
|
|
|
|
|
|
|
|
|
|
|
### 测试类型
|
|
|
|
|
|
|
|
|
|
|
|
#### 功能测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 示例:用户登录功能测试
|
|
|
|
|
|
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('用户名或密码错误')
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 安全测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 示例: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('输入包含非法字符')
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 性能测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 示例: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秒内完成
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 📝 测试用例设计
|
|
|
|
|
|
|
|
|
|
|
|
### 用户认证模块测试用例
|
|
|
|
|
|
|
|
|
|
|
|
#### 用户注册测试
|
|
|
|
|
|
```gherkin
|
|
|
|
|
|
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 注册按钮应该被禁用
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 动物管理测试用例
|
|
|
|
|
|
```gherkin
|
|
|
|
|
|
Feature: 动物信息管理
|
|
|
|
|
|
作为管理员
|
|
|
|
|
|
我想要管理动物信息
|
|
|
|
|
|
以便为用户提供准确的动物数据
|
|
|
|
|
|
|
|
|
|
|
|
Scenario: 添加新动物
|
|
|
|
|
|
Given 我以管理员身份登录
|
|
|
|
|
|
When 我填写动物信息表单
|
|
|
|
|
|
| 字段 | 值 |
|
|
|
|
|
|
| 名称 | 小白 |
|
|
|
|
|
|
| 物种 | 狗 |
|
|
|
|
|
|
| 年龄 | 2岁 |
|
|
|
|
|
|
| 性别 | 雌性 |
|
|
|
|
|
|
| 描述 | 温顺可爱的小狗 |
|
|
|
|
|
|
And 我上传动物照片
|
|
|
|
|
|
And 我点击保存按钮
|
|
|
|
|
|
Then 动物信息应该被成功保存
|
|
|
|
|
|
And 我应该在动物列表中看到新添加的动物
|
|
|
|
|
|
|
|
|
|
|
|
Scenario: 搜索动物
|
|
|
|
|
|
Given 系统中有多个动物记录
|
|
|
|
|
|
When 我在搜索框中输入"小白"
|
|
|
|
|
|
And 我点击搜索按钮
|
|
|
|
|
|
Then 我应该看到包含"小白"的搜索结果
|
|
|
|
|
|
And 结果应该按相关性排序
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### API接口测试用例
|
|
|
|
|
|
|
|
|
|
|
|
#### 动物API测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
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组件测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 页面测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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测试
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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
|
|
|
|
|
|
<?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性能测试脚本
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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配置
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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配置
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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配置
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 📊 测试报告
|
|
|
|
|
|
|
|
|
|
|
|
### 覆盖率报告
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// 生成覆盖率报告脚本
|
|
|
|
|
|
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测试工作流
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
# .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日
|
|
|
|
|
|
**维护人员**: 测试团队
|