1082 lines
29 KiB
Markdown
1082 lines
29 KiB
Markdown
# 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`
|