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

21 KiB
Raw Blame History

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    │
└─────────────────┴──────────┴──────────┴──────────┴─────────┘