# Presence Service 测试文档 ## 1. 测试策略概述 本服务采用**测试金字塔**策略,包含三个层次的自动化测试: ``` ┌───────────────┐ │ E2E 测试 │ ← 少量,验证完整流程 │ (20 tests) │ ├───────────────┤ │ 集成测试 │ ← 适量,验证模块协作 │ (22 tests) │ ├───────────────┤ │ 单元测试 │ ← 大量,验证核心逻辑 │ (123 tests) │ └───────────────┘ 测试金字塔 ``` ### 测试统计 | 测试类型 | 套件数 | 用例数 | 覆盖范围 | |---------|-------|-------|---------| | 单元测试 | 9 | 123 | 领域层 | | 集成测试 | 3 | 22 | 应用层 | | E2E 测试 | 3 | 20 | API 层 | | **总计** | **15** | **165** | - | --- ## 2. 测试架构 ### 2.1 目录结构 ``` test/ ├── setup.ts # 全局测试设置 ├── unit/ # 单元测试 │ ├── domain/ │ │ ├── entities/ │ │ │ ├── event-log.entity.spec.ts │ │ │ └── online-snapshot.entity.spec.ts │ │ ├── aggregates/ │ │ │ └── daily-active-stats.aggregate.spec.ts │ │ ├── value-objects/ │ │ │ ├── install-id.vo.spec.ts │ │ │ ├── event-name.vo.spec.ts │ │ │ └── time-window.vo.spec.ts │ │ └── services/ │ │ ├── online-detection.service.spec.ts │ │ └── dau-calculation.service.spec.ts │ └── shared/ │ └── filters/ │ └── global-exception.filter.spec.ts ├── integration/ # 集成测试 │ └── application/ │ ├── commands/ │ │ └── record-heartbeat.handler.spec.ts │ └── queries/ │ ├── get-online-count.handler.spec.ts │ └── get-online-history.handler.spec.ts └── e2e/ # E2E 测试 ├── setup-e2e.ts # E2E 测试设置 ├── health.e2e-spec.ts ├── presence.e2e-spec.ts └── analytics.e2e-spec.ts ``` ### 2.2 Jest 配置 ```javascript // jest.config.js const baseConfig = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: '.', transform: { '^.+\\.(t|j)s$': 'ts-jest', }, testEnvironment: 'node', moduleNameMapper: { '^@/(.*)$': '/src/$1', }, }; module.exports = { ...baseConfig, collectCoverageFrom: ['src/**/*.(t|j)s', '!src/main.ts', '!src/**/*.module.ts'], coverageDirectory: './coverage', projects: [ { ...baseConfig, displayName: 'unit', testMatch: ['/test/unit/**/*.spec.ts'], setupFilesAfterEnv: ['/test/setup.ts'], }, { ...baseConfig, displayName: 'integration', testMatch: ['/test/integration/**/*.spec.ts'], setupFilesAfterEnv: ['/test/setup.ts'], }, { ...baseConfig, displayName: 'e2e', testMatch: ['/test/e2e/**/*.(spec|e2e-spec).ts'], setupFilesAfterEnv: ['/test/setup.ts', '/test/e2e/setup-e2e.ts'], }, ], }; ``` --- ## 3. 单元测试 ### 3.1 测试目标 单元测试覆盖**领域层**的核心业务逻辑: - 值对象的创建和校验 - 实体的行为和状态变化 - 聚合根的业务规则 - 领域服务的计算逻辑 ### 3.2 测试原则 1. **隔离性**: 不依赖外部服务(数据库、Redis、网络) 2. **快速性**: 毫秒级执行 3. **可重复性**: 每次执行结果一致 4. **自包含**: 不依赖其他测试的状态 ### 3.3 示例:值对象测试 ```typescript // test/unit/domain/value-objects/install-id.vo.spec.ts import { InstallId } from '@/domain/value-objects/install-id.vo'; describe('InstallId', () => { describe('fromString', () => { it('should create InstallId with valid value', () => { const installId = InstallId.fromString('valid-install-id-123'); expect(installId.value).toBe('valid-install-id-123'); }); it('should throw error for empty value', () => { expect(() => InstallId.fromString('')).toThrow(); }); it('should throw error for value shorter than 8 characters', () => { expect(() => InstallId.fromString('short')).toThrow(); }); it('should throw error for value longer than 64 characters', () => { const longValue = 'a'.repeat(65); expect(() => InstallId.fromString(longValue)).toThrow(); }); it('should throw error for value with invalid characters', () => { expect(() => InstallId.fromString('invalid@id#123')).toThrow(); }); }); describe('equals', () => { it('should return true for same value', () => { const id1 = InstallId.fromString('test-install-id'); const id2 = InstallId.fromString('test-install-id'); expect(id1.equals(id2)).toBe(true); }); it('should return false for different values', () => { const id1 = InstallId.fromString('test-install-id-1'); const id2 = InstallId.fromString('test-install-id-2'); expect(id1.equals(id2)).toBe(false); }); }); }); ``` ### 3.4 示例:领域服务测试 ```typescript // test/unit/domain/services/online-detection.service.spec.ts import { OnlineDetectionService } from '@/domain/services/online-detection.service'; describe('OnlineDetectionService', () => { let service: OnlineDetectionService; beforeEach(() => { service = new OnlineDetectionService(); }); describe('isOnline', () => { it('should return true when heartbeat is within window', () => { const now = Date.now(); const lastHeartbeat = new Date(now - 60 * 1000); // 1 minute ago const windowSeconds = 180; // 3 minutes expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(true); }); it('should return false when heartbeat is outside window', () => { const now = Date.now(); const lastHeartbeat = new Date(now - 200 * 1000); // 200 seconds ago const windowSeconds = 180; // 3 minutes expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(false); }); it('should return true for heartbeat exactly at window boundary', () => { const now = Date.now(); const lastHeartbeat = new Date(now - 180 * 1000); // exactly 3 minutes ago const windowSeconds = 180; expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(true); }); }); describe('calculateThresholdTime', () => { it('should calculate correct threshold time', () => { jest.useFakeTimers(); const now = new Date('2025-01-01T12:00:00Z'); jest.setSystemTime(now); const threshold = service.calculateThresholdTime(180); expect(threshold.getTime()).toBe(now.getTime() - 180 * 1000); jest.useRealTimers(); }); }); }); ``` ### 3.5 示例:聚合根测试 ```typescript // test/unit/domain/aggregates/daily-active-stats.aggregate.spec.ts import { DailyActiveStats } from '@/domain/aggregates/daily-active-stats/daily-active-stats.aggregate'; describe('DailyActiveStats', () => { describe('create', () => { it('should create new stats with initial values', () => { const day = new Date('2025-01-01'); const stats = DailyActiveStats.create(day); expect(stats.day).toEqual(day); expect(stats.dauCount).toBe(0); expect(stats.version).toBe(1); }); }); describe('updateStats', () => { it('should update dau count and increment version', () => { const stats = DailyActiveStats.create(new Date('2025-01-01')); const byProvince = new Map([['广东省', 1000]]); const byCity = new Map([['深圳市', 500]]); stats.updateStats(5000, byProvince, byCity); expect(stats.dauCount).toBe(5000); expect(stats.dauByProvince.get('广东省')).toBe(1000); expect(stats.dauByCity.get('深圳市')).toBe(500); expect(stats.version).toBe(2); }); it('should throw error for negative count', () => { const stats = DailyActiveStats.create(new Date('2025-01-01')); expect(() => stats.updateStats(-1, new Map(), new Map())).toThrow(); }); }); describe('reconstitute', () => { it('should reconstitute from persistence data', () => { const data = { day: new Date('2025-01-01'), dauCount: 5000, dauByProvince: new Map([['广东省', 1000]]), dauByCity: new Map([['深圳市', 500]]), calculatedAt: new Date(), version: 3, }; const stats = DailyActiveStats.reconstitute(data); expect(stats.dauCount).toBe(5000); expect(stats.version).toBe(3); }); }); }); ``` --- ## 4. 集成测试 ### 4.1 测试目标 集成测试验证**应用层**的 Command/Query Handler: - Handler 与 Mock 仓储的协作 - 业务流程的正确性 - 事务边界 ### 4.2 测试策略 - Mock 外部依赖(仓储、Redis、Kafka) - 使用 NestJS Testing Module - 验证 Handler 的输入输出 ### 4.3 示例:Command Handler 测试 ```typescript // test/integration/application/commands/record-heartbeat.handler.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { RecordHeartbeatHandler } from '@/application/commands/record-heartbeat/record-heartbeat.handler'; import { RecordHeartbeatCommand } from '@/application/commands/record-heartbeat/record-heartbeat.command'; import { PresenceRedisRepository } from '@/infrastructure/redis/presence-redis.repository'; import { KafkaEventPublisher } from '@/infrastructure/kafka/kafka-event.publisher'; describe('RecordHeartbeatHandler', () => { let handler: RecordHeartbeatHandler; let mockRedisRepo: jest.Mocked; let mockKafkaPublisher: jest.Mocked; beforeEach(async () => { mockRedisRepo = { updateUserPresence: jest.fn(), } as any; mockKafkaPublisher = { publish: jest.fn(), } as any; const module: TestingModule = await Test.createTestingModule({ providers: [ RecordHeartbeatHandler, { provide: PresenceRedisRepository, useValue: mockRedisRepo }, { provide: KafkaEventPublisher, useValue: mockKafkaPublisher }, ], }).compile(); handler = module.get(RecordHeartbeatHandler); }); describe('execute', () => { it('should update presence and publish event', async () => { const command = new RecordHeartbeatCommand( BigInt(12345), 'test-install-id', '1.0.0', Date.now(), ); await handler.execute(command); expect(mockRedisRepo.updateUserPresence).toHaveBeenCalledWith( '12345', expect.any(Number), ); expect(mockKafkaPublisher.publish).toHaveBeenCalled(); }); it('should handle missing userId gracefully', async () => { const command = new RecordHeartbeatCommand( undefined, 'test-install-id', '1.0.0', Date.now(), ); await handler.execute(command); expect(mockRedisRepo.updateUserPresence).toHaveBeenCalledWith( 'test-install-id', // fallback to installId expect.any(Number), ); }); }); }); ``` ### 4.4 示例:Query Handler 测试 ```typescript // test/integration/application/queries/get-online-history.handler.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { GetOnlineHistoryHandler } from '@/application/queries/get-online-history/get-online-history.handler'; import { GetOnlineHistoryQuery } from '@/application/queries/get-online-history/get-online-history.query'; import { ONLINE_SNAPSHOT_REPOSITORY } from '@/domain/repositories/online-snapshot.repository.interface'; import { OnlineSnapshot } from '@/domain/entities/online-snapshot.entity'; describe('GetOnlineHistoryHandler', () => { let handler: GetOnlineHistoryHandler; let mockRepo: any; beforeEach(async () => { mockRepo = { findByTimeRange: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ GetOnlineHistoryHandler, { provide: ONLINE_SNAPSHOT_REPOSITORY, useValue: mockRepo }, ], }).compile(); handler = module.get(GetOnlineHistoryHandler); }); describe('execute', () => { it('should return online history with summary', async () => { const snapshots = [ createSnapshot(new Date('2025-01-01T12:00:00Z'), 1000), createSnapshot(new Date('2025-01-01T12:05:00Z'), 1200), createSnapshot(new Date('2025-01-01T12:10:00Z'), 1100), ]; mockRepo.findByTimeRange.mockResolvedValue(snapshots); const query = new GetOnlineHistoryQuery( new Date('2025-01-01T12:00:00Z'), new Date('2025-01-01T13:00:00Z'), '5m', ); const result = await handler.execute(query); expect(result.data).toHaveLength(3); expect(result.summary.max).toBe(1200); expect(result.summary.min).toBe(1000); expect(result.summary.avg).toBe(1100); }); it('should return empty result for no data', async () => { mockRepo.findByTimeRange.mockResolvedValue([]); const query = new GetOnlineHistoryQuery( new Date('2025-01-01T12:00:00Z'), new Date('2025-01-01T13:00:00Z'), '5m', ); const result = await handler.execute(query); expect(result.data).toHaveLength(0); expect(result.total).toBe(0); }); }); }); function createSnapshot(ts: Date, onlineCount: number): OnlineSnapshot { return OnlineSnapshot.reconstitute({ id: BigInt(Math.floor(Math.random() * 1000000)), ts, onlineCount, windowSeconds: 180, }); } ``` --- ## 5. E2E 测试 ### 5.1 测试目标 E2E 测试验证**完整的 API 流程**: - HTTP 请求/响应 - 认证和授权 - 参数校验 - 数据库交互 ### 5.2 测试环境 E2E 测试需要真实的基础设施: ```yaml # docker-compose.test.yml services: postgres-test: image: postgres:15-alpine environment: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: presence_test ports: - "5434:5432" redis-test: image: redis:7-alpine ports: - "6381:6379" ``` ### 5.3 测试设置 ```typescript // test/e2e/setup-e2e.ts beforeAll(async () => { if (!process.env.DATABASE_URL) { console.warn('WARNING: DATABASE_URL not set.'); } if (!process.env.REDIS_HOST) { console.warn('WARNING: REDIS_HOST not set.'); } }); ``` ### 5.4 示例:API 测试 ```typescript // test/e2e/presence.e2e-spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; import { GlobalExceptionFilter } from '../../src/shared/filters/global-exception.filter'; import { JwtAuthGuard } from '../../src/shared/guards/jwt-auth.guard'; describe('Presence API (E2E)', () => { let app: INestApplication; const mockUserId = BigInt(12345); beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: (context: ExecutionContext) => { const req = context.switchToHttp().getRequest(); req.user = { userId: mockUserId.toString() }; return true; }, }) .compile(); app = moduleFixture.createNestApplication(); app.useGlobalFilters(new GlobalExceptionFilter()); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, })); app.setGlobalPrefix('api/v1'); await app.init(); }); afterAll(async () => { await app.close(); }); describe('POST /api/v1/presence/heartbeat', () => { it('should record heartbeat successfully', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/presence/heartbeat') .send({ installId: 'test-install-id-12345', appVersion: '1.0.0', clientTs: Date.now(), }) .expect(201); expect(response.body).toHaveProperty('ok', true); expect(response.body).toHaveProperty('serverTs'); }); it('should validate installId type', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/presence/heartbeat') .send({ installId: 12345, // Invalid: not a string appVersion: '1.0.0', clientTs: Date.now(), }) .expect(400); expect(response.body.statusCode).toBe(400); }); }); describe('GET /api/v1/presence/online-count', () => { it('should return online count', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/presence/online-count') .expect(200); expect(response.body).toHaveProperty('count'); expect(typeof response.body.count).toBe('number'); expect(response.body).toHaveProperty('windowSeconds', 180); }); }); describe('GET /api/v1/presence/online-history', () => { it('should return online history', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/presence/online-history') .query({ startTime: new Date(Date.now() - 3600000).toISOString(), endTime: new Date().toISOString(), interval: '5m', }) .expect(200); expect(response.body).toHaveProperty('data'); expect(Array.isArray(response.body.data)).toBe(true); expect(response.body).toHaveProperty('interval', '5m'); }); it('should validate interval enum', async () => { await request(app.getHttpServer()) .get('/api/v1/presence/online-history') .query({ startTime: new Date(Date.now() - 3600000).toISOString(), endTime: new Date().toISOString(), interval: '10m', // Invalid }) .expect(400); }); }); }); ``` --- ## 6. 运行测试 ### 6.1 命令行运行 ```bash # 运行所有测试 npm test # 运行单元测试 npm run test:unit # 运行集成测试 npm run test:integration # 运行 E2E 测试 (需要 Docker) npm run test:e2e # 生成覆盖率报告 npm run test:cov # 监视模式 npm run test:watch # 运行特定测试文件 npm test -- --testPathPattern="install-id.vo.spec.ts" ``` ### 6.2 使用 Make 命令 ```bash # 启动测试基础设施 make test-docker-up # 运行单元测试 make test-unit # 运行集成测试 make test-integration # 运行 E2E 测试 make test-e2e # 运行所有测试 make test-all # 停止测试基础设施 make test-docker-down # Docker 内运行全部测试 make test-docker-all ``` ### 6.3 在 WSL2 中运行 ```bash # 进入 WSL2 wsl -d Ubuntu # 设置环境变量并运行 export DATABASE_URL='postgresql://test:test@localhost:5434/presence_test' export REDIS_HOST='localhost' export REDIS_PORT='6381' npm test ``` --- ## 7. 覆盖率报告 ### 7.1 生成报告 ```bash npm run test:cov ``` ### 7.2 覆盖率阈值 ```javascript // package.json { "jest": { "coverageThreshold": { "global": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 } } } } ``` ### 7.3 查看报告 覆盖率报告生成在 `coverage/` 目录: - `coverage/lcov-report/index.html` - HTML 报告 - `coverage/lcov.info` - LCOV 格式 --- ## 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: test POSTGRES_PASSWORD: test POSTGRES_DB: presence_test ports: - 5434:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7-alpine ports: - 6381: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://test:test@localhost:5434/presence_test - name: Run unit tests run: npm run test:unit - name: Run integration tests run: npm run test:integration - name: Run E2E tests run: npm run test:e2e env: DATABASE_URL: postgresql://test:test@localhost:5434/presence_test REDIS_HOST: localhost REDIS_PORT: 6381 - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./coverage/lcov.info ``` --- ## 9. 测试最佳实践 ### 9.1 命名规范 ```typescript describe('ClassName/FunctionName', () => { describe('methodName', () => { it('should when ', () => { // Arrange - Given // Act - When // Assert - Then }); }); }); ``` ### 9.2 AAA 模式 ```typescript it('should calculate correct DAU count', () => { // Arrange (Given) const events = [createEvent('user1'), createEvent('user2')]; const service = new DauCalculationService(); // Act (When) const result = service.calculateDau(events); // Assert (Then) expect(result.total).toBe(2); }); ``` ### 9.3 避免测试反模式 ```typescript // ❌ 测试实现细节 it('should call repository.save once', () => { expect(mockRepo.save).toHaveBeenCalledTimes(1); }); // ✅ 测试行为 it('should persist the event', async () => { await handler.execute(command); const saved = await mockRepo.findById(eventId); expect(saved).toBeDefined(); }); // ❌ 过度 Mock it('should return mocked value', () => { mockService.calculate.mockReturnValue(100); expect(service.calculate()).toBe(100); // 测试的是 Mock,不是代码 }); // ✅ 测试真实逻辑 it('should calculate correct value', () => { const service = new CalculationService(); expect(service.calculate(10, 20)).toBe(30); }); ``` ### 9.4 测试数据工厂 ```typescript // test/factories/event-log.factory.ts export function createEventLog(overrides: Partial = {}): EventLog { return EventLog.create({ userId: BigInt(12345), installId: InstallId.fromString('test-install-id'), eventName: EventName.fromString('app_session_start'), eventTime: new Date(), properties: EventProperties.fromData({}), ...overrides, }); } ```