API 契约测试实战——Pact 框架在微服务中的消费者驱动测试落地
API 契约测试实战——Pact 框架在微服务中的消费者驱动测试落地
适读人群:微服务架构开发者、平台工程师 | 阅读时长:约 18 分钟 | 核心价值:用 Pact 框架实现消费者驱动的契约测试,防止微服务接口变更导致的级联故障
微服务架构下,接口变更引发的 Bug,是我见过最难排查的一类问题。
那是两年前,我们的订单服务升级了一个 API:/api/orders/{id} 的响应里,把 userId 字段重命名成了 customerId(产品说这样更准确)。改动只涉及一个字段名,后端改完测试全过,信心满满上线。
三天后,客服反馈:积分系统里用户的订单历史查不出来了。一查,积分服务消费了订单接口,代码里取的是 userId,改名后返回了 null,积分计算的 SQL 用 null 当条件,结果集为空,所有用户的历史积分数据"消失"了。
最糟糕的是,订单服务的测试通过了,积分服务的测试也通过了——因为积分服务 Mock 了订单接口,Mock 里还是返回 userId,双方都不知道对方已经变了。
这就是微服务集成测试的核心问题:服务之间的接口契约,没有任何机制来保证它不会被默默打破。
Pact,就是解决这个问题的。
一、Pact 的核心思路
Pact 是一种消费者驱动的契约测试框架。核心思路:
- 消费者(Consumer,调用方)定义它对提供方 API 的期望,生成一份"契约文件"(Pact File)。
- 提供者(Provider,被调用方)在 CI 里验证它的实现是否满足消费者定义的契约。
- 如果提供者的实现与契约不符,测试失败,无法上线。
优势:
- 提供者改 API 之前,能立刻发现哪些消费者会受影响
- 契约由消费者定义,解决了"消费者需要什么"的问题
- 不需要部署真实的提供者服务,测试速度快
二、依赖配置
<dependencies>
<!-- Pact 消费者端 -->
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.6.7</version>
<scope>test</scope>
</dependency>
<!-- Pact 提供者端 -->
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>junit5spring</artifactId>
<version>4.6.7</version>
<scope>test</scope>
</dependency>
<!-- Spring Cloud OpenFeign(消费者用来调用提供者) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>三、消费者端:定义契约
以积分服务(Consumer)调用订单服务(Provider)为例:
// 积分服务中的 Feign 客户端
@FeignClient(name = "order-service", url = "${order.service.url}")
public interface OrderServiceClient {
@GetMapping("/api/orders/{orderId}")
OrderResponse getOrder(@PathVariable("orderId") Long orderId);
@GetMapping("/api/orders")
PagedResponse<OrderResponse> getUserOrders(
@RequestParam("userId") Long userId,
@RequestParam("page") int page,
@RequestParam("size") int size);
}
// OrderResponse DTO
@Data
public class OrderResponse {
private Long orderId;
private Long userId; // 消费者期望的字段名是 userId
private String status;
private BigDecimal totalAmount;
private List<OrderItemResponse> items;
private LocalDateTime createdAt;
}消费者契约测试(生成 Pact 文件):
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "order-service")
class OrderServiceClientPactTest {
@Pact(consumer = "points-service")
public RequestResponsePact getOrderByIdPact(PactDslWithProvider builder) {
return builder
.given("订单 1001 存在")
.uponReceiving("获取订单 1001 的详情")
.path("/api/orders/1001")
.method("GET")
.headers(Map.of("Accept", "application/json"))
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.integerType("orderId", 1001)
.integerType("userId", 10086) // 消费者期望 userId 字段
.stringType("status", "COMPLETED")
.decimalType("totalAmount", 299.00)
.array("items")
.closeArray()
.datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ss")
)
.toPact();
}
@Test
@PactTestFor(pactMethod = "getOrderByIdPact")
void 获取订单_契约验证通过(MockServer mockServer) {
// given - Pact 自动启动 MockServer,模拟提供者
OrderServiceClient client = buildClient(mockServer.getUrl());
// when
OrderResponse order = client.getOrder(1001L);
// then
assertThat(order.getOrderId()).isEqualTo(1001L);
assertThat(order.getUserId()).isEqualTo(10086L);
assertThat(order.getStatus()).isEqualTo("COMPLETED");
}
@Pact(consumer = "points-service")
public RequestResponsePact getOrderNotFoundPact(PactDslWithProvider builder) {
return builder
.given("订单 9999 不存在")
.uponReceiving("获取不存在的订单")
.path("/api/orders/9999")
.method("GET")
.willRespondWith()
.status(404)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringType("error", "Order not found")
.stringType("code", "ORDER_NOT_FOUND")
)
.toPact();
}
@Test
@PactTestFor(pactMethod = "getOrderNotFoundPact")
void 获取不存在的订单_返回404_客户端正确处理(MockServer mockServer) {
OrderServiceClient client = buildClient(mockServer.getUrl());
assertThatThrownBy(() -> client.getOrder(9999L))
.isInstanceOf(FeignException.NotFound.class);
}
private OrderServiceClient buildClient(String baseUrl) {
return Feign.builder()
.decoder(new JacksonDecoder())
.encoder(new JacksonEncoder())
.target(OrderServiceClient.class, baseUrl);
}
}运行测试后,Pact 会在 target/pacts/ 目录生成契约文件:
{
"consumer": {"name": "points-service"},
"provider": {"name": "order-service"},
"interactions": [
{
"description": "获取订单 1001 的详情",
"providerStates": [{"name": "订单 1001 存在"}],
"request": {
"method": "GET",
"path": "/api/orders/1001"
},
"response": {
"status": 200,
"body": {
"orderId": 1001,
"userId": 10086,
...
}
}
}
]
}四、提供者端:验证契约
订单服务(Provider)验证自己的实现是否满足消费者的契约:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@Provider("order-service")
@PactFolder("../points-service/target/pacts") // 消费者生成的 pact 文件路径
class OrderServiceProviderPactTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@LocalServerPort
private int port;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
// Provider State 处理:准备测试数据
@State("订单 1001 存在")
void createOrder1001() {
orderRepository.save(Order.builder()
.id(1001L)
.userId(10086L) // 注意:这里必须是 userId,不是 customerId!
.status(OrderStatus.COMPLETED)
.totalAmount(new BigDecimal("299.00"))
.createdAt(LocalDateTime.now())
.build());
}
@State("订单 9999 不存在")
void ensureOrder9999NotExists() {
orderRepository.deleteById(9999L);
}
}当订单服务把 userId 改成 customerId 时,这个提供者测试会失败:
Verifying a pact between points-service and order-service
[from file ../points-service/target/pacts/points-service-order-service.json]
获取订单 1001 的详情
returns a response which
has status code 200 (OK)
has a matching body (FAILED)
$.userId Expected userId but was not found
$.customerId Unexpected key customerId这个失败,在部署到生产环境之前就会被捕获。
五、三个踩坑实录
坑 1:Pact 文件路径在 CI 环境不一致
现象: 本地验证通过,CI 里提供者测试找不到 Pact 文件,报 No pacts found。
原因: @PactFolder 使用的是相对路径,在 CI 里工作目录不同,路径解析失败。
解法: 使用 Pact Broker(官方推荐的方案)替代文件共享:
// 消费者:发布 Pact 到 Broker
@PactBroker(host = "pact-broker.internal.company.com",
tags = {"main", "production"})
// 或者用环境变量
@PactBroker
// 然后设置环境变量
// PACT_BROKER_BASE_URL=https://broker.example.com
// PACT_BROKER_TOKEN=your-token坑 2:Provider State 数据不清理导致测试污染
现象: 前一个 Provider State 插入的数据,影响了后一个 interaction 的验证。
原因: @State 方法只负责准备数据,没有清理逻辑,多个 interaction 共享数据库。
解法:
@State("订单 1001 存在")
void createOrder1001() {
orderRepository.deleteAll(); // 先清理
orderRepository.save(Order.builder().id(1001L)...build());
}
@State(value = "订单 1001 存在", action = StateChangeAction.TEARDOWN)
void cleanupAfterOrder1001() {
orderRepository.deleteById(1001L);
}坑 3:消费者和提供者版本不兼容
现象: 消费者用 Pact 4.x 生成的文件,提供者用 Pact 3.x 验证,报格式错误。
原因: Pact 文件格式在大版本之间有变化,消费者和提供者必须用兼容的版本。
解法: 统一团队所有服务使用相同的 Pact 版本,在 BOM 里管理:
<!-- 统一版本 -->
<properties>
<pact.version>4.6.7</pact.version>
</properties>六、与 CI/CD 集成
# GitHub Actions
- name: Consumer - Run Pact tests and publish
run: |
mvn test -pl points-service
mvn pact:publish -pl points-service \
-Dpact.broker.url=https://broker.example.com \
-Dpact.broker.token=${{ secrets.PACT_TOKEN }}
- name: Provider - Verify pacts
run: |
mvn test -pl order-service \
-Dspring.profiles.active=pact-test \
-Dpact.verifier.publishResults=true \
-Dpact.provider.version=${{ github.sha }}Pact 带来的不只是技术收益,更是团队协作方式的改变:提供者改接口之前,先看有没有消费者依赖这个字段,这成了一个自然的工作流程。
