洞见101之契约测试实践篇

2022-05-20   出处: 刘冉的思辨悟  作/译者:刘冉

1. 介绍

在前一篇文章《洞见101之契约测试理论篇》中,详细阐述了契约测试解决的问题,工作原理以及主要的一些实践等。但是如何真正的实现一套契约测试,仍然需要了解和学习更多细节步骤才能完成。现在我们就来看如何实现一套完整的契约测试。

由于契约测试的特殊性,很难手动执行,所以一般情况下它都是通过自动化的方式来实施。业界有多个开源免费的契约测试自动化框架,其中最为常用的就是 Pact 和 Spring Cloud Contract,并且契约测试也分为基于消费者驱动的契约测试(Consumer Driven Contract Test,即 CDCT)和基于提供者驱动的契约测试(Provider Driven Contract Test,即 PDCT)。其中提供者驱动的契约测试本质上和消费者驱动的契约测试的区别只是契约的制定和修改的流程,以及消费者端代码实现会有所区别,其他的基本是一样的。

假设一个基于前后端分离的和微服务的在线支付系统,其中微服务的数量有 10 多个,每个服务也有 10 多个 Web API,并且所有的后端的服务都通过 BFF(Back For Frontend)层来统一给前端应用提供服务,其中每个微服务是一个独立的服务团队进行开发和维护,而 BFF 层由前端。在这种项目中我们首先需要制定测试策略,在测试策略中就需要确定是不是需要做契约测试,如果确定了需要做契约测试,那么需要通过以下四步来实施契约测试:1,确定契约测试的范围和框架;2,确定契约测试的流程和规则;3,编写契约测试的代码并执行测试;4,管理契约测试。如果能有效的实施这四个步骤,那么契约便能发挥其功效,高效的保证大量微服务之间的交互正确性。

2. 实践步骤

2.1 确定契约测试的范围和框架

在这个假设的项目中,首先根据测试测试策略,确定了需要实施契约测试。然后需要确定契约测试的范围。理论上契约需要所有的 API 消费端和提供端都实施,但是由于本项目中 BFF 层是前端开发团队自己实施的,所以通过讨论前端团队可以内部在开发的时候保证统一编写和修改前端应用和 BFF 的相关代码。所以他们以 BFF 层作为消费端去驱动后端相关的所有微服务。然后又和后端的所有微服务团队讨论,他们同意编写契约测试,但是他们有些服务调用了第三方的其他服务,而这些服务是不属于项目开发,维护等可控制范围,所以无法实施契约。因此项目的契约测试实施范围为 BFF 与微服务之间,以及微服务与微服务之间。如果项目中前端应用和 BFF 分别是由两个不同的团队负责开发,那么它们两个也应该实施契约测试。

契约测试实施图:

其次 Pact 和消费者驱动的契约测试是最为常用的,并且本项目 BFF 层是基于 NodeJS,而后端的微服务是基于 Spring,所以经过讨论最终选择了 Pact 作为契约测试自动化框架,因为需要同时支持 JavaScript 和 Java 两门语言,并且可以通过 Pact Broker 提供微服务的调用关系图。而 Spring Cloud Contract 作为后起之秀,还有一些地方需要改进,比如只支持 Java,没有 Pack Broker 这样的集中化,图形化的契约中心化管理系统。

2.2 确定契约测试的流程和规则

在确定了契约测试范围和框架后,就需要制定契约测试的流程和规则,包括如何制定契约,谁来制定契约,如何变更契约,契约测试失败如何处理等等,并且还要保证每个团队都必须同意并遵守此流程和规则。首先是契约的制定,业务分析(Business Analyst)人员需要更具产品人员(Product Owner)提出来的需求确定前端系统上需要获取,展示或者给后端提供哪些数据,并与前端开发人员进行沟通。然后前端开发人员通过快速简单设计一版 API,并和后端微服务的开发人员进行讨论后,最终设计出来两边都认同的第一版 API 契约。然后前端系统开发人员就在 BFF 层基于这个契约编写消费者端(Customer)的契约测试,并且后端微服务开发者也基于这个契约编写提供端(Provider)的契约测试。

