Skip to content

契约测试

确保微服务之间的接口契约一致性

什么是契约测试?

契约测试是一种测试方法,用于验证服务之间的交互接口是否符合预定的契约。它确保:

  • 📝 接口一致性: 提供者和消费者对接口的理解一致
  • 🔒 独立演进: 服务可以独立开发和部署
  • 🚀 快速反馈: 及早发现接口不兼容问题
  • 🎯 精确测试: 只测试接口契约,不需要完整环境

契约测试 vs 其他测试

测试类型范围速度维护成本环境依赖
单元测试单个组件非常快
契约测试服务接口
集成测试多个服务
端到端测试整个系统非常慢非常高完整环境

Pact 契约测试

1. 消费者端测试

JavaScript/TypeScript 示例

typescript
// userService.contract.test.ts
import { Pact } from '@pact-foundation/pact';
import { UserService } from './userService';
import path from 'path';

describe('User Service Contract Tests', () => {
  const provider = new Pact({
    consumer: 'Frontend Application',
    provider: 'User API',
    port: 8080,
    log: path.resolve(process.cwd(), 'logs', 'pact.log'),
    dir: path.resolve(process.cwd(), 'pacts'),
    logLevel: 'info'
  });

  beforeAll(() => provider.setup());
  afterEach(() => provider.verify());
  afterAll(() => provider.finalize());

  describe('获取用户详情', () => {
    it('应该返回用户信息', async () => {
      // 定义预期的交互
      await provider.addInteraction({
        state: '用户 ID 123 存在',
        uponReceiving: '获取用户 123 的请求',
        withRequest: {
          method: 'GET',
          path: '/api/users/123',
          headers: {
            'Accept': 'application/json',
            'Authorization': 'Bearer token123'
          }
        },
        willRespondWith: {
          status: 200,
          headers: {
            'Content-Type': 'application/json'
          },
          body: {
            id: '123',
            name: 'John Doe',
            email: 'john@example.com',
            createdAt: '2024-01-01T00:00:00Z'
          }
        }
      });

      // 执行实际的 API 调用
      const userService = new UserService('http://localhost:8080');
      const user = await userService.getUserById('123', 'token123');

      // 验证响应
      expect(user).toEqual({
        id: '123',
        name: 'John Doe',
        email: 'john@example.com',
        createdAt: '2024-01-01T00:00:00Z'
      });
    });

    it('用户不存在时返回 404', async () => {
      await provider.addInteraction({
        state: '用户 ID 999 不存在',
        uponReceiving: '获取不存在用户的请求',
        withRequest: {
          method: 'GET',
          path: '/api/users/999',
          headers: {
            'Accept': 'application/json',
            'Authorization': 'Bearer token123'
          }
        },
        willRespondWith: {
          status: 404,
          headers: {
            'Content-Type': 'application/json'
          },
          body: {
            error: 'User not found',
            message: 'The user with ID 999 does not exist'
          }
        }
      });

      const userService = new UserService('http://localhost:8080');
      
      await expect(
        userService.getUserById('999', 'token123')
      ).rejects.toThrow('User not found');
    });
  });

  describe('创建用户', () => {
    it('应该成功创建新用户', async () => {
      const newUser = {
        name: 'Jane Doe',
        email: 'jane@example.com',
        password: 'SecurePass123!'
      };

      await provider.addInteraction({
        state: '准备创建新用户',
        uponReceiving: '创建用户的请求',
        withRequest: {
          method: 'POST',
          path: '/api/users',
          headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
          },
          body: newUser
        },
        willRespondWith: {
          status: 201,
          headers: {
            'Content-Type': 'application/json',
            'Location': '/api/users/124'
          },
          body: {
            id: '124',
            name: 'Jane Doe',
            email: 'jane@example.com',
            createdAt: '2024-01-02T00:00:00Z'
          }
        }
      });

      const userService = new UserService('http://localhost:8080');
      const createdUser = await userService.createUser(newUser);

      expect(createdUser.id).toBe('124');
      expect(createdUser.name).toBe('Jane Doe');
      expect(createdUser.email).toBe('jane@example.com');
    });
  });
});

