# 测试指南 ## 1. 测试策略概述 ### 1.1 测试金字塔 ``` ┌───────────┐ │ E2E │ ← 端到端测试 (少量) │ Tests │ ─┴───────────┴─ ┌───────────────┐ │ Integration │ ← 集成测试 (中等) │ Tests │ ─┴───────────────┴─ ┌───────────────────┐ │ Unit Tests │ ← 单元测试 (大量) └───────────────────┘ ``` ### 1.2 测试类型分布 | 测试类型 | 数量 | 覆盖范围 | 执行时间 | |---------|------|---------|---------| | 单元测试 | 20+ | 领域层、值对象、聚合根 | ~2s | | 集成测试 | 11+ | 数据库仓储、应用服务 | ~10s | | E2E 测试 | 12+ | HTTP API、完整流程 | ~15s | | Docker 测试 | 31+ | 容器化环境全量测试 | ~60s | ## 2. 单元测试 ### 2.1 测试范围 单元测试主要覆盖: - **值对象 (Value Objects)**: 不可变性、验证逻辑、相等性比较 - **聚合根 (Aggregates)**: 业务规则、状态变更、领域事件 - **领域服务**: 业务逻辑计算 ### 2.2 目录结构 ``` src/ ├── domain/ │ ├── value-objects/ │ │ ├── date-range.vo.ts │ │ ├── date-range.spec.ts ← 值对象单元测试 │ │ ├── report-period.vo.ts │ │ ├── report-period.spec.ts │ │ ├── export-format.vo.ts │ │ └── export-format.spec.ts │ └── aggregates/ │ ├── report-definition/ │ │ ├── report-definition.aggregate.ts │ │ └── report-definition.spec.ts ← 聚合根单元测试 │ └── report-snapshot/ │ ├── report-snapshot.aggregate.ts │ └── report-snapshot.spec.ts ``` ### 2.3 值对象测试示例 ```typescript // src/domain/value-objects/date-range.spec.ts describe('DateRange Value Object', () => { describe('create', () => { it('should create valid date range', () => { const startDate = new Date('2024-01-01'); const endDate = new Date('2024-01-31'); const dateRange = DateRange.create(startDate, endDate); expect(dateRange.startDate).toEqual(startDate); expect(dateRange.endDate).toEqual(endDate); }); it('should throw error when start date is after end date', () => { const startDate = new Date('2024-01-31'); const endDate = new Date('2024-01-01'); expect(() => DateRange.create(startDate, endDate)) .toThrow('Start date cannot be after end date'); }); }); describe('equals', () => { it('should return true for equal date ranges', () => { const range1 = DateRange.create( new Date('2024-01-01'), new Date('2024-01-31') ); const range2 = DateRange.create( new Date('2024-01-01'), new Date('2024-01-31') ); expect(range1.equals(range2)).toBe(true); }); }); describe('getDays', () => { it('should calculate correct number of days', () => { const dateRange = DateRange.create( new Date('2024-01-01'), new Date('2024-01-31') ); expect(dateRange.getDays()).toBe(31); }); }); }); ``` ### 2.4 聚合根测试示例 ```typescript // src/domain/aggregates/report-snapshot/report-snapshot.spec.ts describe('ReportSnapshot Aggregate', () => { const mockDefinition = ReportDefinition.reconstitute({ id: 1n, code: 'RPT_TEST', name: 'Test Report', description: 'Test description', category: 'TEST', dataSource: 'test_table', isActive: true, createdAt: new Date(), updatedAt: new Date(), }); describe('create', () => { it('should create snapshot with pending status', () => { const snapshot = ReportSnapshot.create({ definition: mockDefinition, period: ReportPeriod.DAILY, dateRange: DateRange.create( new Date('2024-01-01'), new Date('2024-01-01') ), }); expect(snapshot.status).toBe(SnapshotStatus.PENDING); expect(snapshot.reportCode).toBe('RPT_TEST'); }); }); describe('markAsProcessing', () => { it('should transition from pending to processing', () => { const snapshot = createPendingSnapshot(); snapshot.markAsProcessing(); expect(snapshot.status).toBe(SnapshotStatus.PROCESSING); }); it('should throw error if not in pending status', () => { const snapshot = createCompletedSnapshot(); expect(() => snapshot.markAsProcessing()) .toThrow('Cannot start processing'); }); }); describe('complete', () => { it('should set data and mark as completed', () => { const snapshot = createProcessingSnapshot(); const data = { items: [{ id: 1 }], total: 1 }; snapshot.complete(data); expect(snapshot.status).toBe(SnapshotStatus.COMPLETED); expect(snapshot.snapshotData).toEqual(data); }); }); }); ``` ### 2.5 运行单元测试 ```bash # 运行所有单元测试 npm test # 运行特定文件 npm test -- date-range.spec.ts # Watch 模式 npm run test:watch # 带覆盖率 npm run test:cov ``` ## 3. 集成测试 ### 3.1 测试范围 集成测试覆盖: - **仓储实现**: Prisma 数据库操作 - **应用服务**: 命令/查询处理 - **缓存服务**: Redis 缓存操作 ### 3.2 目录结构 ``` src/ ├── infrastructure/ │ └── repositories/ │ ├── prisma-report-definition.repository.ts │ └── prisma-report-definition.repository.integration.spec.ts ├── application/ │ └── services/ │ ├── reporting-application.service.ts │ └── reporting-application.service.integration.spec.ts ``` ### 3.3 数据库集成测试 ```typescript // prisma-report-definition.repository.integration.spec.ts describe('PrismaReportDefinitionRepository (Integration)', () => { let repository: PrismaReportDefinitionRepository; let prismaService: PrismaService; beforeAll(async () => { const module = await Test.createTestingModule({ providers: [ PrismaReportDefinitionRepository, PrismaService, ], }).compile(); repository = module.get(PrismaReportDefinitionRepository); prismaService = module.get(PrismaService); }); beforeEach(async () => { // 清理测试数据 await prismaService.reportSnapshot.deleteMany(); await prismaService.reportDefinition.deleteMany(); }); afterAll(async () => { await prismaService.$disconnect(); }); describe('save', () => { it('should persist report definition to database', async () => { const definition = ReportDefinition.create({ code: 'RPT_TEST', name: 'Test Report', description: 'Test', category: 'TEST', dataSource: 'test_table', }); await repository.save(definition); const found = await prismaService.reportDefinition.findUnique({ where: { code: 'RPT_TEST' }, }); expect(found).not.toBeNull(); expect(found?.name).toBe('Test Report'); }); }); describe('findByCode', () => { it('should return null for non-existent code', async () => { const result = await repository.findByCode('NON_EXISTENT'); expect(result).toBeNull(); }); it('should return definition when exists', async () => { // 创建测试数据 await prismaService.reportDefinition.create({ data: { code: 'RPT_FIND', name: 'Find Test', description: 'Test', category: 'TEST', dataSource: 'test_table', isActive: true, }, }); const result = await repository.findByCode('RPT_FIND'); expect(result).not.toBeNull(); expect(result?.code).toBe('RPT_FIND'); }); }); }); ``` ### 3.4 应用服务集成测试 ```typescript // reporting-application.service.integration.spec.ts describe('ReportingApplicationService (Integration)', () => { let service: ReportingApplicationService; let prismaService: PrismaService; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule], }).compile(); service = module.get(ReportingApplicationService); prismaService = module.get(PrismaService); }); beforeEach(async () => { await cleanDatabase(prismaService); await seedTestData(prismaService); }); describe('generateReport', () => { it('should create snapshot for valid report', async () => { const result = await service.generateReport({ reportCode: 'RPT_LEADERBOARD', reportPeriod: ReportPeriod.DAILY, startDate: new Date('2024-01-01'), endDate: new Date('2024-01-01'), }); expect(result).toBeDefined(); expect(result.status).toBe(SnapshotStatus.COMPLETED); }); it('should throw when report definition not found', async () => { await expect( service.generateReport({ reportCode: 'NON_EXISTENT', reportPeriod: ReportPeriod.DAILY, startDate: new Date('2024-01-01'), endDate: new Date('2024-01-01'), }) ).rejects.toThrow(ReportDefinitionNotFoundException); }); }); }); ``` ### 3.5 测试数据库配置 ```env # .env.test DATABASE_URL=postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public REDIS_HOST=localhost REDIS_PORT=6380 ``` ### 3.6 运行集成测试 ```bash # 启动测试依赖 docker compose -f docker-compose.test.yml up -d postgres-test redis-test # 推送 Schema DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \ npx prisma db push # 运行集成测试 npm run test:integration # 或使用 Makefile make test-integration ``` ## 4. E2E 测试 ### 4.1 测试范围 E2E 测试覆盖: - HTTP API 端点 - 请求验证 - 响应格式 - 错误处理 - 完整业务流程 ### 4.2 目录结构 ``` test/ ├── app.e2e-spec.ts # 主 E2E 测试文件 ├── jest-e2e.json # E2E Jest 配置 └── setup-e2e.ts # E2E 测试设置 ``` ### 4.3 E2E 测试配置 ```json // test/jest-e2e.json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "testTimeout": 30000, "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "setupFilesAfterEnv": ["/setup-e2e.ts"], "moduleNameMapper": { "^@/(.*)$": "/../src/$1", "^@domain/(.*)$": "/../src/domain/$1", "^@application/(.*)$": "/../src/application/$1", "^@infrastructure/(.*)$": "/../src/infrastructure/$1", "^@api/(.*)$": "/../src/api/$1" } } ``` ```typescript // test/setup-e2e.ts import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../.env.test') }); ``` ### 4.4 E2E 测试示例 ```typescript // test/app.e2e-spec.ts describe('Reporting Service (e2e)', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api/v1'); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); // 注意: TransformInterceptor 已在 AppModule 中注册 // 不要重复注册,避免响应双重包装 await app.init(); }); afterAll(async () => { await app.close(); }); describe('Health Check', () => { it('/api/v1/health (GET) should return ok status', () => { return request(app.getHttpServer()) .get('/api/v1/health') .expect(200) .expect((res) => { expect(res.body.success).toBe(true); expect(res.body.data.status).toBe('ok'); expect(res.body.data.service).toBe('reporting-service'); }); }); }); describe('Reports API', () => { describe('POST /api/v1/reports/generate', () => { it('should validate request body', () => { return request(app.getHttpServer()) .post('/api/v1/reports/generate') .send({}) .expect(400); }); it('should reject invalid report period', () => { return request(app.getHttpServer()) .post('/api/v1/reports/generate') .send({ reportCode: 'RPT_LEADERBOARD', reportPeriod: 'INVALID_PERIOD', startDate: '2024-01-01', endDate: '2024-01-31', }) .expect(400); }); }); }); describe('Export API', () => { describe('POST /api/v1/export', () => { it('should validate request body', () => { return request(app.getHttpServer()) .post('/api/v1/export') .send({}) .expect(400); }); it('should reject invalid format', () => { return request(app.getHttpServer()) .post('/api/v1/export') .send({ snapshotId: '1', format: 'INVALID_FORMAT', }) .expect(400); }); }); }); }); ``` ### 4.5 运行 E2E 测试 ```bash # 确保测试数据库运行 docker compose -f docker-compose.test.yml up -d postgres-test redis-test # 运行 E2E 测试 npm run test:e2e # 或使用 Makefile make test-e2e ``` ## 5. Docker 测试 ### 5.1 测试架构 ``` ┌─────────────────────────────────────────────────────────┐ │ Docker Network │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ PostgreSQL │ │ Redis │ │ Service │ │ │ │ :5432 │ │ :6379 │ │ Test │ │ │ │ (tmpfs) │ │ │ │ Container │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` ### 5.2 Docker Compose 配置 ```yaml # docker-compose.test.yml version: '3.8' services: postgres-test: image: postgres:15-alpine container_name: reporting-postgres-test environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rwadurian_reporting_test ports: - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 tmpfs: - /var/lib/postgresql/data # 使用内存存储加速测试 redis-test: image: redis:7-alpine container_name: reporting-redis-test ports: - "6380:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 5 reporting-service-test: build: context: . dockerfile: Dockerfile.test container_name: reporting-service-test depends_on: postgres-test: condition: service_healthy redis-test: condition: service_healthy environment: NODE_ENV: test DATABASE_URL: postgresql://postgres:postgres@postgres-test:5432/rwadurian_reporting_test?schema=public REDIS_HOST: redis-test REDIS_PORT: 6379 JWT_SECRET: test-secret-key volumes: - ./coverage:/app/coverage command: sh -c "npx prisma db push --skip-generate && npm run test:cov" ``` ### 5.3 测试 Dockerfile ```dockerfile # Dockerfile.test FROM node:20-alpine WORKDIR /app # 安装 OpenSSL (Prisma 依赖) RUN apk add --no-cache openssl openssl-dev # 安装依赖 COPY package*.json ./ RUN npm ci # 复制 Prisma Schema COPY prisma ./prisma/ # 生成 Prisma Client RUN npx prisma generate # 复制源代码 COPY . . # 构建应用 RUN npm run build # 默认运行测试 CMD ["npm", "test"] ``` ### 5.4 .dockerignore ``` node_modules dist coverage .git .env* !.env.example *.log .claude/ ``` ### 5.5 运行 Docker 测试 ```bash # 构建并运行所有测试 docker compose -f docker-compose.test.yml up --build --abort-on-container-exit # 查看测试结果 docker compose -f docker-compose.test.yml logs reporting-service-test # 清理 docker compose -f docker-compose.test.yml down -v # 使用 Makefile make test-docker-all ``` ## 6. Makefile 命令 ```makefile # Makefile .PHONY: test test-unit test-integration test-e2e test-docker-all # 运行所有测试 test: test-unit test-integration test-e2e # 单元测试 test-unit: npm test # 集成测试 test-integration: npm run test:integration # E2E 测试 test-e2e: npm run test:e2e # Docker 中运行所有测试 test-docker-all: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit docker compose -f docker-compose.test.yml down -v # 测试覆盖率 test-cov: npm run test:cov # 清理测试容器 test-clean: docker compose -f docker-compose.test.yml down -v ``` ## 7. 测试最佳实践 ### 7.1 测试命名规范 ```typescript describe('ClassName', () => { describe('methodName', () => { it('should [expected behavior] when [condition]', () => { // test implementation }); }); }); ``` ### 7.2 AAA 模式 ```typescript it('should create valid date range', () => { // Arrange - 准备测试数据 const startDate = new Date('2024-01-01'); const endDate = new Date('2024-01-31'); // Act - 执行被测试的操作 const dateRange = DateRange.create(startDate, endDate); // Assert - 验证结果 expect(dateRange.startDate).toEqual(startDate); expect(dateRange.endDate).toEqual(endDate); }); ``` ### 7.3 测试隔离 ```typescript describe('Repository Integration Tests', () => { beforeEach(async () => { // 每个测试前清理数据 await prismaService.reportSnapshot.deleteMany(); await prismaService.reportDefinition.deleteMany(); }); afterAll(async () => { // 测试结束后断开连接 await prismaService.$disconnect(); }); }); ``` ### 7.4 Mock 使用 ```typescript // 使用 Jest mock const mockRepository = { findByCode: jest.fn(), save: jest.fn(), findAll: jest.fn(), }; beforeEach(() => { jest.clearAllMocks(); }); it('should call repository with correct parameters', async () => { mockRepository.findByCode.mockResolvedValue(mockDefinition); await service.getReportDefinition('RPT_TEST'); expect(mockRepository.findByCode).toHaveBeenCalledWith('RPT_TEST'); }); ``` ## 8. CI/CD 集成 ### 8.1 GitHub Actions ```yaml # .github/workflows/test.yml name: Test on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rwadurian_reporting_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7-alpine ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --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: Push database schema run: npx prisma db push env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public - name: Run unit tests run: npm test - name: Run integration tests run: npm run test:integration env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public REDIS_HOST: localhost REDIS_PORT: 6379 - name: Run E2E tests run: npm run test:e2e env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public REDIS_HOST: localhost REDIS_PORT: 6379 - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./coverage/lcov.info ``` ## 9. 故障排除 ### 9.1 常见问题 **Q: 测试找不到模块** ```bash # 重新生成 Prisma Client npx prisma generate ``` **Q: 数据库连接失败** ```bash # 检查容器状态 docker ps # 检查数据库日志 docker logs reporting-postgres-test ``` **Q: E2E 测试响应格式错误** ```typescript // 确保不要重复注册 TransformInterceptor // AppModule 中已注册,测试中不需要再次注册 ``` **Q: Docker 测试 Prisma 错误** ```dockerfile # 确保 Dockerfile.test 包含 OpenSSL RUN apk add --no-cache openssl openssl-dev ``` ### 9.2 调试测试 ```bash # 运行单个测试文件 npm test -- --testPathPattern=date-range.spec.ts # 详细输出 npm test -- --verbose # 调试模式 node --inspect-brk node_modules/.bin/jest --runInBand ```