# 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": { "^@/(.*)$": "/src/$1" }, "setupFilesAfterEnv": ["/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": { "^@/(.*)$": "/src/$1" }, "setupFilesAfterEnv": ["/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 " \ http://localhost:3000/leaderboard/my-rank # 管理员操作 curl -X POST \ -H "Authorization: Bearer " \ -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% ```