rwadurian/backend/services/leaderboard-service/docs/TESTING.md

966 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Leaderboard Service 测试文档
## 1. 测试架构概述
本服务采用分层测试策略包含单元测试、集成测试和端到端测试E2E确保代码质量和系统可靠性。
### 1.1 测试金字塔
```
┌─────────┐
│ E2E │ 少量 - 关键用户流程
│ Tests │
└────┬────┘
┌────────┴────────┐
│ Integration │ 中等 - 组件集成
│ Tests │
└────────┬────────┘
┌────────────────┴────────────────┐
│ Unit Tests │ 大量 - 业务逻辑
│ (Domain, Services, Utilities) │
└──────────────────────────────────┘
```
### 1.2 测试技术栈
| 工具 | 用途 |
|------|------|
| Jest | 测试框架 |
| ts-jest | TypeScript 支持 |
| @nestjs/testing | NestJS 测试工具 |
| supertest | HTTP 请求测试 |
| Docker Compose | 测试环境容器化 |
### 1.3 测试目录结构
```
test/
├── domain/ # 领域层单元测试
│ ├── value-objects/
│ │ ├── rank-position.vo.spec.ts
│ │ ├── ranking-score.vo.spec.ts
│ │ └── leaderboard-period.vo.spec.ts
│ ├── aggregates/
│ │ └── leaderboard-config.aggregate.spec.ts
│ └── services/
│ └── ranking-merger.service.spec.ts
├── integration/ # 集成测试
│ └── leaderboard-repository.integration.spec.ts
├── app.e2e-spec.ts # E2E 测试
├── setup-integration.ts # 集成测试设置
├── setup-e2e.ts # E2E 测试设置
├── jest-integration.json # 集成测试配置
└── jest-e2e.json # E2E 测试配置
```
## 2. 单元测试
### 2.1 运行单元测试
```bash
# 运行所有单元测试
npm test
# 监听模式
npm run test:watch
# 生成覆盖率报告
npm run test:cov
# 调试模式
npm run test:debug
```
### 2.2 测试覆盖率目标
| 层级 | 目标覆盖率 |
|------|-----------|
| Domain (Value Objects) | >= 90% |
| Domain (Aggregates) | >= 85% |
| Domain (Services) | >= 85% |
| Application Services | >= 80% |
| Infrastructure | >= 70% |
### 2.3 值对象测试示例
```typescript
// test/domain/value-objects/ranking-score.vo.spec.ts
import { RankingScore } from '../../../src/domain/value-objects/ranking-score.vo';
describe('RankingScore', () => {
describe('calculate', () => {
it('应该正确计算龙虎榜分值', () => {
// 用户团队数据:
// - 团队总认种: 230棵
// - 最大单个直推团队: 100棵
// - 龙虎榜分值: 230 - 100 = 130
const score = RankingScore.calculate(230, 100);
expect(score.totalTeamPlanting).toBe(230);
expect(score.maxDirectTeamPlanting).toBe(100);
expect(score.effectiveScore).toBe(130);
});
it('当团队总认种等于最大直推时有效分值为0', () => {
const score = RankingScore.calculate(100, 100);
expect(score.effectiveScore).toBe(0);
});
it('有效分值不能为负数', () => {
const score = RankingScore.calculate(50, 100);
expect(score.effectiveScore).toBe(0);
});
});
describe('compareTo', () => {
it('分值高的应该排在前面', () => {
const score1 = RankingScore.calculate(200, 50); // 有效分值: 150
const score2 = RankingScore.calculate(150, 50); // 有效分值: 100
expect(score1.compareTo(score2)).toBeLessThan(0); // score1 排名更靠前
});
});
describe('isHealthyTeamStructure', () => {
it('大腿占比低于50%应该是健康结构', () => {
const score = RankingScore.calculate(300, 100); // 33.3%
expect(score.isHealthyTeamStructure()).toBe(true);
});
it('大腿占比高于50%应该不是健康结构', () => {
const score = RankingScore.calculate(200, 150); // 75%
expect(score.isHealthyTeamStructure()).toBe(false);
});
});
});
```
### 2.4 聚合根测试示例
```typescript
// test/domain/aggregates/leaderboard-config.aggregate.spec.ts
import { LeaderboardConfig } from '../../../src/domain/aggregates/leaderboard-config/leaderboard-config.aggregate';
import { LeaderboardType } from '../../../src/domain/value-objects/leaderboard-type.enum';
describe('LeaderboardConfig', () => {
describe('createDefault', () => {
it('应该创建默认配置', () => {
const config = LeaderboardConfig.createDefault();
expect(config.configKey).toBe('GLOBAL');
expect(config.dailyEnabled).toBe(true);
expect(config.weeklyEnabled).toBe(true);
expect(config.monthlyEnabled).toBe(true);
expect(config.virtualRankingEnabled).toBe(false);
expect(config.virtualAccountCount).toBe(0);
expect(config.displayLimit).toBe(30);
expect(config.refreshIntervalMinutes).toBe(5);
});
});
describe('updateLeaderboardSwitch', () => {
it('应该更新日榜开关', () => {
const config = LeaderboardConfig.createDefault();
config.updateLeaderboardSwitch('daily', false, 'admin');
expect(config.dailyEnabled).toBe(false);
expect(config.domainEvents.length).toBe(1);
});
});
describe('updateVirtualRankingSettings', () => {
it('应该更新虚拟排名设置', () => {
const config = LeaderboardConfig.createDefault();
config.updateVirtualRankingSettings(true, 30, 'admin');
expect(config.virtualRankingEnabled).toBe(true);
expect(config.virtualAccountCount).toBe(30);
});
it('虚拟账户数量为负数时应该抛出错误', () => {
const config = LeaderboardConfig.createDefault();
expect(() => {
config.updateVirtualRankingSettings(true, -1, 'admin');
}).toThrow('虚拟账户数量不能为负数');
});
});
describe('updateDisplayLimit', () => {
it('显示数量为0时应该抛出错误', () => {
const config = LeaderboardConfig.createDefault();
expect(() => {
config.updateDisplayLimit(0, 'admin');
}).toThrow('显示数量必须大于0');
});
});
});
```
### 2.5 领域服务测试示例
```typescript
// test/domain/services/ranking-merger.service.spec.ts
import { RankingMergerService } from '../../../src/domain/services/ranking-merger.service';
import { LeaderboardRanking } from '../../../src/domain/aggregates/leaderboard-ranking/leaderboard-ranking.aggregate';
describe('RankingMergerService', () => {
let service: RankingMergerService;
beforeEach(() => {
service = new RankingMergerService();
});
describe('mergeRankings', () => {
it('没有虚拟排名时应该保持原始排名', () => {
const realRankings = [
createRealRanking(1n, 1),
createRealRanking(2n, 2),
createRealRanking(3n, 3),
];
const merged = service.mergeRankings([], realRankings, 30);
expect(merged.length).toBe(3);
expect(merged[0].displayPosition.value).toBe(1);
expect(merged[1].displayPosition.value).toBe(2);
expect(merged[2].displayPosition.value).toBe(3);
});
it('有虚拟排名时应该正确调整真实用户排名', () => {
const virtualRankings = [
createVirtualRanking(100n, 1),
createVirtualRanking(101n, 2),
];
const realRankings = [
createRealRanking(1n, 1),
createRealRanking(2n, 2),
];
const merged = service.mergeRankings(virtualRankings, realRankings, 30);
expect(merged.length).toBe(4);
expect(merged[0].isVirtual).toBe(true);
expect(merged[2].isVirtual).toBe(false);
expect(merged[2].displayPosition.value).toBe(3); // 原来第1名变成第3名
});
it('应该遵守显示数量限制', () => {
const virtualRankings = [
createVirtualRanking(100n, 1),
createVirtualRanking(101n, 2),
];
const realRankings = [
createRealRanking(1n, 1),
createRealRanking(2n, 2),
createRealRanking(3n, 3),
];
const merged = service.mergeRankings(virtualRankings, realRankings, 3);
expect(merged.length).toBe(3);
});
});
});
```
## 3. 集成测试
### 3.1 运行集成测试
```bash
# 启动测试数据库
docker compose -f docker-compose.test.yml up -d postgres-test redis-test
# 推送 schema
DATABASE_URL='postgresql://postgres:postgres@localhost:5433/leaderboard_test_db' npx prisma db push
# 运行集成测试
DATABASE_URL='postgresql://postgres:postgres@localhost:5433/leaderboard_test_db' npm run test:integration
# 清理测试环境
docker compose -f docker-compose.test.yml down -v
```
### 3.2 集成测试配置
```json
// test/jest-integration.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testRegex": ".*\\.integration\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["src/**/*.(t|j)s", "!src/main.ts", "!src/**/*.module.ts"],
"coverageDirectory": "./coverage/integration",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"setupFilesAfterEnv": ["<rootDir>/test/setup-integration.ts"],
"testTimeout": 30000
}
```
### 3.3 集成测试设置
```typescript
// test/setup-integration.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
beforeAll(async () => {
try {
await prisma.$connect();
console.log('Database connected for integration tests');
} catch (error) {
console.warn('Database not available for integration tests');
}
});
afterAll(async () => {
await prisma.$disconnect();
});
global.testUtils = {
prisma,
cleanDatabase: async () => {
const tablenames = await prisma.$queryRaw<
Array<{ tablename: string }>
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;
for (const { tablename } of tablenames) {
if (tablename !== '_prisma_migrations') {
await prisma.$executeRawUnsafe(
`TRUNCATE TABLE "public"."${tablename}" CASCADE;`
);
}
}
},
};
```
### 3.4 集成测试示例
```typescript
// test/integration/leaderboard-repository.integration.spec.ts
import { LeaderboardType } from '../../src/domain/value-objects/leaderboard-type.enum';
import { LeaderboardPeriod } from '../../src/domain/value-objects/leaderboard-period.vo';
describe('LeaderboardRepository Integration Tests', () => {
describe('Database Connection', () => {
it('should connect to the database', async () => {
const result = await global.testUtils.prisma.$queryRaw`SELECT 1 as result`;
expect(result).toBeDefined();
});
});
describe('LeaderboardConfig Operations', () => {
beforeEach(async () => {
await global.testUtils.cleanDatabase();
});
it('should create and retrieve leaderboard config', async () => {
const config = await global.testUtils.prisma.leaderboardConfig.create({
data: {
configKey: 'TEST_CONFIG',
dailyEnabled: true,
weeklyEnabled: true,
monthlyEnabled: true,
virtualRankingEnabled: false,
virtualAccountCount: 0,
displayLimit: 30,
refreshIntervalMinutes: 5,
},
});
expect(config.id).toBeDefined();
expect(config.configKey).toBe('TEST_CONFIG');
const retrieved = await global.testUtils.prisma.leaderboardConfig.findUnique({
where: { configKey: 'TEST_CONFIG' },
});
expect(retrieved?.displayLimit).toBe(30);
});
});
describe('LeaderboardRanking Operations', () => {
beforeEach(async () => {
await global.testUtils.cleanDatabase();
});
it('should create leaderboard ranking entries', async () => {
const period = LeaderboardPeriod.currentDaily();
const ranking = await global.testUtils.prisma.leaderboardRanking.create({
data: {
leaderboardType: LeaderboardType.DAILY,
periodKey: period.key,
periodStartAt: period.startAt,
periodEndAt: period.endAt,
userId: BigInt(1),
rankPosition: 1,
displayPosition: 1,
totalTeamPlanting: 200,
maxDirectTeamPlanting: 50,
effectiveScore: 150,
isVirtual: false,
userSnapshot: { nickname: 'TestUser', avatar: null },
},
});
expect(ranking.id).toBeDefined();
expect(ranking.rankPosition).toBe(1);
expect(ranking.effectiveScore).toBe(150);
});
it('should query rankings by period and type', async () => {
const period = LeaderboardPeriod.currentDaily();
await global.testUtils.prisma.leaderboardRanking.createMany({
data: [
{
leaderboardType: LeaderboardType.DAILY,
periodKey: period.key,
periodStartAt: period.startAt,
periodEndAt: period.endAt,
userId: BigInt(1),
rankPosition: 1,
displayPosition: 1,
totalTeamPlanting: 300,
maxDirectTeamPlanting: 100,
effectiveScore: 200,
isVirtual: false,
userSnapshot: { nickname: 'User1', avatar: null },
},
{
leaderboardType: LeaderboardType.DAILY,
periodKey: period.key,
periodStartAt: period.startAt,
periodEndAt: period.endAt,
userId: BigInt(2),
rankPosition: 2,
displayPosition: 2,
totalTeamPlanting: 200,
maxDirectTeamPlanting: 50,
effectiveScore: 150,
isVirtual: false,
userSnapshot: { nickname: 'User2', avatar: null },
},
],
});
const rankings = await global.testUtils.prisma.leaderboardRanking.findMany({
where: {
leaderboardType: LeaderboardType.DAILY,
periodKey: period.key,
},
orderBy: { rankPosition: 'asc' },
});
expect(rankings.length).toBe(2);
expect(rankings[0].effectiveScore).toBe(200);
expect(rankings[1].effectiveScore).toBe(150);
});
});
});
```
## 4. 端到端测试 (E2E)
### 4.1 运行 E2E 测试
```bash
# 启动完整测试环境
docker compose -f docker-compose.test.yml up -d
# 运行 E2E 测试
npm run test:e2e
# 清理
docker compose -f docker-compose.test.yml down -v
```
### 4.2 E2E 测试配置
```json
// test/jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "./coverage/e2e",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"setupFilesAfterEnv": ["<rootDir>/test/setup-e2e.ts"],
"testTimeout": 60000
}
```
### 4.3 E2E 测试示例
```typescript
// test/app.e2e-spec.ts
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
describe('Leaderboard Service E2E Tests', () => {
let app: INestApplication;
beforeAll(() => {
app = global.testApp;
});
describe('Health Check', () => {
it('/health (GET) - should return health status', async () => {
const response = await request(app.getHttpServer())
.get('/health')
.expect(200);
expect(response.body).toHaveProperty('status');
expect(response.body.status).toBe('ok');
});
it('/health/ready (GET) - should return readiness status', async () => {
const response = await request(app.getHttpServer())
.get('/health/ready')
.expect(200);
expect(response.body).toHaveProperty('status');
});
});
describe('Leaderboard API', () => {
it('GET /leaderboard/daily - should return daily leaderboard', async () => {
const response = await request(app.getHttpServer())
.get('/leaderboard/daily')
.expect(200);
expect(response.body).toBeDefined();
});
it('GET /leaderboard/weekly - should return weekly leaderboard', async () => {
const response = await request(app.getHttpServer())
.get('/leaderboard/weekly')
.expect(200);
expect(response.body).toBeDefined();
});
it('GET /leaderboard/monthly - should return monthly leaderboard', async () => {
const response = await request(app.getHttpServer())
.get('/leaderboard/monthly')
.expect(200);
expect(response.body).toBeDefined();
});
});
describe('Authentication Protected Routes', () => {
it('GET /leaderboard/my-rank - should return 401 without token', async () => {
await request(app.getHttpServer())
.get('/leaderboard/my-rank')
.expect(401);
});
});
describe('Admin Protected Routes', () => {
it('GET /leaderboard/config - should return 401 without token', async () => {
await request(app.getHttpServer())
.get('/leaderboard/config')
.expect(401);
});
it('POST /leaderboard/config/switch - should return 401 without token', async () => {
await request(app.getHttpServer())
.post('/leaderboard/config/switch')
.send({ type: 'daily', enabled: true })
.expect(401);
});
});
describe('Swagger Documentation', () => {
it('/api-docs (GET) - should return swagger UI', async () => {
const response = await request(app.getHttpServer())
.get('/api-docs')
.expect(200);
expect(response.text).toContain('html');
});
it('/api-docs-json (GET) - should return swagger JSON', async () => {
const response = await request(app.getHttpServer())
.get('/api-docs-json')
.expect(200);
expect(response.body).toHaveProperty('openapi');
expect(response.body.info.title).toContain('Leaderboard');
});
});
});
```
## 5. Docker 容器化测试
### 5.1 测试环境 Docker Compose
```yaml
# docker-compose.test.yml
version: '3.8'
services:
postgres-test:
image: postgres:15-alpine
container_name: leaderboard-postgres-test
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: leaderboard_test_db
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
redis-test:
image: redis:7-alpine
container_name: leaderboard-redis-test
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 10
test-runner:
build:
context: .
dockerfile: Dockerfile
target: test
container_name: leaderboard-test-runner
depends_on:
postgres-test:
condition: service_healthy
redis-test:
condition: service_healthy
environment:
NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@postgres-test:5432/leaderboard_test_db
REDIS_HOST: redis-test
REDIS_PORT: 6379
JWT_SECRET: test-jwt-secret
volumes:
- ./coverage:/app/coverage
command: >
sh -c "npx prisma migrate deploy && npm test -- --coverage"
```
### 5.2 使用 Makefile 运行测试
```makefile
# Makefile
.PHONY: test test-unit test-integration test-e2e test-docker-unit test-docker-all
# 本地测试
test: test-unit
test-unit:
npm test
test-integration:
npm run test:integration
test-e2e:
npm run test:e2e
test-cov:
npm run test:cov
# Docker 测试
test-docker-unit:
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit test-runner
docker compose -f docker-compose.test.yml down -v
test-docker-integration:
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit integration-test-runner
docker compose -f docker-compose.test.yml down -v
test-docker-e2e:
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit e2e-test-runner
docker compose -f docker-compose.test.yml down -v
test-docker-all: test-docker-unit test-docker-integration test-docker-e2e
```
### 5.3 运行 Docker 测试
```bash
# 单元测试
make test-docker-unit
# 集成测试
make test-docker-integration
# E2E 测试
make test-docker-e2e
# 所有测试
make test-docker-all
```
## 6. 手动测试指南
### 6.1 使用 cURL 测试
```bash
# 健康检查
curl http://localhost:3000/health
# 获取日榜
curl http://localhost:3000/leaderboard/daily
# 获取周榜
curl http://localhost:3000/leaderboard/weekly?limit=10
# 带认证的请求
curl -H "Authorization: Bearer <token>" \
http://localhost:3000/leaderboard/my-rank
# 管理员操作
curl -X POST \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"type": "daily", "enabled": false}' \
http://localhost:3000/leaderboard/config/switch
```
### 6.2 使用 VS Code REST Client
创建 `test.http` 文件:
```http
### 健康检查
GET http://localhost:3000/health
### 获取日榜
GET http://localhost:3000/leaderboard/daily?limit=10
### 获取周榜
GET http://localhost:3000/leaderboard/weekly
### 获取月榜
GET http://localhost:3000/leaderboard/monthly
### 获取我的排名 (需要 token)
GET http://localhost:3000/leaderboard/my-rank
Authorization: Bearer {{token}}
### 获取配置 (管理员)
GET http://localhost:3000/leaderboard/config
Authorization: Bearer {{adminToken}}
### 更新榜单开关 (管理员)
POST http://localhost:3000/leaderboard/config/switch
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"type": "daily",
"enabled": false
}
### 手动刷新排行榜 (管理员)
POST http://localhost:3000/leaderboard/config/refresh
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"type": "DAILY"
}
```
### 6.3 使用 Postman
1. 导入 OpenAPI 规范:`http://localhost:3000/api-docs-json`
2. 设置环境变量:
- `baseUrl`: `http://localhost:3000`
- `token`: 用户 JWT token
- `adminToken`: 管理员 JWT token
## 7. 测试最佳实践
### 7.1 测试命名规范
```typescript
describe('被测试的类/函数', () => {
describe('方法名', () => {
it('应该做什么(正常情况)', () => {});
it('当什么条件时应该如何(边界情况)', () => {});
it('什么情况应该抛出错误(异常情况)', () => {});
});
});
```
### 7.2 AAA 模式
```typescript
it('应该正确计算分值', () => {
// Arrange - 准备
const totalTeam = 200;
const maxDirect = 50;
// Act - 执行
const score = RankingScore.calculate(totalTeam, maxDirect);
// Assert - 断言
expect(score.effectiveScore).toBe(150);
});
```
### 7.3 Mock 使用
```typescript
// 创建 Mock
const mockRepository = {
findById: jest.fn(),
save: jest.fn(),
};
// 设置返回值
mockRepository.findById.mockResolvedValue(mockAggregate);
// 验证调用
expect(mockRepository.save).toHaveBeenCalledWith(expect.objectContaining({
id: expectedId,
}));
```
### 7.4 测试隔离
```typescript
beforeEach(async () => {
// 每个测试前清理数据
await global.testUtils.cleanDatabase();
});
afterEach(() => {
// 清理 mock
jest.clearAllMocks();
});
```
## 8. CI/CD 集成
### 8.1 GitHub Actions 示例
```yaml
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: leaderboard_test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Run database migrations
run: npx prisma db push
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/leaderboard_test_db
- name: Run unit tests
run: npm test -- --coverage
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/leaderboard_test_db
REDIS_HOST: localhost
REDIS_PORT: 6379
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
```
## 9. 测试报告
### 9.1 当前测试结果摘要
| 测试类型 | 测试数量 | 通过 | 失败 | 覆盖率 |
|----------|----------|------|------|--------|
| 单元测试 | 72 | 72 | 0 | ~88% (核心领域) |
| 集成测试 | 7 | 7 | 0 | - |
| E2E 测试 | 11 | 11 | 0 | - |
| Docker 测试 | 79 | 79 | 0 | ~20% (全量) |
### 9.2 覆盖率详情
```
领域层覆盖率:
- value-objects: 88.72%
- aggregates/leaderboard-config: 87.69%
- services/ranking-merger: 96.87%
```