966 lines
26 KiB
Markdown
966 lines
26 KiB
Markdown
# 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%
|
||
```
|