888 lines
24 KiB
Markdown
888 lines
24 KiB
Markdown
# 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,
|
||
});
|
||
}
|
||
```
|