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

809 lines
22 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.

# 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<IRewardLedgerEntryRepository>;
let mockSummaryRepository: jest.Mocked<IRewardSummaryRepository>;
let mockEventPublisher: jest.Mocked<EventPublisherService>;
let mockWalletService: jest.Mocked<WalletServiceClient>;
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>(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<IReferralServiceClient>;
let mockAuthorizationService: jest.Mocked<IAuthorizationServiceClient>;
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>(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/(.*)$": "<rootDir>/../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>(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<IRewardLedgerEntryRepository>;
// 设置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 │
└─────────────────┴──────────┴──────────┴──────────┴─────────┘
```