2. 提供者端验证

Java Spring Boot 示例

java
@Provider("User API")
@PactFolder("pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class UserApiProviderTest {

    @LocalServerPort
    private int port;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        // 清理测试数据
        userRepository.deleteAll();
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @BeforeEach
    void configureContext(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    @State("用户 ID 123 存在")
    void userExists() {
        User user = User.builder()
            .id("123")
            .name("John Doe")
            .email("john@example.com")
            .createdAt(LocalDateTime.parse("2024-01-01T00:00:00"))
            .build();
        
        userRepository.save(user);
    }

    @State("用户 ID 999 不存在")
    void userDoesNotExist() {
        // 不需要做任何事,数据库中本来就没有这个用户
    }

    @State("准备创建新用户")
    void readyToCreateUser() {
        // 确保邮箱未被使用
        userRepository.deleteByEmail("jane@example.com");
    }
}

Python FastAPI 示例

python
# test_user_api_provider.py
import pytest
from pact import Verifier
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db, Base, engine
from app.models import User
from sqlalchemy.orm import Session

# 测试数据库设置
Base.metadata.create_all(bind=engine)

@pytest.fixture
def db():
    """获取测试数据库会话"""
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    
    yield session
    
    session.close()
    transaction.rollback()
    connection.close()

@pytest.fixture
def client(db):
    """创建测试客户端"""
    def override_get_db():
        yield db
    
    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c

class TestUserApiProvider:
    """User API 提供者契约测试"""
    
    def test_verify_contract(self, client, db):
        """验证契约"""
        verifier = Verifier(
            provider='User API',
            provider_base_url='http://localhost:8000'
        )
        
        # 设置状态处理器
        verifier.state_handler_function = lambda state: self.provider_state(state, db)
        
        # 验证契约文件
        output, _ = verifier.verify_pacts(
            './pacts/frontend_application-user_api.json',
            verbose=True
        )
        
        assert output == 0
    
    def provider_state(self, state_name: str, db: Session):
        """处理提供者状态"""
        if state_name == "用户 ID 123 存在":
            user = User(
                id="123",
                name="John Doe",
                email="john@example.com",
                created_at="2024-01-01T00:00:00Z"
            )
            db.add(user)
            db.commit()
            
        elif state_name == "用户 ID 999 不存在":
            # 确保用户不存在
            db.query(User).filter_by(id="999").delete()
            db.commit()
            
        elif state_name == "准备创建新用户":
            # 清理可能存在的冲突数据
            db.query(User).filter_by(email="jane@example.com").delete()
            db.commit()

Spring Cloud Contract

1. 契约定义

groovy
// contracts/user/getUserById.groovy
package contracts.user

import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description "获取用户详情"
    
    request {
        method GET()
        url('/api/users/123')
        headers {
            accept('application/json')
            header('Authorization', 'Bearer token123')
        }
    }
    
    response {
        status OK()
        headers {
            contentType('application/json')
        }
        body([
            id: '123',
            name: 'John Doe',
            email: 'john@example.com',
            createdAt: '2024-01-01T00:00:00Z'
        ])
    }
}

2. 生成测试

java
// 自动生成的测试基类
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public abstract class UserContractTestBase {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.mockMvc(mockMvc);
        
        // 设置测试数据
        User user = User.builder()
            .id("123")
            .name("John Doe")
            .email("john@example.com")
            .createdAt(LocalDateTime.parse("2024-01-01T00:00:00"))
            .build();
            
        when(userService.getUserById("123"))
            .thenReturn(user);
    }
}

契约测试工作流

Pact Broker 部署

Docker Compose 配置

yaml
version: '3.8'

services:
  postgres:
    image: postgres:14
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: pact123
      POSTGRES_DB: pact_broker
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U pact"]
      interval: 10s
      timeout: 5s
      retries: 5

  pact-broker:
    image: pactfoundation/pact-broker:latest
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:pact123@postgres/pact_broker
      PACT_BROKER_BASIC_AUTH_USERNAME: admin
      PACT_BROKER_BASIC_AUTH_PASSWORD: admin123
      PACT_BROKER_WEBHOOK_HOST_WHITELIST: ".*"
      PACT_BROKER_BASE_URL: http://localhost:9292
    links:
      - postgres

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./certs:/etc/nginx/certs
    depends_on:
      - pact-broker

