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

28 KiB
Raw Blame History

MPC Party Service 测试文档

概述

本文档详细说明 MPC Party Service 的测试架构、测试策略和实现方式。

测试金字塔

                    ┌─────────┐
                    │   E2E   │  ← 端到端测试 (15 tests)
                   ┌┴─────────┴┐
                   │Integration │  ← 集成测试 (30 tests)
                  ┌┴───────────┴┐
                  │  Unit Tests  │  ← 单元测试 (81 tests)
                 └───────────────┘

总计: 111+ 测试用例

测试分类

1. 单元测试 (Unit Tests)

测试单个组件的隔离行为,不依赖外部服务。

目录: tests/unit/

运行命令:

npm run test:unit

覆盖范围:

  • 领域实体 (Domain Entities)
  • 值对象 (Value Objects)
  • 领域服务 (Domain Services)
  • 应用层处理器 (Application Handlers)
  • 数据映射器 (Data Mappers)

2. 集成测试 (Integration Tests)

测试多个组件之间的交互,使用模拟的外部依赖。

目录: tests/integration/

运行命令:

npm run test:integration

覆盖范围:

  • 仓储实现 (Repository Implementations)
  • 控制器 (Controllers)
  • 事件发布器 (Event Publishers)

3. 端到端测试 (E2E Tests)

测试完整的 API 流程,模拟真实的客户端请求。

目录: tests/e2e/

运行命令:

npm run test:e2e

覆盖范围:

  • API 端点
  • 认证流程
  • 错误处理
  • 完整的请求/响应周期

测试配置

Jest 配置文件

基础配置 (tests/jest.config.js)

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)

const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,
  testMatch: ['<rootDir>/tests/unit/**/*.spec.ts'],
  testTimeout: 30000,
};

集成测试配置 (tests/jest-integration.config.js)

const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,
  testMatch: ['<rootDir>/tests/integration/**/*.spec.ts'],
  testTimeout: 60000,
};

E2E 测试配置 (tests/jest-e2e.config.js)

const baseConfig = require('./jest.config');

module.exports = {
  ...baseConfig,
  testMatch: ['<rootDir>/tests/e2e/**/*.e2e-spec.ts'],
  testTimeout: 120000,
  maxWorkers: 1, // 顺序执行
};

测试环境设置 (tests/setup.ts)

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)

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)

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)

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)

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)

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)

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)

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 测试

# 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. 启动开发服务器:

    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 参与
    • 列出分片
    • 获取分片详情

测试覆盖率

运行覆盖率报告

npm run test:cov

覆盖率目标

指标 目标 当前
语句覆盖率 > 80% -
分支覆盖率 > 70% -
函数覆盖率 > 80% -
行覆盖率 > 80% -

覆盖率报告输出

报告生成在 coverage/ 目录下,包含:

  • coverage/lcov-report/index.html - HTML 报告
  • coverage/lcov.info - LCOV 格式报告

CI/CD 集成

GitHub Actions 示例

# .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. 测试命名规范

describe('ComponentName', () => {
  describe('methodName', () => {
    it('should [expected behavior] when [condition]', () => {
      // ...
    });
  });
});

2. AAA 模式

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() 创建模拟函数
  • 使用 mockResolvedValuemockRejectedValue 处理异步
  • beforeEach 中重置模拟

4. 避免的反模式

  • 测试实现细节而非行为
  • 过度模拟
  • 测试间相互依赖
  • 测试中使用硬编码的时间等待

故障排除

常见问题

  1. 测试超时

    • 增加 Jest 超时时间
    • 检查异步操作是否正确等待
  2. 模拟不生效

    • 确保模拟在测试之前设置
    • 检查依赖注入的 token 是否正确
  3. 类型错误

    • 确保 TypeScript 配置正确
    • 检查 tsconfig.json 中的 paths 映射
  4. 环境变量问题

    • 确保 tests/setup.ts 正确设置环境变量
    • 检查 jest.config.js 中的 setupFilesAfterEnv