21 KiB
21 KiB
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 # 单元测试
运行测试
快速命令
# 运行所有测试
npm test
# 运行单元测试
make test-unit
# 运行集成测试
make test-integration
# 运行E2E测试
make test-e2e
# 运行所有测试 (Docker环境)
make test-docker-all
# 测试覆盖率
npm run test:cov
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
单元测试
单元测试针对领域层的纯业务逻辑,不依赖外部服务。
测试值对象
// 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);
});
});
});
测试聚合根
// 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隔离外部依赖。
测试应用服务
// 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('没有可结算的收益');
});
});
});
测试领域服务
// 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请求-响应流程。
测试配置
// 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测试示例
// 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
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测试
# 启动测试依赖
make docker-up
# 运行测试
make test-docker-all
# 关闭测试依赖
make docker-down
测试覆盖率
生成覆盖率报告
npm run test:cov
覆盖率目标
| 指标 | 目标 |
|---|---|
| 语句覆盖率 (Statements) | >= 80% |
| 分支覆盖率 (Branches) | >= 75% |
| 函数覆盖率 (Functions) | >= 85% |
| 行覆盖率 (Lines) | >= 80% |
查看报告
覆盖率报告生成在 coverage/ 目录:
# 在浏览器中打开HTML报告
open coverage/lcov-report/index.html
测试最佳实践
1. 测试命名规范
// 使用 describe 组织测试
describe('RewardLedgerEntry', () => {
describe('claim', () => {
it('should transition pending to settleable', () => { ... });
it('should throw error when not pending', () => { ... });
});
});
2. AAA 模式
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. 使用工厂函数创建测试数据
// 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. 避免测试实现细节
// ❌ 测试实现细节
expect(service['privateField']).toBe(expectedValue);
// ✅ 测试行为
const result = await service.publicMethod();
expect(result).toMatchObject({ status: 'success' });
5. 使用 Mock 隔离依赖
// 创建类型安全的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 │
└─────────────────┴──────────┴──────────┴──────────┴─────────┘