rwadurian/backend/mpc-system/docs/04-testing-guide.md

597 lines
17 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 分布式签名系统 - 测试指南
## 1. 测试架构概览
```
┌─────────────────────────────────────────────────────────────────────┐
│ 测试金字塔 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ E2E │ ← 端到端测试 (最慢, 最全面) │
│ │ Tests │ tests/e2e/ │
│ ┌─┴─────────┴─┐ │
│ │ Integration │ ← 集成测试 (服务间交互) │
│ │ Tests │ tests/integration/ │
│ ┌─┴─────────────┴─┐ │
│ │ Unit Tests │ ← 单元测试 (最快, 最多) │
│ │ │ tests/unit/ │
│ └─────────────────┘ *_test.go │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 1.1 测试类型
| 类型 | 位置 | 特点 | 运行时间 |
|------|------|------|---------|
| 单元测试 | `tests/unit/`, `*_test.go` | 测试单个函数/模块 | < 1s |
| 集成测试 | `tests/integration/` | 测试 TSS 协议流程 | 1-5 min |
| E2E 测试 | `tests/e2e/` | 测试完整 HTTP API 流程 | 5-10 min |
### 1.2 测试工具
| 工具 | 用途 |
|------|------|
| testing | Go 标准测试框架 |
| testify | 断言和 Mock |
| httptest | HTTP 测试 |
| gomock | Mock 生成 |
## 2. 单元测试
### 2.1 运行单元测试
```bash
# 运行所有单元测试
make test-unit
# 或使用 go test
go test -v -short ./...
# 运行特定包
go test -v ./pkg/crypto/...
go test -v ./services/account/domain/...
# 运行特定测试
go test -v -run TestEncryption ./pkg/crypto/...
```
### 2.2 单元测试示例
```go
// pkg/crypto/encryption_test.go
package crypto_test
import (
"testing"
"github.com/rwadurian/mpc-system/pkg/crypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAESCryptoService_EncryptDecrypt(t *testing.T) {
// Arrange
masterKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
svc, err := crypto.NewAESCryptoService(masterKey)
require.NoError(t, err)
plaintext := []byte("secret key share data")
// Act
ciphertext, err := svc.Encrypt(plaintext)
require.NoError(t, err)
decrypted, err := svc.Decrypt(ciphertext)
require.NoError(t, err)
// Assert
assert.Equal(t, plaintext, decrypted)
assert.NotEqual(t, plaintext, ciphertext)
}
func TestAESCryptoService_InvalidKey(t *testing.T) {
testCases := []struct {
name string
key string
}{
{"too short", "abcd"},
{"invalid hex", "xyz123"},
{"empty", ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := crypto.NewAESCryptoService(tc.key)
assert.Error(t, err)
})
}
}
```
### 2.3 Mock 使用
```go
// tests/mocks/session_repository_mock.go
type MockSessionRepository struct {
mock.Mock
}
func (m *MockSessionRepository) Save(ctx context.Context, session *entities.Session) error {
args := m.Called(ctx, session)
return args.Error(0)
}
func (m *MockSessionRepository) FindByID(ctx context.Context, id uuid.UUID) (*entities.Session, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*entities.Session), args.Error(1)
}
// 使用 Mock
func TestCreateSession_Success(t *testing.T) {
mockRepo := new(MockSessionRepository)
mockRepo.On("Save", mock.Anything, mock.Anything).Return(nil)
uc := use_cases.NewCreateSessionUseCase(mockRepo)
output, err := uc.Execute(context.Background(), input)
assert.NoError(t, err)
mockRepo.AssertExpectations(t)
}
```
## 3. 集成测试
### 3.1 TSS 协议集成测试
集成测试验证完整的 MPC 协议流程无需外部服务
```bash
# 运行所有集成测试
make test-integration
# 或
go test -v -tags=integration ./tests/integration/...
# 运行特定测试
go test -v ./tests/integration/... -run "TestFull2of3MPCFlow"
go test -v ./tests/integration/... -run "Test3of5Flow"
go test -v ./tests/integration/... -run "Test4of7Flow"
```
### 3.2 集成测试示例
```go
// tests/integration/mpc_full_flow_test.go
package integration_test
import (
"crypto/ecdsa"
"crypto/sha256"
"testing"
"github.com/rwadurian/mpc-system/pkg/tss"
"github.com/stretchr/testify/require"
)
func TestFull2of3MPCFlow(t *testing.T) {
// Step 1: Key Generation (2-of-3)
threshold := 1 // t=1 means t+1=2 signers required
totalParties := 3
keygenResults, err := tss.RunLocalKeygen(threshold, totalParties)
require.NoError(t, err)
require.Len(t, keygenResults, 3)
publicKey := keygenResults[0].PublicKey
require.NotNil(t, publicKey)
// Verify all parties have same public key
for i, result := range keygenResults {
require.Equal(t, publicKey.X, result.PublicKey.X, "Party %d X mismatch", i)
require.Equal(t, publicKey.Y, result.PublicKey.Y, "Party %d Y mismatch", i)
}
// Step 2: Signing with 2 parties
message := []byte("Hello MPC World!")
messageHash := sha256.Sum256(message)
// Test all 3 combinations of 2 parties
combinations := [][2]int{{0, 1}, {0, 2}, {1, 2}}
for _, combo := range combinations {
signers := []*tss.LocalKeygenResult{
keygenResults[combo[0]],
keygenResults[combo[1]],
}
signResult, err := tss.RunLocalSigning(threshold, signers, messageHash[:])
require.NoError(t, err)
// Step 3: Verify signature
valid := ecdsa.Verify(publicKey, messageHash[:], signResult.R, signResult.S)
require.True(t, valid, "Signature should verify for combo %v", combo)
}
}
func TestSecurityProperties(t *testing.T) {
threshold := 1
totalParties := 3
keygenResults, err := tss.RunLocalKeygen(threshold, totalParties)
require.NoError(t, err)
message := []byte("Security test")
messageHash := sha256.Sum256(message)
// Test: Single party cannot sign
singleParty := []*tss.LocalKeygenResult{keygenResults[0]}
_, err = tss.RunLocalSigning(threshold, singleParty, messageHash[:])
require.Error(t, err, "Single party should not sign")
}
```
### 3.3 已验证的阈值方案
| 方案 | 参数 | 密钥生成耗时 | 签名耗时 | 状态 |
|------|------|------------|---------|------|
| 2-of-3 | t=1, n=3 | ~93s | ~80s | PASSED |
| 3-of-5 | t=2, n=5 | ~198s | ~120s | PASSED |
| 4-of-7 | t=3, n=7 | ~221s | ~150s | PASSED |
## 4. E2E 测试
### 4.1 E2E 测试架构
```
┌─────────────────────────────────────────────────────────────┐
│ E2E Test Runner │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Test Suite (testify/suite) │ │
│ │ - SetupSuite: 启动服务, 等待就绪 │ │
│ │ - TearDownSuite: 清理资源 │ │
│ │ - Test*: 测试用例 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▼ HTTP Requests
┌─────────────────────────────────────────────────────────────┐
│ Docker Compose │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Coordinator│ │ Router │ │ Party×3 │ │PostgreSQL│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 运行 E2E 测试
```bash
# 使用 Docker 运行 E2E 测试
make test-docker-e2e
# 手动运行 (需要先启动服务)
docker-compose up -d
go test -v -tags=e2e ./tests/e2e/...
# 运行特定 E2E 测试
go test -v -tags=e2e ./tests/e2e/... -run "TestCompleteKeygenFlow"
```
### 4.3 E2E 测试示例
```go
// tests/e2e/keygen_flow_test.go
//go:build e2e
package e2e_test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type KeygenFlowTestSuite struct {
suite.Suite
baseURL string
client *http.Client
}
func TestKeygenFlowSuite(t *testing.T) {
if testing.Short() {
t.Skip("Skipping e2e test in short mode")
}
suite.Run(t, new(KeygenFlowTestSuite))
}
func (s *KeygenFlowTestSuite) SetupSuite() {
s.baseURL = "http://localhost:8080"
s.client = &http.Client{Timeout: 30 * time.Second}
s.waitForService()
}
func (s *KeygenFlowTestSuite) waitForService() {
for i := 0; i < 30; i++ {
resp, err := s.client.Get(s.baseURL + "/health")
if err == nil && resp.StatusCode == http.StatusOK {
resp.Body.Close()
return
}
time.Sleep(time.Second)
}
s.T().Fatal("Service not ready")
}
func (s *KeygenFlowTestSuite) TestCompleteKeygenFlow() {
// Step 1: Create session
createResp := s.createSession(CreateSessionRequest{
SessionType: "keygen",
ThresholdT: 2,
ThresholdN: 3,
CreatedBy: "e2e_test",
})
s.Require().NotEmpty(createResp.SessionID)
// Step 2: Join with 3 parties
for i := 0; i < 3; i++ {
joinResp := s.joinSession(JoinSessionRequest{
JoinToken: createResp.JoinToken,
PartyID: fmt.Sprintf("party_%d", i),
DeviceType: "test",
})
s.Assert().Equal(createResp.SessionID, joinResp.SessionID)
}
// Step 3: Mark all parties ready
for i := 0; i < 3; i++ {
s.markPartyReady(createResp.SessionID, fmt.Sprintf("party_%d", i))
}
// Step 4: Start session
s.startSession(createResp.SessionID)
// Step 5: Verify session status
status := s.getSessionStatus(createResp.SessionID)
s.Assert().Equal("in_progress", status.Status)
}
func (s *KeygenFlowTestSuite) TestJoinWithInvalidToken() {
resp, err := s.client.Post(
s.baseURL+"/api/v1/sessions/join",
"application/json",
bytes.NewReader([]byte(`{"join_token":"invalid"}`)),
)
s.Require().NoError(err)
defer resp.Body.Close()
s.Assert().Equal(http.StatusUnauthorized, resp.StatusCode)
}
```
### 4.4 Docker E2E 测试配置
```yaml
# tests/docker-compose.test.yml
version: '3.8'
services:
postgres-test:
image: postgres:14-alpine
environment:
POSTGRES_USER: mpc_user
POSTGRES_PASSWORD: mpc_password
POSTGRES_DB: mpc_system_test
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 5s
retries: 5
integration-tests:
build:
context: ..
dockerfile: tests/Dockerfile.test
environment:
TEST_DATABASE_URL: postgres://mpc_user:mpc_password@postgres-test:5432/mpc_system_test
depends_on:
postgres-test:
condition: service_healthy
command: go test -v ./tests/integration/...
e2e-tests:
build:
context: ..
dockerfile: tests/Dockerfile.test
environment:
SESSION_COORDINATOR_URL: http://session-coordinator:8080
depends_on:
- session-coordinator
- message-router
- server-party-1
- server-party-2
- server-party-3
command: go test -v -tags=e2e ./tests/e2e/...
```
## 5. 测试覆盖率
### 5.1 生成覆盖率报告
```bash
# 运行测试并生成覆盖率
make test-coverage
# 或手动
go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# 查看覆盖率
open coverage.html
```
### 5.2 覆盖率目标
| 模块 | 目标覆盖率 | 说明 |
|------|-----------|------|
| pkg/tss | > 80% | 核心加密逻辑 |
| pkg/crypto | > 90% | 加密工具 |
| domain | > 85% | 业务规则 |
| use_cases | > 75% | 用例编排 |
| adapters | > 60% | I/O 适配 |
## 6. 手动测试
### 6.1 使用 cURL 测试 API
```bash
# 健康检查
curl http://localhost:8080/health
# 创建 keygen 会话
curl -X POST http://localhost:8083/api/v1/mpc/keygen \
-H "Content-Type: application/json" \
-d '{
"threshold_n": 3,
"threshold_t": 2,
"participants": [
{"party_id": "user_device", "device_type": "iOS"},
{"party_id": "server_party", "device_type": "server"},
{"party_id": "recovery", "device_type": "recovery"}
]
}'
# 查询会话状态
curl http://localhost:8083/api/v1/mpc/sessions/{session_id}
```
### 6.2 使用 grpcurl 测试 gRPC
```bash
# 安装 grpcurl
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
# 列出服务
grpcurl -plaintext localhost:50051 list
# 创建会话
grpcurl -plaintext -d '{
"session_type": "keygen",
"threshold_n": 3,
"threshold_t": 2
}' localhost:50051 mpc.coordinator.v1.SessionCoordinator/CreateSession
```
## 7. 持续集成
### 7.1 GitHub Actions 配置
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Run unit tests
run: make test-unit
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Run integration tests
run: make test-integration
timeout-minutes: 30
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run E2E tests in Docker
run: make test-docker-e2e
timeout-minutes: 30
```
## 8. 测试最佳实践
### 8.1 测试命名
```go
// 函数测试: Test<Function>_<Scenario>
func TestEncrypt_WithValidKey(t *testing.T) {}
func TestEncrypt_WithInvalidKey(t *testing.T) {}
// 表驱动测试
func TestEncrypt(t *testing.T) {
testCases := []struct {
name string
key string
input []byte
wantErr bool
}{
{"valid key", "abc123...", []byte("data"), false},
{"empty key", "", []byte("data"), true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// test logic
})
}
}
```
### 8.2 测试隔离
```go
// 使用 t.Parallel() 并行运行
func TestSomething(t *testing.T) {
t.Parallel()
// ...
}
// 使用 t.Cleanup() 清理
func TestWithCleanup(t *testing.T) {
resource := createResource()
t.Cleanup(func() {
resource.Close()
})
}
```
### 8.3 避免 Flaky 测试
```go
// 使用重试机制
func waitForCondition(t *testing.T, check func() bool, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if check() {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
// 使用固定种子
rand.Seed(42)
```