22 KiB
22 KiB
测试指南
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 值对象测试示例
// 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 聚合根测试示例
// 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 运行单元测试
# 运行所有单元测试
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 数据库集成测试
// 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 应用服务集成测试
// 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.test
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public
REDIS_HOST=localhost
REDIS_PORT=6380
3.6 运行集成测试
# 启动测试依赖
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 测试配置
// test/jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"testTimeout": 30000,
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFilesAfterEnv": ["<rootDir>/setup-e2e.ts"],
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/../src/$1",
"^@domain/(.*)$": "<rootDir>/../src/domain/$1",
"^@application/(.*)$": "<rootDir>/../src/application/$1",
"^@infrastructure/(.*)$": "<rootDir>/../src/infrastructure/$1",
"^@api/(.*)$": "<rootDir>/../src/api/$1"
}
}
// 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 测试示例
// 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 测试
# 确保测试数据库运行
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 配置
# 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.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 测试
# 构建并运行所有测试
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
.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 测试命名规范
describe('ClassName', () => {
describe('methodName', () => {
it('should [expected behavior] when [condition]', () => {
// test implementation
});
});
});
7.2 AAA 模式
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 测试隔离
describe('Repository Integration Tests', () => {
beforeEach(async () => {
// 每个测试前清理数据
await prismaService.reportSnapshot.deleteMany();
await prismaService.reportDefinition.deleteMany();
});
afterAll(async () => {
// 测试结束后断开连接
await prismaService.$disconnect();
});
});
7.4 Mock 使用
// 使用 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
# .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: 测试找不到模块
# 重新生成 Prisma Client
npx prisma generate
Q: 数据库连接失败
# 检查容器状态
docker ps
# 检查数据库日志
docker logs reporting-postgres-test
Q: E2E 测试响应格式错误
// 确保不要重复注册 TransformInterceptor
// AppModule 中已注册,测试中不需要再次注册
Q: Docker 测试 Prisma 错误
# 确保 Dockerfile.test 包含 OpenSSL
RUN apk add --no-cache openssl openssl-dev
9.2 调试测试
# 运行单个测试文件
npm test -- --testPathPattern=date-range.spec.ts
# 详细输出
npm test -- --verbose
# 调试模式
node --inspect-brk node_modules/.bin/jest --runInBand