24 KiB
24 KiB
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 配置
// 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 测试原则
- 隔离性: 不依赖外部服务(数据库、Redis、网络)
- 快速性: 毫秒级执行
- 可重复性: 每次执行结果一致
- 自包含: 不依赖其他测试的状态
3.3 示例:值对象测试
// 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 示例:领域服务测试
// 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 示例:聚合根测试
// 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 测试
// 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 测试
// 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 测试需要真实的基础设施:
# 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 测试设置
// 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 测试
// 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 命令行运行
# 运行所有测试
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 命令
# 启动测试基础设施
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 中运行
# 进入 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 生成报告
npm run test:cov
7.2 覆盖率阈值
// 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
# .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 命名规范
describe('ClassName/FunctionName', () => {
describe('methodName', () => {
it('should <expected behavior> when <condition>', () => {
// Arrange - Given
// Act - When
// Assert - Then
});
});
});
9.2 AAA 模式
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 避免测试反模式
// ❌ 测试实现细节
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 测试数据工厂
// 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,
});
}