契约测试
确保微服务之间的接口契约一致性
什么是契约测试?
契约测试是一种测试方法,用于验证服务之间的交互接口是否符合预定的契约。它确保:
- 📝 接口一致性: 提供者和消费者对接口的理解一致
- 🔒 独立演进: 服务可以独立开发和部署
- 🚀 快速反馈: 及早发现接口不兼容问题
- 🎯 精确测试: 只测试接口契约,不需要完整环境
契约测试 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故障排除
常见问题
契约不匹配
bash# 查看详细的不匹配信息 npm run test:contract -- --verbose状态设置失败
java// 添加详细日志 @State("user exists") public void userExists() { log.info("Setting up state: user exists"); // 状态设置代码 }Broker 连接问题
bash# 测试 Broker 连接 curl -u admin:admin123 http://localhost:9292/pacts/latest
总结
契约测试的价值:
- 🔐 接口稳定性: 防止意外的破坏性变更
- 🚀 独立开发: 团队可以并行工作
- 📊 清晰文档: 契约即文档
- ⚡ 快速反馈: 及早发现问题
- 🎯 精确测试: 只测试真正重要的部分
通过实施契约测试,确保微服务架构中的服务间通信可靠性。