4. Pact 实战(上):搭建 Pact Broker 与编写第一个消费者端契约测试
本章进入 Pact 框架的实战环节。你将完成两件核心工作:部署 Pact Broker(契约共享与版本管理的基础设施)和编写第一个消费者驱动的契约测试(Java / Spring Boot 示例)。通过本章学习,你将掌握从测试编写到契约发布的全链路操作。
4.1 为什么需要 Pact Broker?
在单机或单分支场景下,Pact 文件可以保存在本地文件系统。一旦团队规模扩大、微服务数量增多,就需要一个中央仓库来管理契约文件、版本关系和验证状态。Pact Broker 正是为此而生:
| 能力 | 本地文件 | Pact Broker |
|---|---|---|
| 契约存储 | 单机目录 | 中央仓库,支持多团队 |
| 版本管理 | 手动命名 | 自动关联消费者/提供者版本 |
| 验证触发 | 手动复制 | Webhook 自动通知提供者 |
| 矩阵查询 | 不支持 | 查看哪些版本已验证 |
| 环境标记 | 不支持 | 标记生产/测试环境 |
推荐策略:即使只有 2 个微服务,也建议从第一天起使用 Pact Broker。后期迁移成本远高于初期搭建成本。
4.2 搭建 Pact Broker(Docker Compose 方式)
Pact Broker 官方推荐使用 Docker 部署。下面是一个最小化生产级配置,包含 Broker 服务和一个 PostgreSQL 数据库。
4.2.1 docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact_pass
POSTGRES_DB: pact_broker
volumes:
- pgdata:/var/lib/postgresql/data
broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_USERNAME: pact
PACT_BROKER_DATABASE_PASSWORD: pact_pass
PACT_BROKER_DATABASE_HOST: postgres
PACT_BROKER_DATABASE_NAME: pact_broker
PACT_BROKER_PORT: "9292"
PACT_BROKER_DATABASE_CONNECT_MAX_RETRIES: "10"
depends_on:
- postgres
volumes:
pgdata:
4.2.2 启动与验证
- 在
docker-compose.yml所在目录执行:docker-compose up -d - 等待约 30 秒,访问
http://localhost:9292 - 首次访问会进入初始化页面,设置管理员账号(建议使用
admin / admin用于本地开发) - 登录后看到空白的 Pact 列表页面,即表示 Broker 运行正常
生产环境注意:请务必修改默认密码,并配置 HTTPS 和持久化卷的备份策略。PostgreSQL 连接字符串中的密码不应硬编码在 compose 文件中。
4.3 消费者端契约测试架构
在编写测试之前,先理解消费者端测试在整个契约测试流程中的位置:
4.4 编写第一个消费者端契约测试
我们以 Order Service(消费者)调用 User Service(提供者)的 /users/{id} 接口为例,演示完整的测试编写过程。
4.4.1 项目依赖(pom.xml)
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Pact Consumer JUnit 5 -->
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.6.3</version>
<scope>test</scope>
</dependency>
<!-- Pact Consumer 核心库 -->
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>java8</artifactId>
<version>4.6.3</version>
<scope>test</scope>
</dependency>
4.4.2 消费者服务代码
首先定义消费者侧调用提供者的 Feign 客户端(或 RestTemplate):
// UserServiceClient.java
@Component
public class UserServiceClient {
private final RestTemplate restTemplate;
private final String userServiceUrl;
public UserServiceClient(RestTemplate restTemplate,
@Value("${user.service.url}") String userServiceUrl) {
this.restTemplate = restTemplate;
this.userServiceUrl = userServiceUrl;
}
public User getUserById(Long id) {
String url = userServiceUrl + "/users/" + id;
ResponseEntity<User> response = restTemplate.getForEntity(url, User.class);
if (response.getStatusCode() == HttpStatus.OK) {
return response.getBody();
}
throw new RuntimeException("Failed to get user: " + response.getStatusCode());
}
}
4.4.3 编写 Pact 契约测试
这是本章的核心——使用 @PactTestFor 和 @Pact 注解定义消费者期望:
// UserServiceClientPactTest.java
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService", port = "8081")
public class UserServiceClientPactTest {
@Pact(consumer = "OrderService")
public V4Pact createUserPact(PactDslWithProvider builder) {
return builder
.given("用户 ID 1001 存在")
.uponReceiving("获取用户信息")
.path("/users/1001")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.integerType("id", 1001)
.stringType("name", "张三")
.stringType("email", "zhangsan@example.com")
)
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "createUserPact")
void testGetUserById(MockServer mockServer) {
// 使用 MockServer 的 URL 替换真实提供者地址
UserServiceClient client = new UserServiceClient(
new RestTemplate(),
mockServer.getUrl()
);
User user = client.getUserById(1001L);
assertThat(user.getId()).isEqualTo(1001);
assertThat(user.getName()).isEqualTo("张三");
assertThat(user.getEmail()).isEqualTo("zhangsan@example.com");
}
}
测试要点解析:
@Pact(consumer = "OrderService"):定义消费者名称,必须与提供者端配置一致given("用户 ID 1001 存在"):提供者端的状态描述,用于提供者测试时匹配uponReceiving("获取用户信息"):测试场景描述,出现在 Pact Broker 的交互记录中PactDslJsonBody:使用 DSL 定义 JSON 结构,支持类型匹配和正则约束mockServer.getUrl():Pact 自动启动 Mock Server,测试结束后自动关闭
4.4.4 运行测试并生成 Pact 文件
执行测试后,在 target/pacts/ 目录下会生成 OrderService-UserService.json 文件。以下是生成的契约文件片段:
{
"consumer": { "name": "OrderService" },
"provider": { "name": "UserService" },
"interactions": [
{
"description": "获取用户信息",
"providerStates": [
{ "name": "用户 ID 1001 存在" }
],
"request": {
"method": "GET",
"path": "/users/1001",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"id": 1001,
"name": "张三",
"email": "zhangsan@example.com"
}
}
}
],
"metadata": {
"pactSpecification": { "version": "4.0" }
}
}
4.5 将契约发布到 Pact Broker
测试通过后,需要将生成的 Pact 文件发布到 Broker。推荐使用 Maven 插件或 Gradle 任务自动化发布。
4.5.1 Maven 插件配置
<plugin>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>maven</artifactId>
<version>4.6.3</version>
<configuration>
<pactBrokerUrl>http://localhost:9292</pactBrokerUrl>
<pactBrokerUsername>admin</pactBrokerUsername>
<pactBrokerPassword>admin</pactBrokerPassword>
<projectVersion>1.0.0</projectVersion>
<trimSnapshot>true</trimSnapshot>
</configuration>
</plugin>
4.5.2 执行发布
mvn pact:publish -Dpact.consumer.version=1.0.0
发布成功后,访问 http://localhost:9292 即可看到 OrderService 与 UserService 的契约记录。
4.6 消费者测试最佳实践
| 实践 | 说明 |
|---|---|
| 每个接口一个 @Pact 方法 | 保持测试粒度清晰,便于定位问题 |
| 使用 providerState 描述前置条件 | 提供者端根据 state 准备测试数据 |
| 避免硬编码具体值 | 使用 integerType()、stringType() 等 DSL 方法定义类型匹配 |
| 测试覆盖异常路径 | 如 404、500 响应,确保消费者正确处理错误 |
| CI 中自动发布 | 每次构建成功后自动发布新版本契约 |
常见陷阱:不要在消费者测试中直接使用真实数据库或外部服务。Pact 的 Mock Server 已经模拟了提供者行为,任何外部依赖都会破坏测试的隔离性和可重复性。
4.7 验证发布结果
回到 Pact Broker 的 Web 界面,你应该能看到:
- 左侧导航出现 OrderService 和 UserService 两个应用
- 点击 UserService 可以看到来自 OrderService 的契约
- 契约详情页显示交互列表、请求/响应详情和元数据
- 版本号
1.0.0已关联到该契约