26 KiB
26 KiB
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 运行单元测试
# 运行所有单元测试
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 值对象测试示例
// 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 聚合根测试示例
// 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 领域服务测试示例
// 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 运行集成测试
# 启动测试数据库
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 集成测试配置
// 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 集成测试设置
// 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 集成测试示例
// 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 测试
# 启动完整测试环境
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 测试配置
// 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 测试示例
// 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
# 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
.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 测试
# 单元测试
make test-docker-unit
# 集成测试
make test-docker-integration
# E2E 测试
make test-docker-e2e
# 所有测试
make test-docker-all
6. 手动测试指南
6.1 使用 cURL 测试
# 健康检查
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 文件:
### 健康检查
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
- 导入 OpenAPI 规范:
http://localhost:3000/api-docs-json - 设置环境变量:
baseUrl:http://localhost:3000token: 用户 JWT tokenadminToken: 管理员 JWT token
7. 测试最佳实践
7.1 测试命名规范
describe('被测试的类/函数', () => {
describe('方法名', () => {
it('应该做什么(正常情况)', () => {});
it('当什么条件时应该如何(边界情况)', () => {});
it('什么情况应该抛出错误(异常情况)', () => {});
});
});
7.2 AAA 模式
it('应该正确计算分值', () => {
// Arrange - 准备
const totalTeam = 200;
const maxDirect = 50;
// Act - 执行
const score = RankingScore.calculate(totalTeam, maxDirect);
// Assert - 断言
expect(score.effectiveScore).toBe(150);
});
7.3 Mock 使用
// 创建 Mock
const mockRepository = {
findById: jest.fn(),
save: jest.fn(),
};
// 设置返回值
mockRepository.findById.mockResolvedValue(mockAggregate);
// 验证调用
expect(mockRepository.save).toHaveBeenCalledWith(expect.objectContaining({
id: expectedId,
}));
7.4 测试隔离
beforeEach(async () => {
// 每个测试前清理数据
await global.testUtils.cleanDatabase();
});
afterEach(() => {
// 清理 mock
jest.clearAllMocks();
});
8. CI/CD 集成
8.1 GitHub Actions 示例
# .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%