一般情况下第一版 API 的契约在软件系统开发过程中都需要进行更改。如果任意端对 API 的契约进行了更改,需要人工及时告知另外一端契约发生了更改,然后一起讨论并确认契约的变更,并更改相应的契约,双方再各自去更改自己的契约测试。如果一端由于各种原因而没有通知另外一方端,强行更改自己端的契约测试或者契约管理服务器上的契约文件,那么另外一端会在下次它执行契约测试的时候失败,从而实现了强制性的自动化通知。这个时候另外一端发现契约测试失败,就会查找原因,如果发现是由于契约文件的更改而造成的,就需要发起一次变更讨论来确定契约的变更,从而第一时间发现了 API 交互的问题。

由于本项目选择消费者驱动契约测试的方式,并基于 Pact 自动化测试框架来实施,所以契约文件是由消费者的契约测试代码自动化生成并给提供端,并且契约文件必须由消费端进行更改,提供端不能更改。因此如果消费端或者提供端需要更改契约文件,都需要两端经过讨论和协商后统一由消费端来更改契约文件。

基于消费者驱动契约测试的契约变更流程图:

2.3 编写契约测试的代码并执行测试

编写契约测试并不复杂,并且相对于编写单元测试,它开发的工作量是比较少的。并且由于契约测试都是基于 mock 的方式来,所以稳定性特别高,一般出现问题都是因为契约被改变,或者业务代码的改变导致无法满足契约了。

Consumer 端:

在假设项目中,如果编写 BFF 和 ServiceA 之间的契约测试,只需要在 BFF 层中,根据确定好的契约直接编写契约测试即可,不需要再手动建立什么 mock 服务,因为 pact 会在每次执行契约测试的自动帮你建立一个提供端的 mock 服务。并且每次契约测试完成后就会生成一个契约文件,然后放到一个统一的存储契约文件的地方。

测试示例代码如下:

    const provider = new Pact({
        consumer: "Consumer Example",
        provider: "Provider Example",
    })
    describe("Consumer Test", () => {
        before(() =>
            provider.addInteraction({
                state: "TestCase 1",
                uponReceiving: "a request to provider",
                withRequest: {
                method: "POST",
                path: "/provider",
                body: like(responseBody),
                headers: {
                    "Content-Type": "application/json; charset=utf-8",
                },
            },
            willRespondWith: {
                status: 200,
                headers: {
                    "Content-Type": "application/json; charset=utf-8",
                },
                body: like(responseBody),
                },
            })
        )
        it("Test case", done => {
            expect(callProviderService()).to.eventually.be.fulfilled.notify(done)
        })
    })

如果编写 ServiceA 和 ServiceC 之间的契约测试,(假设 ServiceA 调用 ServiceC)同样只需要在 ServiceA 中编写契约测试代码。

测试示例代码如下:

@RunWith(SpringRunner.class)
@SpringBootTest
public class ConsumerTest extends ConsumerPactTest {
@Autowired
ProviderService providerService;
@Override
@Pact(provider="Provider Example", consumer="Consumer Example")
public RequestResponsePact createPact(PactDslWithProvider builder) {
    Map<String, String> headers = new HashMap<String, String>();
    headers.put("Content-Type", "application/json;charset=UTF-8");
    return builder
        .given("TestCase 1")
        .uponReceiving("a request to provider")
            .path("/provider")
            .method("POST")
            .body(requestBody)
        .willRespondWith()
            .headers(headers)
            .status(200)
            .body(resonseBody)
    .toPact();
    }
@Override
protected void runTest(MockServer mockServer, PactTestExecutionContext context) {
    providerService.setBackendURL(mockServer.getUrl());
    providerService.callProviderService();
    }
}

Provider端:

