契约测试模式:消费者驱动契约与Pact框架实践

微服务架构下,服务之间的依赖关系越来越复杂。我见过太多团队,服务A改了接口,服务B直接挂掉,然后两边互相甩锅。这种问题怎么治?契约测试就是答案。

说白了,契约测试就是服务之间签一份「合同」。消费者说我要什么,提供者说我能给什么。两边各自验证,谁也别耍赖。

为什么需要契约测试?

先想想看,传统集成测试有什么问题?

  • 需要把所有服务都部署起来,环境搭建费时费力
  • 测试跑得慢,一次集成测试可能要几十分钟
  • 出了问题定位困难,不知道是消费者还是提供者的锅

我在项目中遇到过这样的情况:一个订单服务改了返回字段,下游的支付服务直接报错。因为支付服务依赖订单的某个字段,但订单服务觉得「这个字段没人用」就给删了。这就是典型的沟通断裂。

契约测试的核心思路是:消费者定义期望,提供者验证实现。两边各自独立测试,但共享一份契约。

消费者驱动契约(Consumer-Driven Contracts)

消费者驱动契约,简称CDC。这个名字有点绕,我换个说法:谁用谁说了算

消费者先写一份契约,描述自己需要什么接口、什么参数、什么返回值。提供者拿到这份契约,照着实现就行。

核心流程:

  1. 消费者编写契约(期望的请求和响应)
  2. 契约发布到共享的契约仓库
  3. 提供者从仓库拉取契约
  4. 提供者验证自己的服务是否满足契约
  5. 验证通过才能部署

这样做的好处很明显:消费者不用等提供者开发完。消费者可以先写契约,然后基于契约做自己的开发。两边可以并行推进。

提供者验证(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流程图

契约测试核心流程 消费者服务 (如:订单服务) 编写契约 Pact Broker 提供者服务 (如:用户服务) 拉取契约 验证契约 返回验证结果 收到验证通知 消费者 提供者 Pact Broker

契约测试的适用场景

不是所有场景都适合契约测试。我总结了几条经验:

场景 是否推荐 说明
服务间HTTP接口调用 强烈推荐 Pact对HTTP支持最好
消息队列(Kafka/RabbitMQ) 推荐 Pact支持消息契约
内部RPC调用(gRPC) 可以 需要额外配置
第三方外部服务 谨慎 第三方可能不配合验证
数据库共享 不推荐 契约测试不适合数据层

避坑指南

我曾经在项目中犯过一个错误:契约写得太细,把每个字段的精确值都写死了。结果提供者稍微改了个字段格式,契约就挂了。后来我改成用stringTypeintegerType这种匹配器,只验证类型和结构,不验证具体值。

还有一次,团队把契约测试当成了集成测试的替代品。结果上线后业务逻辑出问题了。记住:契约测试只验证接口契约,不验证业务逻辑。业务逻辑该写单元测试还是得写。

重要提醒:契约测试不是银弹。它解决的是服务间接口一致性的问题,不能解决所有测试问题。合理的测试金字塔应该是:单元测试 > 契约测试 > 集成测试 > 端到端测试。

总结

契约测试模式,说白了就是让消费者和提供者各司其职。消费者定义期望,提供者验证实现。Pact框架让这个过程变得自动化、可管理。

我个人习惯在项目初期就引入契约测试。越早引入,收益越大。等到服务多了再想加,改造成本就高了。

嗯,最后说一句:契约测试不是银弹,但它是微服务架构下不可或缺的一环。用好它,能省掉很多扯皮的时间。


公众号:蓝海资料掘金营,微信deep3321