# Planting Service 测试文档 ## 目录 - [测试策略](#测试策略) - [测试架构](#测试架构) - [单元测试](#单元测试) - [集成测试](#集成测试) - [端到端测试](#端到端测试) - [测试覆盖率](#测试覆盖率) - [Docker 测试](#docker-测试) - [CI/CD 集成](#cicd-集成) - [最佳实践](#最佳实践) --- ## 测试策略 ### 测试金字塔 ``` ┌─────────┐ │ E2E │ ◄── 少量,验证完整流程 │ Tests │ ─┴─────────┴─ ┌─────────────┐ │ Integration │ ◄── 中等数量,验证模块协作 │ Tests │ ─┴─────────────┴─ ┌─────────────────┐ │ Unit Tests │ ◄── 大量,验证业务逻辑 │ │ └─────────────────┘ ``` ### 测试分布 | 测试类型 | 数量 | 覆盖范围 | 执行时间 | |---------|------|---------|---------| | 单元测试 | 45+ | 领域逻辑、值对象 | < 10s | | 集成测试 | 12+ | 应用服务、用例 | < 30s | | E2E 测试 | 17+ | API 端点、认证 | < 60s | --- ## 测试架构 ### 目录结构 ``` planting-service/ ├── src/ │ ├── domain/ │ │ ├── aggregates/ │ │ │ ├── planting-order.aggregate.ts │ │ │ └── planting-order.aggregate.spec.ts # 单元测试 │ │ ├── services/ │ │ │ ├── fund-allocation.service.ts │ │ │ └── fund-allocation.service.spec.ts # 单元测试 │ │ └── value-objects/ │ │ ├── tree-count.vo.ts │ │ └── tree-count.vo.spec.ts # 单元测试 │ └── application/ │ └── services/ │ ├── planting-application.service.ts │ └── planting-application.service.integration.spec.ts # 集成测试 └── test/ ├── app.e2e-spec.ts # E2E 测试 └── jest-e2e.json # E2E 配置 ``` ### 测试配置 **Jest 配置 (package.json)** ```json { "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": ["**/*.(t|j)s"], "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { "^@/(.*)$": "/$1" } } } ``` **E2E 配置 (test/jest-e2e.json)** ```json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { "^@/(.*)$": "/../src/$1" } } ``` --- ## 单元测试 ### 概述 单元测试专注于测试独立的业务逻辑单元,不依赖外部服务或数据库。 ### 测试领域聚合 ```typescript // src/domain/aggregates/planting-order.aggregate.spec.ts import { PlantingOrder } from './planting-order.aggregate'; import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum'; describe('PlantingOrder', () => { describe('create', () => { it('应该创建有效的订单', () => { const order = PlantingOrder.create(BigInt(1), 5); expect(order.userId).toBe(BigInt(1)); expect(order.treeCount).toBe(5); expect(order.totalAmount).toBe(5 * 2199); expect(order.status).toBe(PlantingOrderStatus.CREATED); }); it('应该生成唯一的订单号', () => { const order1 = PlantingOrder.create(BigInt(1), 1); const order2 = PlantingOrder.create(BigInt(1), 1); expect(order1.orderNo).not.toBe(order2.orderNo); expect(order1.orderNo).toMatch(/^PO\d{14}$/); }); it('应该产生领域事件', () => { const order = PlantingOrder.create(BigInt(1), 5); const events = order.getDomainEvents(); expect(events).toHaveLength(1); expect(events[0].constructor.name).toBe('PlantingOrderCreatedEvent'); }); }); describe('selectProvinceCity', () => { it('应该允许在CREATED状态下选择省市', () => { const order = PlantingOrder.create(BigInt(1), 1); order.selectProvinceCity('440000', '广东省', '440100', '广州市'); expect(order.provinceCode).toBe('440000'); expect(order.cityCode).toBe('440100'); }); it('不应该允许在非CREATED状态下选择省市', () => { const order = PlantingOrder.create(BigInt(1), 1); order.cancel(); expect(() => { order.selectProvinceCity('440000', '广东省', '440100', '广州市'); }).toThrow(); }); }); describe('confirmProvinceCity', () => { it('应该在5秒后允许确认', () => { jest.useFakeTimers(); const order = PlantingOrder.create(BigInt(1), 1); order.selectProvinceCity('440000', '广东省', '440100', '广州市'); jest.advanceTimersByTime(5000); expect(() => order.confirmProvinceCity()).not.toThrow(); expect(order.status).toBe(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED); jest.useRealTimers(); }); it('应该在5秒内拒绝确认', () => { jest.useFakeTimers(); const order = PlantingOrder.create(BigInt(1), 1); order.selectProvinceCity('440000', '广东省', '440100', '广州市'); jest.advanceTimersByTime(3000); // 只过了3秒 expect(() => order.confirmProvinceCity()).toThrow('还需等待'); jest.useRealTimers(); }); }); }); ``` ### 测试值对象 ```typescript // src/domain/value-objects/tree-count.vo.spec.ts import { TreeCount } from './tree-count.vo'; describe('TreeCount', () => { describe('create', () => { it('应该创建有效的数量', () => { const count = TreeCount.create(5); expect(count.value).toBe(5); }); it('应该拒绝0或负数', () => { expect(() => TreeCount.create(0)).toThrow('认种数量必须大于0'); expect(() => TreeCount.create(-1)).toThrow('认种数量必须大于0'); }); it('应该拒绝超过最大限制', () => { expect(() => TreeCount.create(1001)).toThrow('单次认种数量不能超过1000'); }); it('应该拒绝小数', () => { expect(() => TreeCount.create(1.5)).toThrow('认种数量必须为整数'); }); }); describe('checkLimit', () => { it('应该正确检查限购', () => { expect(TreeCount.checkLimit(900, 100)).toBe(true); // 刚好1000 expect(TreeCount.checkLimit(901, 100)).toBe(false); // 超过1000 }); }); }); ``` ### 测试领域服务 ```typescript // src/domain/services/fund-allocation.service.spec.ts import { FundAllocationDomainService } from './fund-allocation.service'; describe('FundAllocationDomainService', () => { let service: FundAllocationDomainService; beforeEach(() => { service = new FundAllocationDomainService(); }); describe('calculateAllocations', () => { it('应该返回10种分配', () => { const allocations = service.calculateAllocations(1, { referralChain: [], nearestProvinceAuth: null, nearestCityAuth: null, nearestCommunity: null, }); expect(allocations).toHaveLength(10); }); it('应该正确计算资金池分配', () => { const allocations = service.calculateAllocations(1, { referralChain: [], nearestProvinceAuth: null, nearestCityAuth: null, nearestCommunity: null, }); const poolAllocation = allocations.find(a => a.targetType === 'POOL'); expect(poolAllocation?.amount).toBe(1979.1); // 2199 * 90% }); it('应该正确计算总金额', () => { const allocations = service.calculateAllocations(5, { referralChain: [], nearestProvinceAuth: null, nearestCityAuth: null, nearestCommunity: null, }); const total = allocations.reduce((sum, a) => sum + a.amount, 0); expect(total).toBeCloseTo(5 * 2199, 2); }); }); }); ``` ### 运行单元测试 ```bash # 运行所有单元测试 npm run test # 或 make test-unit # 监听模式 npm run test:watch # 运行特定文件 npm run test -- planting-order.aggregate.spec.ts # 详细输出 npm run test -- --verbose ``` --- ## 集成测试 ### 概述 集成测试验证多个组件协同工作,使用 Mock 替代外部依赖。 ### 应用服务集成测试 ```typescript // src/application/services/planting-application.service.integration.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { PlantingApplicationService } from './planting-application.service'; import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service'; import { IPlantingOrderRepository, PLANTING_ORDER_REPOSITORY, } from '../../domain/repositories/planting-order.repository.interface'; import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client'; import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client'; describe('PlantingApplicationService (Integration)', () => { let service: PlantingApplicationService; let orderRepository: jest.Mocked; let walletService: jest.Mocked; let referralService: jest.Mocked; beforeEach(async () => { // 创建 Mocks orderRepository = { save: jest.fn(), findById: jest.fn(), findByOrderNo: jest.fn(), findByUserId: jest.fn(), countTreesByUserId: jest.fn(), } as any; walletService = { getBalance: jest.fn(), deductForPlanting: jest.fn(), allocateFunds: jest.fn(), } as any; referralService = { getReferralContext: jest.fn(), } as any; const module: TestingModule = await Test.createTestingModule({ providers: [ PlantingApplicationService, FundAllocationDomainService, { provide: PLANTING_ORDER_REPOSITORY, useValue: orderRepository }, { provide: WalletServiceClient, useValue: walletService }, { provide: ReferralServiceClient, useValue: referralService }, ], }).compile(); service = module.get(PlantingApplicationService); }); describe('createOrder', () => { it('应该成功创建订单', async () => { // Arrange orderRepository.countTreesByUserId.mockResolvedValue(0); walletService.getBalance.mockResolvedValue({ userId: '1', available: 100000, locked: 0, currency: 'USDT', }); orderRepository.save.mockResolvedValue(undefined); // Act const result = await service.createOrder(BigInt(1), 5); // Assert expect(result.treeCount).toBe(5); expect(result.totalAmount).toBe(5 * 2199); expect(orderRepository.save).toHaveBeenCalled(); }); it('应该拒绝超过限购数量', async () => { orderRepository.countTreesByUserId.mockResolvedValue(950); await expect(service.createOrder(BigInt(1), 100)) .rejects.toThrow('超过个人最大认种数量限制'); }); it('应该拒绝余额不足', async () => { orderRepository.countTreesByUserId.mockResolvedValue(0); walletService.getBalance.mockResolvedValue({ userId: '1', available: 100, // 余额不足 locked: 0, currency: 'USDT', }); await expect(service.createOrder(BigInt(1), 5)) .rejects.toThrow('余额不足'); }); }); describe('payOrder', () => { it('应该成功支付订单并完成资金分配', async () => { jest.useFakeTimers(); // 准备订单 const order = PlantingOrder.create(BigInt(1), 1); order.selectProvinceCity('440000', '广东省', '440100', '广州市'); jest.advanceTimersByTime(5000); order.confirmProvinceCity(); // 设置 mocks orderRepository.findByOrderNo.mockResolvedValue(order); walletService.deductForPlanting.mockResolvedValue(true); referralService.getReferralContext.mockResolvedValue({ referralChain: [], nearestProvinceAuth: null, nearestCityAuth: null, nearestCommunity: null, }); walletService.allocateFunds.mockResolvedValue(true); // Act const result = await service.payOrder(order.orderNo, BigInt(1)); // Assert expect(result.allocations.length).toBe(10); expect(walletService.deductForPlanting).toHaveBeenCalled(); expect(walletService.allocateFunds).toHaveBeenCalled(); jest.useRealTimers(); }); }); }); ``` ### 运行集成测试 ```bash # 运行集成测试 npm run test -- --testPathPattern=integration --runInBand # 或 make test-integration ``` --- ## 端到端测试 ### 概述 E2E 测试通过 HTTP 请求验证完整的 API 功能。 ### E2E 测试实现 ```typescript // test/app.e2e-spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common'; import * as request from 'supertest'; import { HealthController } from '../src/api/controllers/health.controller'; import { PlantingOrderController } from '../src/api/controllers/planting-order.controller'; import { PlantingApplicationService } from '../src/application/services/planting-application.service'; import { JwtAuthGuard } from '../src/api/guards/jwt-auth.guard'; // Mock 服务 const mockPlantingService = { createOrder: jest.fn(), selectProvinceCity: jest.fn(), payOrder: jest.fn(), getUserOrders: jest.fn(), getUserPosition: jest.fn(), }; describe('PlantingController (e2e) - Unauthorized', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ controllers: [HealthController, PlantingOrderController], providers: [ { provide: PlantingApplicationService, useValue: mockPlantingService }, ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => false }) .compile(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api/v1'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, })); await app.init(); }); afterAll(async () => { await app.close(); }); describe('/health (GET)', () => { it('应该返回健康状态', () => { return request(app.getHttpServer()) .get('/api/v1/health') .expect(200) .expect((res) => { expect(res.body.status).toBe('ok'); }); }); }); describe('/planting/orders (POST)', () => { it('应该拒绝未认证的请求', () => { return request(app.getHttpServer()) .post('/api/v1/planting/orders') .send({ treeCount: 1 }) .expect(403); }); }); }); describe('PlantingController (e2e) - Authorized', () => { let app: INestApplication; beforeAll(async () => { // 设置认证通过的 Guard const mockAuthGuard = { canActivate: (context: ExecutionContext) => { const req = context.switchToHttp().getRequest(); req.user = { id: '1', username: 'testuser' }; return true; }, }; const moduleFixture: TestingModule = await Test.createTestingModule({ controllers: [HealthController, PlantingOrderController], providers: [ { provide: PlantingApplicationService, useValue: mockPlantingService }, ], }) .overrideGuard(JwtAuthGuard) .useValue(mockAuthGuard) .compile(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api/v1'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, })); await app.init(); }); describe('/planting/orders (POST)', () => { it('应该成功创建订单', async () => { mockPlantingService.createOrder.mockResolvedValue({ orderNo: 'PO202411300001', treeCount: 5, totalAmount: 10995, status: 'CREATED', }); const response = await request(app.getHttpServer()) .post('/api/v1/planting/orders') .send({ treeCount: 5 }) .expect(201); expect(response.body.orderNo).toBe('PO202411300001'); }); it('应该验证 treeCount 必须为正整数', async () => { await request(app.getHttpServer()) .post('/api/v1/planting/orders') .send({ treeCount: 0 }) .expect(400); }); }); }); ``` ### 运行 E2E 测试 ```bash # 运行 E2E 测试 npm run test:e2e # 或 make test-e2e ``` --- ## 测试覆盖率 ### 生成覆盖率报告 ```bash npm run test:cov # 或 make test-cov ``` ### 覆盖率指标 | 指标 | 目标 | 当前 | |-----|------|-----| | 语句覆盖率 | > 80% | 34% | | 分支覆盖率 | > 70% | 17% | | 函数覆盖率 | > 80% | 36% | | 行覆盖率 | > 80% | 34% | ### 核心模块覆盖率 | 模块 | 覆盖率 | 说明 | |-----|-------|------| | planting-application.service | 89% | 核心应用服务 | | fund-allocation.service | 93% | 资金分配服务 | | planting-order.aggregate | 69% | 订单聚合 | | tree-count.vo | 100% | 数量值对象 | ### 覆盖率报告位置 覆盖率报告生成在 `coverage/` 目录: ``` coverage/ ├── lcov-report/ │ └── index.html # HTML 报告 ├── lcov.info # LCOV 格式 └── clover.xml # Clover 格式 ``` --- ## Docker 测试 ### Docker 测试配置 **docker-compose.test.yml** ```yaml version: '3.8' services: db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rwadurian_planting_test ports: - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 test: build: context: . dockerfile: Dockerfile.test depends_on: db: condition: service_healthy environment: NODE_ENV: test DATABASE_URL: postgresql://postgres:postgres@db:5432/rwadurian_planting_test JWT_SECRET: test-jwt-secret volumes: - ./src:/app/src - ./test:/app/test - ./prisma:/app/prisma ``` **Dockerfile.test** ```dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY prisma ./prisma/ RUN npx prisma generate COPY . . RUN npm run build CMD ["npm", "run", "test"] ``` ### 运行 Docker 测试 ```bash # 运行所有 Docker 测试 make test-docker-all # 分步运行 docker-compose -f docker-compose.test.yml up -d db docker-compose -f docker-compose.test.yml run --rm test npm run test docker-compose -f docker-compose.test.yml down -v ``` --- ## CI/CD 集成 ### GitHub Actions 示例 ```yaml # .github/workflows/test.yml name: Test on: push: branches: [main, develop] pull_request: branches: [main, develop] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rwadurian_planting_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Generate Prisma Client run: npx prisma generate - name: Run migrations run: npx prisma migrate deploy env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test - name: Run unit tests run: npm run test - name: Run E2E tests run: npm run test:e2e env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test - name: Upload coverage uses: codecov/codecov-action@v3 with: file: ./coverage/lcov.info ``` --- ## 最佳实践 ### 1. 测试命名规范 ```typescript describe('被测试的单元', () => { describe('方法名', () => { it('应该 + 预期行为', () => { // ... }); }); }); ``` ### 2. AAA 模式 ```typescript it('应该创建有效的订单', () => { // Arrange - 准备 const userId = BigInt(1); const treeCount = 5; // Act - 执行 const order = PlantingOrder.create(userId, treeCount); // Assert - 断言 expect(order.treeCount).toBe(5); }); ``` ### 3. 避免测试实现细节 ```typescript // 不好 - 测试实现细节 it('应该调用 repository.save', () => { await service.createOrder(1, 5); expect(repository.save).toHaveBeenCalledTimes(1); }); // 好 - 测试行为 it('应该返回创建的订单', () => { const result = await service.createOrder(1, 5); expect(result.orderNo).toBeDefined(); expect(result.treeCount).toBe(5); }); ``` ### 4. 独立的测试数据 ```typescript // 每个测试使用独立数据 beforeEach(() => { jest.clearAllMocks(); }); // 使用工厂函数创建测试数据 function createTestOrder(overrides = {}) { return PlantingOrder.create(BigInt(1), 1, { ...defaultProps, ...overrides, }); } ``` ### 5. Mock 外部依赖 ```typescript // 只 mock 必要的外部依赖 const walletService = { getBalance: jest.fn().mockResolvedValue({ available: 100000 }), deductForPlanting: jest.fn().mockResolvedValue(true), }; ``` --- ## 常用命令速查 ```bash # 单元测试 make test-unit # 运行单元测试 npm run test -- --watch # 监听模式 npm run test -- --verbose # 详细输出 npm run test -- file.spec # 运行特定文件 # 集成测试 make test-integration # 运行集成测试 # E2E 测试 make test-e2e # 运行 E2E 测试 # 覆盖率 make test-cov # 生成覆盖率报告 # Docker 测试 make test-docker-all # Docker 中运行所有测试 # 所有测试 make test-all # 运行所有测试 ```