契约测试模式:消费者驱动契约与Pact框架实践
微服务架构下,服务之间的依赖关系越来越复杂。我见过太多团队,服务A改了接口,服务B直接挂掉,然后两边互相甩锅。这种问题怎么治?契约测试就是答案。
说白了,契约测试就是服务之间签一份「合同」。消费者说我要什么,提供者说我能给什么。两边各自验证,谁也别耍赖。
为什么需要契约测试?
先想想看,传统集成测试有什么问题?
- 需要把所有服务都部署起来,环境搭建费时费力
- 测试跑得慢,一次集成测试可能要几十分钟
- 出了问题定位困难,不知道是消费者还是提供者的锅
我在项目中遇到过这样的情况:一个订单服务改了返回字段,下游的支付服务直接报错。因为支付服务依赖订单的某个字段,但订单服务觉得「这个字段没人用」就给删了。这就是典型的沟通断裂。
契约测试的核心思路是:消费者定义期望,提供者验证实现。两边各自独立测试,但共享一份契约。
消费者驱动契约(Consumer-Driven Contracts)
消费者驱动契约,简称CDC。这个名字有点绕,我换个说法:谁用谁说了算。
消费者先写一份契约,描述自己需要什么接口、什么参数、什么返回值。提供者拿到这份契约,照着实现就行。
核心流程:
- 消费者编写契约(期望的请求和响应)
- 契约发布到共享的契约仓库
- 提供者从仓库拉取契约
- 提供者验证自己的服务是否满足契约
- 验证通过才能部署
这样做的好处很明显:消费者不用等提供者开发完。消费者可以先写契约,然后基于契约做自己的开发。两边可以并行推进。
提供者验证(Provider Verification)
提供者这边,要做的事情就是验证。验证自己的服务能不能满足消费者提的要求。
我曾经踩过一个坑:提供者验证只跑了一次,后来改了代码没重新验证,结果上线就出问题了。所以我现在要求团队:每次代码变更都必须重新跑契约验证。
提供者验证通常包括:
- 检查请求参数是否匹配
- 检查响应状态码是否正确
- 检查响应字段是否存在、类型是否正确
- 检查响应字段的值是否在预期范围内
注意:提供者验证不是端到端测试。它只验证接口契约,不验证业务逻辑。业务逻辑应该由单元测试和集成测试覆盖。
Pact框架实践
Pact是目前最流行的契约测试框架。它支持Java、C++、JavaScript等多种语言。我主要用Java版本,也就是pact-jvm。
先看一个简单的例子。假设有一个用户服务,提供查询用户信息的接口。
消费者端(编写契约)
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService", port = "8080")
public class UserServiceConsumerTest {
@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")
public void testGetUser(MockServer mockServer) {
// 使用mockServer发送请求
UserServiceClient client = new UserServiceClient(mockServer.getUrl());
User user = client.getUser(1001);
assertEquals(1001, user.getId());
assertEquals("张三", user.getName());
}
}
这段代码做了两件事:
- 定义了一个契约:GET /users/1001 应该返回什么
- 用这个契约生成了一个mock服务,然后测试消费者代码
提供者端(验证契约)
@Provider("UserService")
@PactBroker(url = "http://pact-broker:9292")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserServiceProviderTest {
@LocalServerPort
int port;
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
@State("用户ID为1001的用户存在")
public void userExists() {
// 准备测试数据
User user = new User(1001, "张三", "zhangsan@example.com");
userRepository.save(user);
}
}
提供者这边,直接从Pact Broker拉取契约,然后验证自己的服务。注意那个@State注解,它对应消费者契约里的given。这是Pact的一个巧妙设计:消费者描述前置条件,提供者负责准备。
Pact Broker:契约的中央仓库
契约不能只存在本地,需要有个地方共享。Pact Broker就是干这个的。
我建议团队把Pact Broker部署在CI/CD流水线中。每次消费者提交契约,Broker会通知提供者去验证。验证通过后,Broker会标记契约状态为「已验证」。
小技巧:可以在Broker上配置webhook,当契约变更时自动触发提供者的验证流水线。这样就不用人工去检查了。
契约测试的SVG流程图
契约测试的适用场景
不是所有场景都适合契约测试。我总结了几条经验:
| 场景 | 是否推荐 | 说明 |
|---|---|---|
| 服务间HTTP接口调用 | 强烈推荐 | Pact对HTTP支持最好 |
| 消息队列(Kafka/RabbitMQ) | 推荐 | Pact支持消息契约 |
| 内部RPC调用(gRPC) | 可以 | 需要额外配置 |
| 第三方外部服务 | 谨慎 | 第三方可能不配合验证 |
| 数据库共享 | 不推荐 | 契约测试不适合数据层 |
避坑指南
我曾经在项目中犯过一个错误:契约写得太细,把每个字段的精确值都写死了。结果提供者稍微改了个字段格式,契约就挂了。后来我改成用stringType、integerType这种匹配器,只验证类型和结构,不验证具体值。
还有一次,团队把契约测试当成了集成测试的替代品。结果上线后业务逻辑出问题了。记住:契约测试只验证接口契约,不验证业务逻辑。业务逻辑该写单元测试还是得写。
重要提醒:契约测试不是银弹。它解决的是服务间接口一致性的问题,不能解决所有测试问题。合理的测试金字塔应该是:单元测试 > 契约测试 > 集成测试 > 端到端测试。
总结
契约测试模式,说白了就是让消费者和提供者各司其职。消费者定义期望,提供者验证实现。Pact框架让这个过程变得自动化、可管理。
我个人习惯在项目初期就引入契约测试。越早引入,收益越大。等到服务多了再想加,改造成本就高了。
嗯,最后说一句:契约测试不是银弹,但它是微服务架构下不可或缺的一环。用好它,能省掉很多扯皮的时间。
公众号:蓝海资料掘金营,微信deep3321