volumes:
  postgres_data:

CI/CD 集成

GitHub Actions

yaml
name: Contract Tests

on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Run consumer tests
        run: npm run test:contract:consumer
        
      - name: Publish pacts
        run: |
          npm run pact:publish -- \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-token=${{ secrets.PACT_BROKER_TOKEN }} \
            --consumer-app-version=${{ github.sha }}

  provider-verification:
    runs-on: ubuntu-latest
    needs: consumer-tests
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
          
      - name: Verify contracts
        run: |
          ./mvnw test \
            -Dpact.provider.version=${{ github.sha }} \
            -Dpact.verifier.publishResults=true \
            -Dpact.broker.url=${{ secrets.PACT_BROKER_URL }} \
            -Dpact.broker.token=${{ secrets.PACT_BROKER_TOKEN }}

  can-i-deploy:
    runs-on: ubuntu-latest
    needs: provider-verification
    steps:
      - name: Can I Deploy?
        run: |
          npx @pact-foundation/pact-cli can-i-deploy \
            --pacticipant="Frontend Application" \
            --version=${{ github.sha }} \
            --to=production \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-token=${{ secrets.PACT_BROKER_TOKEN }}

契约测试最佳实践

1. 契约版本管理

javascript
// package.json
{
  "scripts": {
    "pact:publish": "pact-broker publish ./pacts --consumer-app-version=$(git rev-parse HEAD)",
    "pact:tag": "pact-broker create-version-tag --pacticipant 'Frontend Application' --version $(git rev-parse HEAD) --tag $(git branch --show-current)",
    "pact:can-i-deploy": "pact-broker can-i-deploy --pacticipant 'Frontend Application' --version $(git rev-parse HEAD) --to-environment production"
  }
}

2. 状态管理

typescript
// 使用工厂模式管理测试状态
class TestStateFactory {
  static states = {
    'user exists': (userId: string) => ({
      setup: async () => {
        await database.users.create({
          id: userId,
          name: 'Test User',
          email: 'test@example.com'
        });
      },
      teardown: async () => {
        await database.users.delete(userId);
      }
    }),
    
    'user does not exist': (userId: string) => ({
      setup: async () => {
        await database.users.delete(userId);
      },
      teardown: async () => {
        // 无需清理
      }
    })
  };
}

3. 契约演进策略

yaml
# 契约演进规则
rules:
  # 向后兼容的变更
  backward_compatible:
    - 添加可选字段
    - 添加新的端点
    - 添加可选的查询参数
    
  # 需要协调的变更
  breaking_changes:
    - 删除字段
    - 更改字段类型
    - 更改必填字段
    - 删除端点
    
  # 版本策略
  versioning:
    - 使用 URL 版本: /api/v1/users
    - 使用请求头版本: Accept: application/vnd.api+json;version=1
    - 使用查询参数: /api/users?version=1

故障排除

常见问题

  1. 契约不匹配

    bash
    # 查看详细的不匹配信息
    npm run test:contract -- --verbose
  2. 状态设置失败

    java
    // 添加详细日志
    @State("user exists")
    public void userExists() {
        log.info("Setting up state: user exists");
        // 状态设置代码
    }
  3. Broker 连接问题

    bash
    # 测试 Broker 连接
    curl -u admin:admin123 http://localhost:9292/pacts/latest

总结

契约测试的价值:

  • 🔐 接口稳定性: 防止意外的破坏性变更
  • 🚀 独立开发: 团队可以并行工作
  • 📊 清晰文档: 契约即文档
  • 快速反馈: 及早发现问题
  • 🎯 精确测试: 只测试真正重要的部分

通过实施契约测试,确保微服务架构中的服务间通信可靠性。

SOLO Development Guide