# Reward Service 测试指南 ## 测试概述 本服务采用分层测试策略,确保代码质量和业务逻辑正确性: | 测试类型 | 目的 | 测试范围 | 依赖 | |---------|------|---------|------| | 单元测试 | 测试领域逻辑 | 值对象、聚合根 | 无外部依赖 | | 集成测试 | 测试服务层 | 应用服务、领域服务 | Mock依赖 | | E2E测试 | 测试完整流程 | API端点 | Mock服务 | ### 技术栈 - **测试框架**: Jest 30.x - **HTTP测试**: Supertest 7.x - **Mock工具**: Jest内置Mock - **容器化**: Docker Compose --- ## 测试架构 ``` test/ ├── integration/ # 集成测试 │ ├── reward-application.service.spec.ts │ └── reward-calculation.service.spec.ts │ ├── app.e2e-spec.ts # E2E测试 ├── jest-e2e.json # E2E测试配置 └── setup.ts # 测试环境设置 src/ ├── domain/ │ ├── aggregates/ │ │ ├── reward-ledger-entry/ │ │ │ └── reward-ledger-entry.spec.ts # 单元测试 │ │ └── reward-summary/ │ │ └── reward-summary.spec.ts # 单元测试 │ └── value-objects/ │ ├── money.spec.ts # 单元测试 │ └── hashpower.spec.ts # 单元测试 ``` --- ## 运行测试 ### 快速命令 ```bash # 运行所有测试 npm test # 运行单元测试 make test-unit # 运行集成测试 make test-integration # 运行E2E测试 make test-e2e # 运行所有测试 (Docker环境) make test-docker-all # 测试覆盖率 npm run test:cov ``` ### Makefile 命令详解 ```makefile # 单元测试 - 测试领域逻辑和值对象 test-unit: npm test -- --testPathPatterns='src/.*\.spec\.ts$' --verbose # 集成测试 - 测试服务层和仓储 test-integration: npm test -- --testPathPatterns='test/integration/.*\.spec\.ts$' --verbose # 端到端测试 - 测试完整API流程 test-e2e: npm run test:e2e -- --verbose # Docker环境中运行所有测试 test-docker-all: docker-compose -f docker-compose.test.yml up -d sleep 5 npm test -- --testPathPatterns='src/.*\.spec\.ts$' --verbose || true npm test -- --testPathPatterns='test/integration/.*\.spec\.ts$' --verbose || true npm run test:e2e -- --verbose || true docker-compose -f docker-compose.test.yml down -v ``` --- ## 单元测试 单元测试针对领域层的纯业务逻辑,不依赖外部服务。 ### 测试值对象 ```typescript // src/domain/value-objects/money.spec.ts describe('Money', () => { describe('USDT factory', () => { it('should create Money with USDT currency', () => { const money = Money.USDT(100); expect(money.amount).toBe(100); expect(money.currency).toBe('USDT'); }); }); describe('validation', () => { it('should throw error for negative amount', () => { expect(() => Money.USDT(-100)).toThrow('金额不能为负数'); }); }); describe('add', () => { it('should add two Money values', () => { const money1 = Money.USDT(100); const money2 = Money.USDT(50); const result = money1.add(money2); expect(result.amount).toBe(150); }); }); describe('subtract', () => { it('should return zero when subtracting larger value', () => { const money1 = Money.USDT(50); const money2 = Money.USDT(100); const result = money1.subtract(money2); expect(result.amount).toBe(0); }); }); }); ``` ### 测试聚合根 ```typescript // src/domain/aggregates/reward-ledger-entry/reward-ledger-entry.spec.ts describe('RewardLedgerEntry', () => { describe('createPending', () => { it('should create a pending reward with 24h expiration', () => { const entry = RewardLedgerEntry.createPending({ userId: BigInt(100), rewardSource: createTestRewardSource(), usdtAmount: Money.USDT(500), hashpowerAmount: Hashpower.zero(), memo: 'Test reward', }); expect(entry.isPending).toBe(true); expect(entry.expireAt).toBeDefined(); expect(entry.getRemainingTimeMs()).toBeGreaterThan(0); }); }); describe('claim', () => { it('should transition pending to settleable', () => { const entry = createPendingEntry(); entry.claim(); expect(entry.isSettleable).toBe(true); expect(entry.claimedAt).toBeDefined(); expect(entry.expireAt).toBeNull(); }); it('should throw error when not pending', () => { const entry = createSettleableEntry(); expect(() => entry.claim()).toThrow('只有待领取状态才能领取'); }); }); describe('expire', () => { it('should transition pending to expired', () => { const entry = createPendingEntry(); entry.expire(); expect(entry.isExpired).toBe(true); expect(entry.expiredAt).toBeDefined(); }); }); describe('settle', () => { it('should transition settleable to settled', () => { const entry = createSettleableEntry(); entry.settle('BNB', 0.25); expect(entry.isSettled).toBe(true); expect(entry.settledAt).toBeDefined(); }); it('should throw error when not settleable', () => { const entry = createPendingEntry(); expect(() => entry.settle('BNB', 0.25)).toThrow('只有可结算状态才能结算'); }); }); }); ``` --- ## 集成测试 集成测试验证应用服务层与领域服务的协作,使用Mock隔离外部依赖。 ### 测试应用服务 ```typescript // test/integration/reward-application.service.spec.ts describe('RewardApplicationService (Integration)', () => { let service: RewardApplicationService; let mockLedgerRepository: jest.Mocked; let mockSummaryRepository: jest.Mocked; let mockEventPublisher: jest.Mocked; let mockWalletService: jest.Mocked; beforeEach(async () => { // 创建Mock对象 mockLedgerRepository = { save: jest.fn(), saveAll: jest.fn(), findByUserId: jest.fn(), findPendingByUserId: jest.fn(), findSettleableByUserId: jest.fn(), findExpiredPending: jest.fn(), countByUserId: jest.fn(), }; mockSummaryRepository = { findByUserId: jest.fn(), getOrCreate: jest.fn(), save: jest.fn(), }; mockEventPublisher = { publish: jest.fn(), publishAll: jest.fn(), }; mockWalletService = { executeSwap: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ RewardApplicationService, RewardCalculationService, RewardExpirationService, { provide: REWARD_LEDGER_ENTRY_REPOSITORY, useValue: mockLedgerRepository, }, { provide: REWARD_SUMMARY_REPOSITORY, useValue: mockSummaryRepository, }, { provide: EventPublisherService, useValue: mockEventPublisher, }, { provide: WalletServiceClient, useValue: mockWalletService, }, // ... 其他Mock ], }).compile(); service = module.get(RewardApplicationService); }); describe('distributeRewards', () => { it('should distribute rewards and update summaries', async () => { // Arrange const params = { sourceOrderId: BigInt(1), sourceUserId: BigInt(100), treeCount: 10, provinceCode: '440000', cityCode: '440100', }; mockSummaryRepository.getOrCreate.mockResolvedValue( RewardSummary.create(BigInt(100)) ); // Act await service.distributeRewards(params); // Assert expect(mockLedgerRepository.saveAll).toHaveBeenCalled(); expect(mockSummaryRepository.save).toHaveBeenCalled(); expect(mockEventPublisher.publishAll).toHaveBeenCalled(); }); }); describe('settleRewards', () => { it('should settle rewards and call wallet service', async () => { // Arrange const settleableRewards = [createSettleableEntry()]; mockLedgerRepository.findSettleableByUserId.mockResolvedValue(settleableRewards); mockSummaryRepository.getOrCreate.mockResolvedValue( RewardSummary.create(BigInt(100)) ); mockWalletService.executeSwap.mockResolvedValue({ success: true, receivedAmount: 0.25, txHash: '0x123', }); // Act const result = await service.settleRewards({ userId: BigInt(100), settleCurrency: 'BNB', }); // Assert expect(result.success).toBe(true); expect(result.receivedAmount).toBe(0.25); expect(mockWalletService.executeSwap).toHaveBeenCalledWith({ userId: BigInt(100), usdtAmount: expect.any(Number), targetCurrency: 'BNB', }); }); it('should return error when no settleable rewards', async () => { // Arrange mockLedgerRepository.findSettleableByUserId.mockResolvedValue([]); // Act const result = await service.settleRewards({ userId: BigInt(100), settleCurrency: 'BNB', }); // Assert expect(result.success).toBe(false); expect(result.error).toBe('没有可结算的收益'); }); }); }); ``` ### 测试领域服务 ```typescript // test/integration/reward-calculation.service.spec.ts describe('RewardCalculationService (Integration)', () => { let service: RewardCalculationService; let mockReferralService: jest.Mocked; let mockAuthorizationService: jest.Mocked; beforeEach(async () => { mockReferralService = { getReferralChain: jest.fn(), }; mockAuthorizationService = { findNearestAuthorizedProvince: jest.fn(), findNearestAuthorizedCity: jest.fn(), findNearestCommunity: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ RewardCalculationService, { provide: REFERRAL_SERVICE_CLIENT, useValue: mockReferralService, }, { provide: AUTHORIZATION_SERVICE_CLIENT, useValue: mockAuthorizationService, }, ], }).compile(); service = module.get(RewardCalculationService); }); describe('calculateRewards', () => { const baseParams = { sourceOrderId: BigInt(1), sourceUserId: BigInt(100), treeCount: 10, provinceCode: '440000', cityCode: '440100', }; it('should calculate all 6 types of rewards', async () => { // Arrange mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [{ userId: BigInt(200), hasPlanted: true }], }); mockAuthorizationService.findNearestAuthorizedProvince.mockResolvedValue(BigInt(300)); mockAuthorizationService.findNearestAuthorizedCity.mockResolvedValue(BigInt(400)); mockAuthorizationService.findNearestCommunity.mockResolvedValue(BigInt(500)); // Act const rewards = await service.calculateRewards(baseParams); // Assert expect(rewards).toHaveLength(6); const rightTypes = rewards.map(r => r.rewardSource.rightType); expect(rightTypes).toContain(RightType.SHARE_RIGHT); expect(rightTypes).toContain(RightType.PROVINCE_TEAM_RIGHT); expect(rightTypes).toContain(RightType.PROVINCE_AREA_RIGHT); expect(rightTypes).toContain(RightType.CITY_TEAM_RIGHT); expect(rightTypes).toContain(RightType.CITY_AREA_RIGHT); expect(rightTypes).toContain(RightType.COMMUNITY_RIGHT); }); it('should calculate share right reward (500 USDT) when referrer has planted', async () => { // Arrange mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [{ userId: BigInt(200), hasPlanted: true }], }); // ... 其他Mock设置 // Act const rewards = await service.calculateRewards(baseParams); // Assert const shareReward = rewards.find( r => r.rewardSource.rightType === RightType.SHARE_RIGHT ); expect(shareReward).toBeDefined(); expect(shareReward?.isSettleable).toBe(true); expect(shareReward?.usdtAmount.amount).toBe(500 * 10); }); it('should create pending share right reward when referrer has not planted', async () => { // Arrange mockReferralService.getReferralChain.mockResolvedValue({ ancestors: [{ userId: BigInt(200), hasPlanted: false }], }); // Act const rewards = await service.calculateRewards(baseParams); // Assert const shareReward = rewards.find( r => r.rewardSource.rightType === RightType.SHARE_RIGHT ); expect(shareReward?.isPending).toBe(true); }); }); }); ``` --- ## E2E测试 E2E测试验证完整的HTTP请求-响应流程。 ### 测试配置 ```json // test/jest-e2e.json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { "^src/(.*)$": "/../src/$1" } } ``` ### E2E测试示例 ```typescript // test/app.e2e-spec.ts describe('Reward Service (e2e)', () => { let app: INestApplication; let jwtService: JwtService; let mockRewardService: any; const TEST_JWT_SECRET = 'test-secret-key-for-testing'; const createTestToken = (userId: string = '100') => { return jwtService.sign({ sub: userId, username: 'testuser', roles: ['user'], }); }; beforeEach(async () => { mockRewardService = { getRewardSummary: jest.fn().mockResolvedValue({ pendingUsdt: 1000, pendingHashpower: 0.5, pendingExpireAt: new Date(Date.now() + 12 * 60 * 60 * 1000), settleableUsdt: 500, settleableHashpower: 0.2, settledTotalUsdt: 2000, settledTotalHashpower: 1.0, expiredTotalUsdt: 100, expiredTotalHashpower: 0.1, }), // ... 其他Mock方法 }; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [() => ({ JWT_SECRET: TEST_JWT_SECRET })], }), PassportModule, JwtModule.register({ secret: TEST_JWT_SECRET, signOptions: { expiresIn: '1h' }, }), ], controllers: [HealthController, RewardController, SettlementController], providers: [ { provide: RewardApplicationService, useValue: mockRewardService, }, // ... JwtStrategy配置 ], }).compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ transform: true })); await app.init(); jwtService = moduleFixture.get(JwtService); }); afterEach(async () => { await app.close(); }); describe('Health Check', () => { it('/health (GET) should return healthy status', () => { return request(app.getHttpServer()) .get('/health') .expect(200) .expect((res) => { expect(res.body.status).toBe('ok'); expect(res.body.service).toBe('reward-service'); }); }); }); describe('Rewards API', () => { describe('GET /rewards/summary', () => { it('should return 401 without auth token', () => { return request(app.getHttpServer()) .get('/rewards/summary') .expect(401); }); it('should return reward summary with valid token', () => { const token = createTestToken(); return request(app.getHttpServer()) .get('/rewards/summary') .set('Authorization', `Bearer ${token}`) .expect(200) .expect((res) => { expect(res.body.pendingUsdt).toBe(1000); expect(res.body.settleableUsdt).toBe(500); }); }); }); }); describe('Settlement API', () => { describe('POST /rewards/settle', () => { it('should settle rewards successfully with valid token', () => { const token = createTestToken(); return request(app.getHttpServer()) .post('/rewards/settle') .set('Authorization', `Bearer ${token}`) .send({ settleCurrency: 'BNB' }) .expect(201) .expect((res) => { expect(res.body.success).toBe(true); }); }); it('should validate settleCurrency parameter', () => { const token = createTestToken(); return request(app.getHttpServer()) .post('/rewards/settle') .set('Authorization', `Bearer ${token}`) .send({ settleCurrency: '' }) .expect(400); }); }); }); }); ``` --- ## Docker测试环境 ### docker-compose.test.yml ```yaml version: '3.8' services: postgres: image: postgres:15-alpine container_name: reward-test-postgres environment: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: reward_test ports: - '5433:5432' healthcheck: test: ['CMD-SHELL', 'pg_isready -U test -d reward_test'] interval: 5s timeout: 5s retries: 5 redis: image: redis:7-alpine container_name: reward-test-redis ports: - '6380:6379' healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 5s retries: 5 kafka: image: confluentinc/cp-kafka:7.5.0 container_name: reward-test-kafka depends_on: - zookeeper ports: - '9093:9092' environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 # ... 其他配置 ``` ### 运行Docker测试 ```bash # 启动测试依赖 make docker-up # 运行测试 make test-docker-all # 关闭测试依赖 make docker-down ``` --- ## 测试覆盖率 ### 生成覆盖率报告 ```bash npm run test:cov ``` ### 覆盖率目标 | 指标 | 目标 | |------|------| | 语句覆盖率 (Statements) | >= 80% | | 分支覆盖率 (Branches) | >= 75% | | 函数覆盖率 (Functions) | >= 85% | | 行覆盖率 (Lines) | >= 80% | ### 查看报告 覆盖率报告生成在 `coverage/` 目录: ```bash # 在浏览器中打开HTML报告 open coverage/lcov-report/index.html ``` --- ## 测试最佳实践 ### 1. 测试命名规范 ```typescript // 使用 describe 组织测试 describe('RewardLedgerEntry', () => { describe('claim', () => { it('should transition pending to settleable', () => { ... }); it('should throw error when not pending', () => { ... }); }); }); ``` ### 2. AAA 模式 ```typescript it('should calculate total amount', () => { // Arrange - 准备测试数据 const reward1 = createReward(100); const reward2 = createReward(200); // Act - 执行被测试的行为 const total = service.calculateTotal([reward1, reward2]); // Assert - 验证结果 expect(total).toBe(300); }); ``` ### 3. 使用工厂函数创建测试数据 ```typescript // test/helpers/factories.ts export function createTestRewardSource(overrides = {}) { return RewardSource.create( RightType.SHARE_RIGHT, BigInt(1), BigInt(100), ...overrides, ); } export function createPendingEntry(overrides = {}) { return RewardLedgerEntry.createPending({ userId: BigInt(100), rewardSource: createTestRewardSource(), usdtAmount: Money.USDT(500), hashpowerAmount: Hashpower.zero(), ...overrides, }); } ``` ### 4. 避免测试实现细节 ```typescript // ❌ 测试实现细节 expect(service['privateField']).toBe(expectedValue); // ✅ 测试行为 const result = await service.publicMethod(); expect(result).toMatchObject({ status: 'success' }); ``` ### 5. 使用 Mock 隔离依赖 ```typescript // 创建类型安全的Mock const mockRepository = { save: jest.fn(), findById: jest.fn(), } as jest.Mocked; // 设置Mock返回值 mockRepository.findById.mockResolvedValue(expectedEntry); // 验证Mock被调用 expect(mockRepository.save).toHaveBeenCalledWith(expect.objectContaining({ userId: BigInt(100), })); ``` --- ## 测试报告示例 ``` Test Suites: 7 passed, 7 total Tests: 77 passed, 77 total Snapshots: 0 total Time: 13.026 s ┌─────────────────────────────────────────────────────────────┐ │ 测试结果汇总 │ ├─────────────────┬──────────┬──────────┬──────────┬─────────┤ │ 测试类型 │ 测试套件 │ 测试用例 │ 状态 │ 耗时 │ ├─────────────────┼──────────┼──────────┼──────────┼─────────┤ │ 单元测试 │ 4 │ 43 │ ✅ 通过 │ 3.2s │ │ 集成测试 │ 2 │ 20 │ ✅ 通过 │ 4.8s │ │ E2E测试 │ 1 │ 14 │ ✅ 通过 │ 5.0s │ ├─────────────────┼──────────┼──────────┼──────────┼─────────┤ │ 总计 │ 7 │ 77 │ ✅ 通过 │ ~13s │ └─────────────────┴──────────┴──────────┴──────────┴─────────┘ ```