其次在服务的提供端,编写契约测试要稍微复杂一点,首先要从统一存储契约文件的地方获取到契约文件,并且还需要固定测试数据,从而需要 mock 测试数据和 mock 被测服务的依赖服务,从而保证每次契约测试中 API 返回的 Response Body 的 Shcema 都不会改变。本项目中使用 Wiremock 来 Mock 被测试服务的依赖服务,使用 Spring MVC 来启动被测服务。

测试示例代码如下:

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Provider("Provider Example") // This name should match that defined on the consumer side
@PactBroker(url = "http://localhost:9292")
@AutoConfigureWireMock(port = 8979) // This port should match that defined on the remote call
public class ProviderTest {
    @LocalServerPort
    private int localPort;
    @Value("classpath:mockTestData.json")
    private Resource mockTestData;

    @BeforeEach
    void before(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", localPort));
        System.setProperty("pact.verifier.publishResults", "true");
    }

    @AfterEach
    void tearDown() {
        removeAllMappings();
    }

    @TestTemplate
    @ExtendWith(PactVerificationSpringProvider.class)
    void pactVerificationTestTemplate(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @State(value = {"TestCase 1"})
    void hasAnimalsStates() throws IOException {
        InputStream inputStream = mockTestData.getInputStream();
        stubFor(
            post("/provider")
                .willReturn(
                    aResponse()
                        .withHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
                        .withBody(inputStream.readAllBytes())
                )
            );
        inputStream.close();
    }
}

2.4 管理契约测试

完成了契约文件的设计,确定了契约测试的流程,编写完了契约测试的代码,接下来就需要对契约文件进行有效的管理、展示和持续执行。

1. 集中化管理契约

契约文件必须集中化管理,因为只有集中化管理,才能在一端更改了契约之后,另外一端通过契约测试的失败而知道契约测试被更改了,从而实现自动化的触发变更流程。

其次集中化管理契约文件有两种方法:1, 通过代码库进行管理;2,通过 Pact Broker 管理系统进行管理。首先通过代码库进行管理,即将契约文件统一放在公用的一个代码目录或者一个代码库中。其次通过 Pact Broker 进行管理,则是将契约文件通过代码的方式上传到 Pact Broker 进行管理。在本项目中,在消费端的契约测试代码里面,通过 Pact 的 Maven 或者 Gradle 插件则可以配置好 Pact Broker 的服务器地址。然后执行消费端的契约测试,如果契约测试成功通过了,此插件则可以把生成后的契约文件自动的上传到 Pact Broker 里面。

Broker 流程图:

2. 可视化展示服务的依赖和调用链

因为微服务的数量比较多,所以为了方便管理和维护,需要一个可视化的微服务依赖和调用链条图。而 Pact Broker 整个提供这个功能。只需要将所有微服务的契约文件上传到 Pact Broker 中,就可以通过其 Web 系统查看到所有微服务的依赖和调用链。

3. 自动化契约测试并加入持续构建流水线中

将契约测试直接加入到每次代码提交后的构建流水线中,可以有效的发现和防止破坏契约的业务代码,或者被另外一端破坏的契约,从而在第一时间发现这样的问题,而不是等到系统部署到测试系统后,通过回归测试发现这些问题。

3. 总结

我在不少项目中都尝试过实施契约测试,但是真正实施成功的并不多,主要原因还是规模和痛点不够大,从而导致团队觉得没有必要做,或者觉得做了收益比投入少。而成功的一般的都是团队人员足够痛,或者经历过大型多团队项目中服务改变等各种痛点,从而导致他们解决自己的痛点而主动实施契约测试,但是前提是他们都知道契约测试。所以要成功实施契约都是有两个主要的前提条件:1,团队对于相关问题(见理论篇)足够痛,2,团队懂契约测试。在这种情况下,团队才可能愿意主动实施契约测试,才能成功的实施契约测试。所以首先是要让开发团队懂契约测试,比如契约测试能解决什么问题,实施流程,相关测试框架等,然后等待团队无法忍受相关痛点后,成功的实施契约测试就可以水到渠成了。


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
270° /2700 人阅读/0 条评论 发表评论

登录 后发表评论