test: add comprehensive test suite for server
- Add load testing with Artillery (smoke, standard, peak, stress) - Add functional tests: voting, lottery, admin (18 tests) - Add fault tolerance tests (14 tests) - Add network tests for WebSocket (7 tests) - Add compatibility tests (15 tests) - Add security tests: anti-fraud, auth, data integrity (10 tests) - Fix optional callback handling in socket handlers - Total: 64 test cases, all passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
132
packages/server/load-test/README.md
Normal file
132
packages/server/load-test/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 年会互动大屏系统 - 压力测试指南
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
```bash
|
||||
cd packages/server
|
||||
pnpm add -D artillery @artilleryio/engine-socketio-v3
|
||||
```
|
||||
|
||||
### 2. 确保服务运行
|
||||
```bash
|
||||
# 根目录启动所有服务
|
||||
pnpm dev
|
||||
|
||||
# 或仅启动服务端
|
||||
pnpm dev:server
|
||||
```
|
||||
|
||||
### 3. 运行压测
|
||||
```bash
|
||||
# 冒烟测试 (快速验证)
|
||||
pnpm test:load:smoke
|
||||
|
||||
# 标准测试
|
||||
pnpm test:load
|
||||
|
||||
# 重压模式 (200并发,对应需求规格书验收标准)
|
||||
pnpm test:load:heavy
|
||||
|
||||
# 脉冲测试 (模拟现场投票热潮)
|
||||
pnpm test:load:pulse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试模式说明
|
||||
|
||||
| 模式 | 命令 | 并发量 | 持续时间 | 用途 |
|
||||
|------|------|--------|----------|------|
|
||||
| `smoke` | `pnpm test:load:smoke` | 5 | 5s | 快速验证配置 |
|
||||
| `standard` | `pnpm test:load` | 50 | 20s | 日常开发测试 |
|
||||
| `heavy` | `pnpm test:load:heavy` | 200 | 35s | **验收标准 (150人并发)** |
|
||||
| `pulse` | `pnpm test:load:pulse` | 150-200 | 21s | 模拟现场脉冲式投票 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
根据 `年会互动大屏系统需求规格说明书.md`:
|
||||
|
||||
| 指标 | 目标值 | 检查方式 |
|
||||
|------|--------|----------|
|
||||
| 并发连接 | 200+ | Artillery 报告 `vusers.session_length` |
|
||||
| QPS | > 1000 | 报告 `socketio.emit_rate` |
|
||||
| 延迟 P95 | < 500ms | 报告 `socketio.response_time.p95` |
|
||||
| 错误率 | < 1% | 报告 `vusers.failed` / `vusers.completed` |
|
||||
| Redis 计数 | 准确无丢票 | 对比 Redis 统计与预期投票数 |
|
||||
|
||||
---
|
||||
|
||||
## 报告输出
|
||||
|
||||
测试完成后会输出类似以下报告:
|
||||
|
||||
```
|
||||
All VUs finished. Total time: 35 seconds
|
||||
|
||||
--------------------------------
|
||||
Summary report @ 16:30:00(+0800)
|
||||
--------------------------------
|
||||
|
||||
socketio.emit: ..................... 5000
|
||||
socketio.emit_rate: ................ 142.86/sec
|
||||
socketio.response_time:
|
||||
min: ............................. 12
|
||||
max: ............................. 456
|
||||
median: .......................... 48
|
||||
p95: ............................. 189
|
||||
p99: ............................. 312
|
||||
|
||||
vusers.completed: .................. 200
|
||||
vusers.created: .................... 200
|
||||
vusers.created_by_name.完整投票流程: 140
|
||||
vusers.created_by_name.快速连投压力: 40
|
||||
vusers.created_by_name.观望用户: .... 20
|
||||
vusers.failed: ..................... 0
|
||||
vusers.session_length:
|
||||
min: ............................. 2500
|
||||
max: ............................. 6800
|
||||
median: .......................... 3400
|
||||
p95: ............................. 5200
|
||||
p99: ............................. 6100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 生成 HTML 报告
|
||||
```bash
|
||||
npx artillery run packages/server/load-test/vote-load-test.yaml \
|
||||
--output report.json && \
|
||||
npx artillery report report.json
|
||||
```
|
||||
|
||||
### 指定环境运行
|
||||
```bash
|
||||
npx artillery run packages/server/load-test/vote-load-test.yaml -e heavy
|
||||
```
|
||||
|
||||
### 调试模式 (查看详细日志)
|
||||
```bash
|
||||
DEBUG=socketio:* npx artillery run packages/server/load-test/vote-load-test.yaml -e smoke
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 1. 连接失败
|
||||
- 检查服务端是否运行在 `localhost:3000`
|
||||
- 确认 CORS 配置允许本地访问
|
||||
|
||||
### 2. 投票被拒绝 (VOTING_CLOSED)
|
||||
- 确保导播台已开启投票通道
|
||||
- 或临时修改服务端绕过投票状态检查
|
||||
|
||||
### 3. 性能不达标
|
||||
- 检查 Redis 连接是否正常
|
||||
- 确认未在调试模式运行
|
||||
- 考虑增加 Node.js 内存限制: `NODE_OPTIONS="--max-old-space-size=4096"`
|
||||
184
packages/server/load-test/vote-load-test.yaml
Normal file
184
packages/server/load-test/vote-load-test.yaml
Normal file
@@ -0,0 +1,184 @@
|
||||
# ============================================================================
|
||||
# 年会互动大屏系统 - 投票压力测试配置 (100人场景)
|
||||
# ============================================================================
|
||||
#
|
||||
# 运行方式:
|
||||
# pnpm test:load # 标准测试 (30并发)
|
||||
# pnpm test:load:smoke # 冒烟测试 (5并发, 10秒)
|
||||
# pnpm test:load:peak # 峰值测试 (80并发)
|
||||
# pnpm test:load:stress # 压力测试 (120并发)
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
config:
|
||||
target: "http://localhost:3000"
|
||||
|
||||
# 性能阈值
|
||||
ensure:
|
||||
p95: 500
|
||||
maxErrorRate: 1
|
||||
|
||||
# Socket.IO 配置
|
||||
socketio:
|
||||
transports: ["websocket"]
|
||||
path: "/socket.io"
|
||||
|
||||
# 默认 phases (被环境配置覆盖)
|
||||
phases:
|
||||
- duration: 1
|
||||
arrivalRate: 1
|
||||
|
||||
# 环境变量
|
||||
variables:
|
||||
programIds:
|
||||
- "program_1"
|
||||
- "program_2"
|
||||
- "program_3"
|
||||
- "program_4"
|
||||
- "program_5"
|
||||
- "program_6"
|
||||
- "program_7"
|
||||
ticketTypes:
|
||||
- "creative"
|
||||
- "visual"
|
||||
- "atmosphere"
|
||||
- "performance"
|
||||
- "teamwork"
|
||||
- "popularity"
|
||||
- "potential"
|
||||
|
||||
# 处理器脚本
|
||||
processor: "./vote-processor.cjs"
|
||||
|
||||
# 环境配置
|
||||
environments:
|
||||
# 冒烟测试 - 快速验证 (10秒, 5并发)
|
||||
smoke:
|
||||
target: "http://localhost:3000"
|
||||
phases:
|
||||
- name: "冒烟测试"
|
||||
duration: 10
|
||||
arrivalRate: 5
|
||||
|
||||
# 标准测试 - 正常负载 (30秒, 30并发)
|
||||
standard:
|
||||
target: "http://localhost:3000"
|
||||
phases:
|
||||
- name: "预热"
|
||||
duration: 5
|
||||
arrivalRate: 5
|
||||
- name: "正常负载"
|
||||
duration: 20
|
||||
arrivalRate: 30
|
||||
- name: "冷却"
|
||||
duration: 5
|
||||
arrivalRate: 5
|
||||
|
||||
# 峰值测试 - 投票开始瞬间 (80并发)
|
||||
peak:
|
||||
target: "http://localhost:3000"
|
||||
phases:
|
||||
- name: "预热"
|
||||
duration: 3
|
||||
arrivalRate: 10
|
||||
- name: "峰值爬升"
|
||||
duration: 5
|
||||
arrivalRate: 20
|
||||
rampTo: 80
|
||||
- name: "峰值维持"
|
||||
duration: 15
|
||||
arrivalRate: 80
|
||||
- name: "冷却"
|
||||
duration: 5
|
||||
arrivalRate: 10
|
||||
|
||||
# 压力测试 - 1.5倍余量 (120并发)
|
||||
stress:
|
||||
target: "http://localhost:3000"
|
||||
phases:
|
||||
- name: "预热"
|
||||
duration: 5
|
||||
arrivalRate: 20
|
||||
- name: "压力爬升"
|
||||
duration: 10
|
||||
arrivalRate: 40
|
||||
rampTo: 120
|
||||
- name: "压力维持"
|
||||
duration: 15
|
||||
arrivalRate: 120
|
||||
- name: "冷却"
|
||||
duration: 5
|
||||
arrivalRate: 20
|
||||
|
||||
# ============================================================================
|
||||
# 测试场景定义
|
||||
# ============================================================================
|
||||
|
||||
scenarios:
|
||||
# 场景 1: 完整投票流程 (权重70%)
|
||||
- name: "完整投票流程"
|
||||
weight: 7
|
||||
engine: socketio
|
||||
flow:
|
||||
- function: "generateUserId"
|
||||
- emit:
|
||||
channel: "connection:join"
|
||||
data:
|
||||
userId: "{{ userId }}"
|
||||
userName: "压测用户-{{ userId }}"
|
||||
role: "user"
|
||||
department: "测试部门"
|
||||
- think: 0.5
|
||||
- loop:
|
||||
- function: "selectRandomProgram"
|
||||
- function: "selectRandomTicketType"
|
||||
- emit:
|
||||
channel: "vote:submit"
|
||||
data:
|
||||
candidateId: "{{ selectedProgram }}"
|
||||
category: "{{ selectedTicket }}"
|
||||
localId: "{{ $uuid }}"
|
||||
- think: 0.3
|
||||
count: 5
|
||||
- think: 1
|
||||
|
||||
# 场景 2: 快速连投 (权重20%)
|
||||
- name: "快速连投压力"
|
||||
weight: 2
|
||||
engine: socketio
|
||||
flow:
|
||||
- function: "generateUserId"
|
||||
- emit:
|
||||
channel: "connection:join"
|
||||
data:
|
||||
userId: "{{ userId }}"
|
||||
userName: "速投用户-{{ userId }}"
|
||||
role: "user"
|
||||
department: "压力测试"
|
||||
- loop:
|
||||
- function: "selectRandomProgram"
|
||||
- function: "selectRandomTicketType"
|
||||
- emit:
|
||||
channel: "vote:submit"
|
||||
data:
|
||||
candidateId: "{{ selectedProgram }}"
|
||||
category: "{{ selectedTicket }}"
|
||||
localId: "{{ $uuid }}"
|
||||
- think: 0.1
|
||||
count: 7
|
||||
- think: 0.5
|
||||
|
||||
# 场景 3: 观望用户 (权重10%)
|
||||
- name: "观望用户"
|
||||
weight: 1
|
||||
engine: socketio
|
||||
flow:
|
||||
- function: "generateUserId"
|
||||
- emit:
|
||||
channel: "connection:join"
|
||||
data:
|
||||
userId: "{{ userId }}"
|
||||
userName: "观望用户-{{ userId }}"
|
||||
role: "user"
|
||||
department: "观望组"
|
||||
- think: 5
|
||||
131
packages/server/load-test/vote-processor.cjs
Normal file
131
packages/server/load-test/vote-processor.cjs
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Artillery 压力测试处理器
|
||||
* 用于生成测试数据和自定义逻辑
|
||||
*
|
||||
* 注意:使用 CommonJS 格式以兼容 Artillery
|
||||
*/
|
||||
|
||||
// 节目ID列表
|
||||
const programIds = [
|
||||
'program_1',
|
||||
'program_2',
|
||||
'program_3',
|
||||
'program_4',
|
||||
'program_5',
|
||||
'program_6',
|
||||
'program_7',
|
||||
];
|
||||
|
||||
// 奖项票种列表 (与 shared/constants 一致)
|
||||
const ticketTypes = [
|
||||
'best_creativity',
|
||||
'best_visual',
|
||||
'best_atmosphere',
|
||||
'best_performance',
|
||||
'best_teamwork',
|
||||
'best_innovation',
|
||||
'most_popular',
|
||||
];
|
||||
|
||||
// 用户计数器 (确保每个虚拟用户有唯一ID)
|
||||
let userCounter = 0;
|
||||
|
||||
/**
|
||||
* 生成唯一用户ID
|
||||
*/
|
||||
function generateUserId(userContext, events, done) {
|
||||
userCounter++;
|
||||
const timestamp = Date.now();
|
||||
userContext.vars.userId = `stress_user_${timestamp}_${userCounter}`;
|
||||
userContext.vars.voteIndex = 0; // 初始化投票索引
|
||||
userContext.vars.usedTickets = []; // 已使用的票种
|
||||
return done();
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机选择一个节目
|
||||
*/
|
||||
function selectRandomProgram(userContext, events, done) {
|
||||
const randomIndex = Math.floor(Math.random() * programIds.length);
|
||||
userContext.vars.selectedProgram = programIds[randomIndex];
|
||||
return done();
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机选择一个未使用的票种
|
||||
* 符合业务逻辑:每种票只能用一次
|
||||
*/
|
||||
function selectRandomTicketType(userContext, events, done) {
|
||||
const usedTickets = userContext.vars.usedTickets || [];
|
||||
const availableTickets = ticketTypes.filter(t => !usedTickets.includes(t));
|
||||
|
||||
if (availableTickets.length === 0) {
|
||||
// 所有票已用完,重置 (用于压测场景)
|
||||
userContext.vars.usedTickets = [];
|
||||
userContext.vars.selectedTicket = ticketTypes[0];
|
||||
} else {
|
||||
const randomIndex = Math.floor(Math.random() * availableTickets.length);
|
||||
userContext.vars.selectedTicket = availableTickets[randomIndex];
|
||||
userContext.vars.usedTickets.push(availableTickets[randomIndex]);
|
||||
}
|
||||
|
||||
return done();
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序选择票种 (确保不重复)
|
||||
*/
|
||||
function selectNextTicketType(userContext, events, done) {
|
||||
const voteIndex = userContext.vars.voteIndex || 0;
|
||||
userContext.vars.selectedTicket = ticketTypes[voteIndex % ticketTypes.length];
|
||||
userContext.vars.voteIndex = voteIndex + 1;
|
||||
return done();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录投票结果 (用于调试)
|
||||
*/
|
||||
function logVoteResult(userContext, events, done) {
|
||||
const { userId, selectedProgram, selectedTicket } = userContext.vars;
|
||||
console.log(`[VOTE] User: ${userId} -> Program: ${selectedProgram} (${selectedTicket})`);
|
||||
return done();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机延迟 (模拟真实用户行为)
|
||||
*/
|
||||
function randomDelay(userContext, events, done) {
|
||||
const minDelay = 100; // 最小 100ms
|
||||
const maxDelay = 500; // 最大 500ms
|
||||
const delay = Math.floor(Math.random() * (maxDelay - minDelay)) + minDelay;
|
||||
userContext.vars.thinkTime = delay / 1000; // 转换为秒
|
||||
return done();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在连接前打印信息 (调试用)
|
||||
*/
|
||||
function beforeConnect(requestParams, context, ee, next) {
|
||||
console.log(`[CONNECT] Connecting user: ${context.vars.userId}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应 (调试用)
|
||||
*/
|
||||
function afterResponse(requestParams, response, context, ee, next) {
|
||||
if (response.body && response.body.error) {
|
||||
console.log(`[ERROR] ${context.vars.userId}: ${response.body.error}`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// CommonJS exports
|
||||
exports.generateUserId = generateUserId;
|
||||
exports.selectRandomProgram = selectRandomProgram;
|
||||
exports.selectRandomTicketType = selectRandomTicketType;
|
||||
exports.selectNextTicketType = selectNextTicketType;
|
||||
exports.logVoteResult = logVoteResult;
|
||||
exports.randomDelay = randomDelay;
|
||||
exports.beforeConnect = beforeConnect;
|
||||
exports.afterResponse = afterResponse;
|
||||
Reference in New Issue
Block a user