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

1082 lines
28 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.

# MPC Party Service 测试文档
## 概述
本文档详细说明 MPC Party Service 的测试架构、测试策略和实现方式。
## 测试金字塔
```
┌─────────┐
│ E2E │ ← 端到端测试 (15 tests)
┌┴─────────┴┐
│Integration │ ← 集成测试 (30 tests)
┌┴───────────┴┐
│ Unit Tests │ ← 单元测试 (81 tests)
└───────────────┘
总计: 111+ 测试用例
```
## 测试分类
### 1. 单元测试 (Unit Tests)
测试单个组件的隔离行为,不依赖外部服务。
**目录**: `tests/unit/`
**运行命令**:
```bash
npm run test:unit
```
**覆盖范围**:
- 领域实体 (Domain Entities)
- 值对象 (Value Objects)
- 领域服务 (Domain Services)
- 应用层处理器 (Application Handlers)
- 数据映射器 (Data Mappers)
### 2. 集成测试 (Integration Tests)
测试多个组件之间的交互,使用模拟的外部依赖。
**目录**: `tests/integration/`
**运行命令**:
```bash
npm run test:integration
```
**覆盖范围**:
- 仓储实现 (Repository Implementations)
- 控制器 (Controllers)
- 事件发布器 (Event Publishers)
### 3. 端到端测试 (E2E Tests)
测试完整的 API 流程,模拟真实的客户端请求。
**目录**: `tests/e2e/`
**运行命令**:
```bash
npm run test:e2e
```
**覆盖范围**:
- API 端点
- 认证流程
- 错误处理
- 完整的请求/响应周期
---
## 测试配置
### Jest 配置文件
#### 基础配置 (`tests/jest.config.js`)
```javascript
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '..',
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.module.ts',
'!src/main.ts',
],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/tests/unit/**/*.spec.ts',
'<rootDir>/tests/integration/**/*.spec.ts',
],
};
```
#### 单元测试配置 (`tests/jest-unit.config.js`)
```javascript
const baseConfig = require('./jest.config');
module.exports = {
...baseConfig,
testMatch: ['<rootDir>/tests/unit/**/*.spec.ts'],
testTimeout: 30000,
};
```
#### 集成测试配置 (`tests/jest-integration.config.js`)
```javascript
const baseConfig = require('./jest.config');
module.exports = {
...baseConfig,
testMatch: ['<rootDir>/tests/integration/**/*.spec.ts'],
testTimeout: 60000,
};
```
#### E2E 测试配置 (`tests/jest-e2e.config.js`)
```javascript
const baseConfig = require('./jest.config');
module.exports = {
...baseConfig,
testMatch: ['<rootDir>/tests/e2e/**/*.e2e-spec.ts'],
testTimeout: 120000,
maxWorkers: 1, // 顺序执行
};
```
### 测试环境设置 (`tests/setup.ts`)
```typescript
export {};
// 设置测试环境变量
process.env.NODE_ENV = 'test';
process.env.APP_PORT = '3006';
process.env.DATABASE_URL = 'mysql://test:test@localhost:3306/test_db';
process.env.REDIS_HOST = 'localhost';
process.env.REDIS_PORT = '6379';
process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing-only';
process.env.SHARE_MASTER_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
process.env.MPC_PARTY_ID = 'party-test-1';
process.env.KAFKA_ENABLED = 'false';
// 增加异步操作超时
jest.setTimeout(30000);
// 自定义匹配器
expect.extend({
toBeValidUUID(received: string) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const pass = uuidRegex.test(received);
return {
message: () => pass
? `expected ${received} not to be a valid UUID`
: `expected ${received} to be a valid UUID`,
pass,
};
},
toBeValidHex(received: string, expectedLength?: number) {
const hexRegex = /^[0-9a-f]+$/i;
const isHex = hexRegex.test(received);
const lengthMatch = expectedLength === undefined || received.length === expectedLength;
return {
message: () => `expected ${received} to be valid hex`,
pass: isHex && lengthMatch,
};
},
toBeValidPublicKey(received: string) {
const hexRegex = /^[0-9a-f]+$/i;
const isHex = hexRegex.test(received);
const isValidLength = received.length === 66 || received.length === 130;
return {
message: () => `expected ${received} to be a valid public key`,
pass: isHex && isValidLength,
};
},
});
```
---
## 单元测试详解
### 领域实体测试
#### PartyShare 实体 (`tests/unit/domain/party-share.entity.spec.ts`)
```typescript
describe('PartyShare Entity', () => {
const createTestShareData = () => ShareData.create(
Buffer.from('encrypted-share-data'),
Buffer.from('123456789012'), // 12 bytes IV
Buffer.from('1234567890123456'), // 16 bytes authTag
);
describe('create', () => {
it('should create a valid party share', () => {
const share = PartyShare.create({
partyId: PartyId.create('user123-server'),
sessionId: SessionId.generate(),
shareType: PartyShareType.WALLET,
shareData: createTestShareData(),
publicKey: PublicKey.fromHex('03' + '0'.repeat(64)),
threshold: Threshold.create(3, 2),
});
expect(share).toBeDefined();
expect(share.id).toBeDefined();
expect(share.status).toBe(PartyShareStatus.ACTIVE);
});
it('should emit ShareCreatedEvent', () => {
const share = PartyShare.create({/*...*/});
const events = share.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(ShareCreatedEvent);
});
});
describe('markAsUsed', () => {
it('should update lastUsedAt timestamp', () => {
const share = PartyShare.create({/*...*/});
const before = share.lastUsedAt;
share.markAsUsed();
expect(share.lastUsedAt).toBeDefined();
expect(share.lastUsedAt).not.toBe(before);
});
it('should throw error when share is not active', () => {
const share = PartyShare.create({/*...*/});
share.revoke('test reason');
expect(() => share.markAsUsed()).toThrow(DomainError);
});
});
describe('rotate', () => {
it('should create new share with rotated status for old share', () => {
const oldShare = PartyShare.create({/*...*/});
const newShareData = createTestShareData();
const newShare = oldShare.rotate(newShareData, SessionId.generate());
expect(oldShare.status).toBe(PartyShareStatus.ROTATED);
expect(newShare.status).toBe(PartyShareStatus.ACTIVE);
expect(newShare.publicKey.toHex()).toBe(oldShare.publicKey.toHex());
});
});
describe('revoke', () => {
it('should mark share as revoked', () => {
const share = PartyShare.create({/*...*/});
share.revoke('Security concern');
expect(share.status).toBe(PartyShareStatus.REVOKED);
});
});
});
```
### 值对象测试
#### Value Objects (`tests/unit/domain/value-objects.spec.ts`)
```typescript
describe('Value Objects', () => {
describe('SessionId', () => {
it('should create valid SessionId', () => {
const sessionId = SessionId.create('550e8400-e29b-41d4-a716-446655440000');
expect(sessionId.value).toBe('550e8400-e29b-41d4-a716-446655440000');
});
it('should generate unique SessionId', () => {
const id1 = SessionId.generate();
const id2 = SessionId.generate();
expect(id1.value).not.toBe(id2.value);
});
it('should throw error for invalid format', () => {
expect(() => SessionId.create('invalid')).toThrow();
});
});
describe('Threshold', () => {
it('should create valid threshold', () => {
const threshold = Threshold.create(3, 2);
expect(threshold.n).toBe(3);
expect(threshold.t).toBe(2);
});
it('should validate participants', () => {
const threshold = Threshold.create(3, 2);
expect(threshold.canSign(2)).toBe(true);
expect(threshold.canSign(1)).toBe(false);
});
it('should throw error for invalid values', () => {
expect(() => Threshold.create(2, 3)).toThrow(); // t > n
expect(() => Threshold.create(0, 1)).toThrow(); // n = 0
});
});
describe('ShareData', () => {
it('should serialize to JSON and back', () => {
const original = ShareData.create(
Buffer.from('data'),
Buffer.from('123456789012'),
Buffer.from('1234567890123456'),
);
const json = original.toJSON();
const restored = ShareData.fromJSON(json);
expect(restored.encryptedData).toEqual(original.encryptedData);
});
});
});
```
### 领域服务测试
#### ShareEncryptionDomainService (`tests/unit/domain/share-encryption.spec.ts`)
```typescript
describe('ShareEncryptionDomainService', () => {
let service: ShareEncryptionDomainService;
let masterKey: Buffer;
beforeEach(() => {
service = new ShareEncryptionDomainService();
masterKey = service.generateMasterKey();
});
describe('encrypt/decrypt', () => {
it('should encrypt and decrypt data correctly', () => {
const plaintext = Buffer.from('secret share data');
const encrypted = service.encrypt(plaintext, masterKey);
const decrypted = service.decrypt(encrypted, masterKey);
expect(decrypted).toEqual(plaintext);
});
it('should produce different ciphertexts for same plaintext', () => {
const plaintext = Buffer.from('secret share data');
const encrypted1 = service.encrypt(plaintext, masterKey);
const encrypted2 = service.encrypt(plaintext, masterKey);
expect(encrypted1.encryptedData).not.toEqual(encrypted2.encryptedData);
});
it('should fail decryption with wrong key', () => {
const plaintext = Buffer.from('secret share data');
const wrongKey = service.generateMasterKey();
const encrypted = service.encrypt(plaintext, masterKey);
expect(() => service.decrypt(encrypted, wrongKey)).toThrow();
});
it('should fail decryption with tampered ciphertext', () => {
const plaintext = Buffer.from('secret share data');
const encrypted = service.encrypt(plaintext, masterKey);
encrypted.encryptedData[0] ^= 0xFF; // 篡改数据
expect(() => service.decrypt(encrypted, masterKey)).toThrow();
});
});
describe('deriveKeyFromPassword', () => {
it('should derive consistent key from password', async () => {
const password = 'test-password';
const salt = Buffer.from('1234567890123456');
const key1 = await service.deriveKeyFromPassword(password, salt);
const key2 = await service.deriveKeyFromPassword(password, salt);
expect(key1).toEqual(key2);
});
});
});
```
### 应用层处理器测试
#### ParticipateInKeygenHandler (`tests/unit/application/participate-keygen.handler.spec.ts`)
```typescript
describe('ParticipateInKeygenHandler', () => {
let handler: ParticipateInKeygenHandler;
let mockPartyShareRepository: any;
let mockTssProtocolService: any;
let mockCoordinatorClient: any;
let mockEncryptionService: any;
let mockEventPublisher: any;
beforeEach(async () => {
// 创建模拟对象
mockPartyShareRepository = {
save: jest.fn(),
findById: jest.fn(),
};
mockTssProtocolService = {
runKeygen: jest.fn(),
};
mockCoordinatorClient = {
joinSession: jest.fn(),
reportCompletion: jest.fn(),
};
mockEncryptionService = {
encrypt: jest.fn(),
};
mockEventPublisher = {
publishAll: jest.fn(),
};
// 创建测试模块
const module: TestingModule = await Test.createTestingModule({
providers: [
ParticipateInKeygenHandler,
{ provide: PARTY_SHARE_REPOSITORY, useValue: mockPartyShareRepository },
{ provide: TSS_PROTOCOL_SERVICE, useValue: mockTssProtocolService },
{ provide: MPCCoordinatorClient, useValue: mockCoordinatorClient },
{ provide: ShareEncryptionDomainService, useValue: mockEncryptionService },
{ provide: EventPublisherService, useValue: mockEventPublisher },
// ... 其他依赖
],
}).compile();
handler = module.get<ParticipateInKeygenHandler>(ParticipateInKeygenHandler);
});
it('should be defined', () => {
expect(handler).toBeDefined();
});
describe('execute', () => {
it('should have properly constructed command', () => {
const command = new ParticipateInKeygenCommand(
'550e8400-e29b-41d4-a716-446655440000',
'user123-server',
'join-token-abc123',
PartyShareType.WALLET,
'user-id-123',
);
expect(command.sessionId).toBe('550e8400-e29b-41d4-a716-446655440000');
expect(command.partyId).toBe('user123-server');
expect(command.shareType).toBe(PartyShareType.WALLET);
});
});
describe('error handling', () => {
it('should handle coordinator connection failure', async () => {
mockCoordinatorClient.joinSession.mockRejectedValue(
new Error('Connection refused'),
);
expect(mockCoordinatorClient.joinSession).toBeDefined();
});
it('should handle TSS protocol errors', async () => {
mockTssProtocolService.runKeygen.mockRejectedValue(
new Error('TSS protocol failed'),
);
expect(mockTssProtocolService.runKeygen).toBeDefined();
});
});
});
```
---
## 集成测试详解
### 仓储集成测试
#### PartyShareRepository (`tests/integration/party-share.repository.spec.ts`)
```typescript
describe('PartyShareRepository (Integration)', () => {
let repository: PartyShareRepositoryImpl;
let prismaService: any;
let mapper: PartyShareMapper;
const createMockShare = (): PartyShare => {
return PartyShare.create({
partyId: PartyId.create('user123-server'),
sessionId: SessionId.generate(),
shareType: PartyShareType.WALLET,
shareData: ShareData.create(
Buffer.from('encrypted-test-share-data'),
Buffer.from('123456789012'),
Buffer.from('1234567890123456'),
),
publicKey: PublicKey.fromHex('03' + '0'.repeat(64)),
threshold: Threshold.create(3, 2),
});
};
beforeEach(async () => {
// 模拟 Prisma Service
prismaService = {
partyShare: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
};
mapper = new PartyShareMapper();
const module: TestingModule = await Test.createTestingModule({
providers: [
PartyShareRepositoryImpl,
{ provide: PrismaService, useValue: prismaService },
PartyShareMapper,
],
}).compile();
repository = module.get<PartyShareRepositoryImpl>(PartyShareRepositoryImpl);
});
describe('save', () => {
it('should save a new share', async () => {
const share = createMockShare();
prismaService.partyShare.create.mockResolvedValue({
...mapper.toPersistence(share),
id: share.id.value,
});
await repository.save(share);
expect(prismaService.partyShare.create).toHaveBeenCalled();
});
});
describe('findById', () => {
it('should return share when found', async () => {
const share = createMockShare();
const persistenceData = mapper.toPersistence(share);
prismaService.partyShare.findUnique.mockResolvedValue({
...persistenceData,
id: share.id.value,
});
const result = await repository.findById(share.id);
expect(result).toBeDefined();
expect(result).toBeInstanceOf(PartyShare);
});
it('should return null when not found', async () => {
prismaService.partyShare.findUnique.mockResolvedValue(null);
const result = await repository.findById(ShareId.generate());
expect(result).toBeNull();
});
});
describe('delete', () => {
it('should soft delete share by updating status to revoked', async () => {
const shareId = ShareId.generate();
prismaService.partyShare.update.mockResolvedValue({});
await repository.delete(shareId);
expect(prismaService.partyShare.update).toHaveBeenCalledWith({
where: { id: shareId.value },
data: {
status: PartyShareStatus.REVOKED,
updatedAt: expect.any(Date),
},
});
});
});
});
```
### 控制器集成测试
#### MPCPartyController (`tests/integration/mpc-party.controller.spec.ts`)
```typescript
describe('MPCPartyController (Integration)', () => {
let controller: MPCPartyController;
let mockApplicationService: any;
beforeEach(async () => {
mockApplicationService = {
participateInKeygen: jest.fn(),
participateInSigning: jest.fn(),
rotateShare: jest.fn(),
getShareInfo: jest.fn(),
listShares: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [MPCPartyController],
providers: [
{ provide: MPCPartyApplicationService, useValue: mockApplicationService },
{ provide: JwtService, useValue: { verifyAsync: jest.fn() } },
{ provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: Reflector, useValue: { getAllAndOverride: jest.fn().mockReturnValue(true) } },
],
}).compile();
controller = module.get<MPCPartyController>(MPCPartyController);
});
describe('health', () => {
it('should return health status', () => {
const result = controller.health();
expect(result.status).toBe('ok');
expect(result.service).toBe('mpc-party-service');
expect(result.timestamp).toBeDefined();
});
});
describe('participateInKeygen', () => {
it('should return accepted response for async keygen', async () => {
mockApplicationService.participateInKeygen.mockReturnValue(new Promise(() => {}));
const dto = {
sessionId: '550e8400-e29b-41d4-a716-446655440000',
partyId: 'user123-server',
joinToken: 'join-token-abc',
shareType: PartyShareType.WALLET,
userId: 'user-id-123',
};
const result = await controller.participateInKeygen(dto);
expect(result).toEqual({
message: 'Keygen participation started',
sessionId: dto.sessionId,
partyId: dto.partyId,
});
});
});
describe('listShares', () => {
it('should call application service listShares', async () => {
const mockResult = {
items: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
mockApplicationService.listShares.mockResolvedValue(mockResult);
const result = await controller.listShares({ partyId: 'user123-server', page: 1, limit: 10 });
expect(mockApplicationService.listShares).toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
});
});
```
---
## 端到端测试详解
### E2E 测试 (`tests/e2e/mpc-service.e2e-spec.ts`)
```typescript
describe('MPC Service E2E Tests', () => {
let app: INestApplication;
let prismaService: any;
let jwtService: JwtService;
let authToken: string;
// 模拟外部服务
let mockCoordinatorClient: any;
let mockMessageRouterClient: any;
let mockTssProtocolService: any;
let mockEventPublisher: any;
beforeAll(async () => {
// 设置模拟实现
mockCoordinatorClient = {
joinSession: jest.fn(),
reportCompletion: jest.fn(),
};
mockTssProtocolService = {
runKeygen: jest.fn(),
runSigning: jest.fn(),
runRefresh: jest.fn(),
};
mockEventPublisher = {
publish: jest.fn(),
publishAll: jest.fn(),
};
// 模拟 Prisma
prismaService = {
partyShare: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
// ...
};
// 创建测试应用
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useValue(prismaService)
.overrideProvider(MPCCoordinatorClient)
.useValue(mockCoordinatorClient)
.overrideProvider(TSS_PROTOCOL_SERVICE)
.useValue(mockTssProtocolService)
.overrideProvider(EventPublisherService)
.useValue(mockEventPublisher)
.compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}));
await app.init();
jwtService = moduleFixture.get<JwtService>(JwtService);
// 生成测试令牌
authToken = jwtService.sign({
sub: 'test-user-id',
type: 'access',
partyId: 'user123-server',
});
});
afterAll(async () => {
await app.close();
});
describe('Health Check', () => {
it('GET /api/v1/mpc-party/health - should return healthy status', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/mpc-party/health')
.expect(200);
const body = response.body.data || response.body;
expect(body.status).toBe('ok');
expect(body.service).toBe('mpc-party-service');
});
});
describe('Keygen Flow', () => {
it('POST /api/v1/mpc-party/keygen/participate - should accept keygen participation', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/mpc-party/keygen/participate')
.set('Authorization', `Bearer ${authToken}`)
.send({
sessionId: '550e8400-e29b-41d4-a716-446655440000',
partyId: 'user123-server',
joinToken: 'join-token-abc',
shareType: PartyShareType.WALLET,
userId: 'test-user-id',
})
.expect(202);
const body = response.body.data || response.body;
expect(body.message).toBe('Keygen participation started');
});
it('should validate required fields', async () => {
await request(app.getHttpServer())
.post('/api/v1/mpc-party/keygen/participate')
.set('Authorization', `Bearer ${authToken}`)
.send({ sessionId: '550e8400-e29b-41d4-a716-446655440000' })
.expect(400);
});
});
describe('Authentication', () => {
it('should reject requests without token', async () => {
await request(app.getHttpServer())
.get('/api/v1/mpc-party/shares')
.expect(401);
});
it('should reject requests with invalid token', async () => {
await request(app.getHttpServer())
.get('/api/v1/mpc-party/shares')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
it('should reject requests with expired token', async () => {
const expiredToken = jwtService.sign(
{ sub: 'test-user', type: 'access' },
{ expiresIn: '-1h' },
);
await request(app.getHttpServer())
.get('/api/v1/mpc-party/shares')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
describe('Error Handling', () => {
it('should return structured error for validation failures', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/mpc-party/keygen/participate')
.set('Authorization', `Bearer ${authToken}`)
.send({})
.expect(400);
expect(response.body).toHaveProperty('message');
});
it('should handle internal server errors gracefully', async () => {
prismaService.partyShare.findMany.mockRejectedValue(
new Error('Database connection lost'),
);
const response = await request(app.getHttpServer())
.get('/api/v1/mpc-party/shares')
.set('Authorization', `Bearer ${authToken}`)
.expect(500);
expect(response.body).toHaveProperty('message');
});
});
});
```
---
## 手动测试指南
### 1. 使用 cURL 测试
```bash
# 1. 健康检查
curl -X GET http://localhost:3006/api/v1/mpc-party/health
# 2. 获取 JWT Token从身份服务获取或手动生成
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# 3. 测试 Keygen 参与
curl -X POST http://localhost:3006/api/v1/mpc-party/keygen/participate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"partyId": "user123-server",
"joinToken": "join-token-abc123",
"shareType": "wallet",
"userId": "user-id-123"
}'
# 4. 列出分片
curl -X GET "http://localhost:3006/api/v1/mpc-party/shares?page=1&limit=10" \
-H "Authorization: Bearer $TOKEN"
# 5. 获取分片信息
curl -X GET http://localhost:3006/api/v1/mpc-party/shares/share_xxx \
-H "Authorization: Bearer $TOKEN"
```
### 2. 使用 Swagger UI
1. 启动开发服务器:
```bash
npm run start:dev
```
2. 访问 Swagger UI
```
http://localhost:3006/api/docs
```
3. 点击 "Authorize" 按钮,输入 JWT Token
4. 测试各个端点
### 3. 使用 Postman
1. 导入 API 集合(如果有提供)
2. 设置环境变量:
- `BASE_URL`: `http://localhost:3006/api/v1`
- `TOKEN`: JWT Token
3. 按顺序执行请求:
- 健康检查
- Keygen 参与
- 列出分片
- 获取分片详情
---
## 测试覆盖率
### 运行覆盖率报告
```bash
npm run test:cov
```
### 覆盖率目标
| 指标 | 目标 | 当前 |
|------|------|------|
| 语句覆盖率 | > 80% | - |
| 分支覆盖率 | > 70% | - |
| 函数覆盖率 | > 80% | - |
| 行覆盖率 | > 80% | - |
### 覆盖率报告输出
报告生成在 `coverage/` 目录下,包含:
- `coverage/lcov-report/index.html` - HTML 报告
- `coverage/lcov.info` - LCOV 格式报告
---
## CI/CD 集成
### GitHub Actions 示例
```yaml
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- 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
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
```
---
## 最佳实践
### 1. 测试命名规范
```typescript
describe('ComponentName', () => {
describe('methodName', () => {
it('should [expected behavior] when [condition]', () => {
// ...
});
});
});
```
### 2. AAA 模式
```typescript
it('should save share successfully', async () => {
// Arrange - 准备测试数据
const share = createMockShare();
prismaService.partyShare.create.mockResolvedValue(share);
// Act - 执行被测方法
await repository.save(share);
// Assert - 验证结果
expect(prismaService.partyShare.create).toHaveBeenCalled();
});
```
### 3. 模拟最佳实践
- 只模拟外部依赖,不模拟被测代码
- 使用 `jest.fn()` 创建模拟函数
- 使用 `mockResolvedValue``mockRejectedValue` 处理异步
-`beforeEach` 中重置模拟
### 4. 避免的反模式
- 测试实现细节而非行为
- 过度模拟
- 测试间相互依赖
- 测试中使用硬编码的时间等待
---
## 故障排除
### 常见问题
1. **测试超时**
- 增加 Jest 超时时间
- 检查异步操作是否正确等待
2. **模拟不生效**
- 确保模拟在测试之前设置
- 检查依赖注入的 token 是否正确
3. **类型错误**
- 确保 TypeScript 配置正确
- 检查 `tsconfig.json` 中的 `paths` 映射
4. **环境变量问题**
- 确保 `tests/setup.ts` 正确设置环境变量
- 检查 `jest.config.js` 中的 `setupFilesAfterEnv`