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

888 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
module.exports = {
...baseConfig,
collectCoverageFrom: ['src/**/*.(t|j)s', '!src/main.ts', '!src/**/*.module.ts'],
coverageDirectory: './coverage',
projects: [
{
...baseConfig,
displayName: 'unit',
testMatch: ['<rootDir>/test/unit/**/*.spec.ts'],
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
},
{
...baseConfig,
displayName: 'integration',
testMatch: ['<rootDir>/test/integration/**/*.spec.ts'],
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
},
{
...baseConfig,
displayName: 'e2e',
testMatch: ['<rootDir>/test/e2e/**/*.(spec|e2e-spec).ts'],
setupFilesAfterEnv: ['<rootDir>/test/setup.ts', '<rootDir>/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<PresenceRedisRepository>;
let mockKafkaPublisher: jest.Mocked<KafkaEventPublisher>;
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 <expected behavior> when <condition>', () => {
// 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<EventLogProps> = {}): 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,
});
}
```