前言

第一次分享 契约测试,虽然已经足足过去了3年多了,我也换了一个公司,换了N个团队了,但是并没有一个团队有在使用契约测试的,甚至当我在团队再次分享这个东西时,发现听过这个名词的人都寥寥无几。虽然我们没用,但也不代表我们不能偶尔抬头望望天空。

学习目标

  1. 认识单体和微服务,并能明确知道单体服务的缺点和微服务带来的挑战
  2. 契约测试基本概念
  3. 契约测试解决什么问题
  4. PACT 的基础使用

服务类型介绍

单体服务

名词解析

所有的功能在一个服务中实现,使用同一个数据库,模块与模块间通过各种方法内部调用。
image.png

单体服务的缺点

研发:

  • 系统复杂度高,可读性和可维护性差;

单体服务把所有的模块放一个工程开发,随着模块增加,业务复杂度增加,模块与模块之间的互相调用越来越多,这时对于一个新接手项目的研发来说简直是天灾。 一来代码量巨大,二来如果模块包管理不合理可能都找不到某个模块对应的代码,三来模块间耦合度高了,往往牵一发而动全身。

  • 技术栈单一,后期扩展升级难;

如上提到,单体服务大多是放一个代码工程里面,一个代码工程通常会是使用一种特点的开发语言,而需要使用其它语言则变得很难,例如Java 工程,但是某个模块并发量巨大,想通过go来实现,这在单体服务是无做到。
测试:

  • 耦合度高,可靠性差,一个bug可能影响整个应用;

上面提高单体对研发来说复杂度高,对测试来说就是让测试变得很难,很难评估影响范围。
运维:

  • 部署频率高,部署时间长

任何一个模块变更,都需要真个应用部署。不仅频率搞,而且时长久。往往打包都需要消耗很多时间,打包后的jar上次到制品库等又因为jar包大而效率低。 所以单体服务时部署可能会考虑到增量部署或者全量部署。

  • 合并工作量大
  • 服务扩展性、伸缩性差

假设应用有用户管理模块,下单模块,支付模块,商品模块。 随着发展,可能商品模块出现了性能瓶颈,需要增加机器和服务。但因为单体服务把所有模块放一个服务中,那就扩展时就不得不同时扩张了商品模块外的模块。

  • 等等

    微服务

    因为单体服务存在这么多的问题,所以就发展出了微服务。

    名词解析

    微服务架构将功能进行拆分,拆分成各种小服务单独实现,每个服务都可以有自己独立的数据库,服务与服务之间通过接口互相调用。
    image.png

    微服务的优点

    微服务的优点 其实即便上就是对应上了单体服务的缺点 。

  • 职责单一,分工明细,易于理解和维护

  • 低耦合,服务之间的相互影响小
  • 可以采用不同的架构,语言,存储,容易扩展
  • 开发独立,部署独立
  • 扩展性强,伸缩性好

    微服务带来的挑战

    微服务在解决单体服务的问题同时,也给我们研发和测试带来了新的挑战。

    挑战

  • 服务之间依赖复杂

单体是方法和方法之间的依赖,现在是服务和服务之间的依赖,这个依赖可以清晰很多,但依旧是非常复杂的,如果拉出公司的服务依赖图,会是一张毫无规则,错综复杂的网状图。

  • 不同服务可能采用不同的开发语言,架构,存储等技术

如果不同服务才用了不同的技术栈,在满足一些特定场景需求同时也提高了难度,毕竟并发所有研发工程师或者测试工程师均熟悉各种技术栈。

  • 服务接口数量庞大,调用方多,变更会影响上下游,且常不及时通知

上游的变动,如果不及时通知下游或者让下游感知,则进程出现上游改动,下游报错的问题。

  • 参数组合多,测试易遗漏

往往一个服务不仅仅是给一个服务使用,而可能给多个服务使用,每个服务可能都会有不同的需求,则这时服务提供方的接口可能就会包含多个使用方需要的入参和结果返回,那对测试来说就是入参多了,组合的用例多了,场景多了,测试容易遗漏场景。

  • 联调需要等待各个团队全部准备就绪

微服务,往往不同的服务是不同的开发团队开发,联调需要等各个团队都完成开发。

  • 往往多个团队完成开发,测试,团队间沟通成本高

微服务把服务拆分到多个团队实现,团队和团队间肯定存在一定的墙,虽然会尽可能的去拆墙,但沟通成本等肯定更大。

  • 联调阶段发现各种不按定义来开发,入参数名不对、用错字段等各种低级错误

虽然大部分情况下,服务提供者和服务使用者都会一起确定两者之间的接口定义,但服务提供者可能会因为研发过程并未能完全按之前定义来开发,最后联调一样会存在各种各样的问题。

现有解决方案

凡事都有利有弊,扬长避短,QA面对微服务的挑战,为了保证质量,我们首先参试了

保障质量 – 集成测试

给我们提供保证质量的信心,但

  • 强依赖
  • 反馈不及时
  • 容易破坏
  • 需要大量人力检查

image.png

提高效率 - 使用Mock

给我们提供了独立运行,快速反馈,稳定,且容易维护,但无法给我们质量的信心。
image.png

问题和痛点

集成测试:提高了我们的测试上线信心,但受依赖影响。
Mock:独立、快速、稳定,但API调用方对API提供方的变更经常需要通过对API的测试来感知。
那鱼和熊掌真的就不可兼得?我们要鱼还是要熊掌?
小孩子才做选择,我们大人了,我们全都要。

契约测试

解决方案

解决方式首先是依赖关系的解耦,去掉直接对外部API的依赖,二是内部和外部系统都依赖于一个双方共同认可的约定—“契约”,并且约定内容的变化会被及时感知;其次,将系统之间的集成测试,转换为由契约生成的单元测试,例如通过契约描述的内容,构建测试替身。这样,同时契约替代外部API成为信息变更的载体。
image.png

概念解析

契约测试:契约测试也叫消费者驱动测试。

两个角色

消费者(Consumer)和 生产者(Provider)

一个思想

需求驱动(消费者驱动)

一个文件

由Consumer端和Provider端共同定义的规范,包含API路径,输入,输出。通常由Consumer生成。

实现原理

Consumer 端提供一个类似“契约”的东西(如json 文件,约定好request和response)交给Provider 端,告诉Provider 有什么需求,然后Provider 根据这份“契约”去实现。

PACT 框架介绍

市面上支持做契约的框架有很多,例如 Spring Cloud Contract 、Pacto、Webmock、VCR等等,而这里主要介绍PTCA ,主要原因它应该是最早的契约测试框架,同时支持支持多种开发语言。而更多框架对比可以阅读:

什么是PACT

Pact 是实现契约测试的框架之一,最早由 REA 公司(一家澳大利亚房产门户网站),为克服在微服务演进过程中面临的服务间测试问题而开发。
Pact 主要支持服务间 RESTful 接口的验证。经过几年的发展,Pact 已经支持 Ruby、Java、Python、JS 、Go等多种语言。

DEMO

这里以Java8 + junit5 为案例。 查考

消费者添加依赖

  1. <dependency>
  2. <groupId>au.com.dius</groupId>
  3. <artifactId>pact-jvm-consumer-junit5</artifactId>
  4. <version>4.0.10</version>
  5. <scope>test</scope>
  6. </dependency>
  7. <dependency>
  8. <groupId>au.com.dius</groupId>
  9. <artifactId>pact-jvm-consumer-java8</artifactId>
  10. <version>4.0.10</version>
  11. </dependency>
  12. <plugin>
  13. <groupId>org.apache.maven.plugins</groupId>
  14. <artifactId>maven-surefire-plugin</artifactId>
  15. <version>2.22.2</version>
  16. <configuration>
  17. <systemPropertyVariables>
  18. // 设定pact 生成的契约文件相对于工程的存放路径
  19. <pact.rootDir>./pacts</pact.rootDir>
  20. </systemPropertyVariables>
  21. </configuration>
  22. </plugin>

生产者添加依赖

<dependency>
    <groupId>au.com.dius</groupId>
    <artifactId>pact-jvm-provider-junit5</artifactId>
    <version>4.0.10</version>
    <scope>test</scope>
</dependency>

消费者定义

  • @ExtendWith({PactConsumerTestExt.class}) 放于类上,指定通过pact方式执行
  • @PactTestFor(providerName = “user”, port = “8586”) 定义一个Mock,端口和host不填则随机,只要是生产者是 user 的则走mock数据
  • @Pact(provider = “user”, consumer = “queryUser”) 定义一个契约,消费者名字叫 queryUser 生产者叫user ```java import au.com.dius.pact.consumer.dsl.PactDslWithProvider; import au.com.dius.pact.consumer.junit5.PactConsumerTestExt; import au.com.dius.pact.consumer.junit5.PactTestFor; import au.com.dius.pact.core.model.RequestResponsePact; import au.com.dius.pact.core.model.annotations.Pact; import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.serializer.SerializerFeature; import com.pact.demo.entity.get.object.UserInformationDto; import io.pactfoundation.consumer.dsl.LambdaDsl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.web.client.RestTemplate;

import java.util.HashMap; import java.util.Map;

@ExtendWith({PactConsumerTestExt.class}) @PactTestFor(providerName = “user”, port = “8586”) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) public class ConsumerTest {

RestTemplate restTemplate;

@BeforeEach
public void initialRestTemplate() {
    restTemplate = new RestTemplate();
}

private Map<String, String> jsonHeader() {
    Map<String, String> map = new HashMap<>(100);
    map.put("Content-Type", "application/json;charset=UTF-8");
    return map;
}

@Pact(provider = "user", consumer = "queryUser")
public RequestResponsePact retrieveUserTask(PactDslWithProvider builder) {
    return builder
        //  given 对应生成的契约文件中的 name
        // 通过DSL 的语法定义了接口的地址,请求方式,入参和出参
            .given("查询用户数据")
            .uponReceiving("用户查询的描述")
            .path("/user-tasks/1")
            .method("GET")
            .willRespondWith()
            .status(200)
            .body(

                    LambdaDsl.newJsonBody(o -> o
                            .numberValue("id", 1)
                            .stringValue("company", "TEST")
                            .booleanValue("flag", true)
                            .numberType("phoneNumber")
                            .stringType("address")
                            .booleanType("delete")

                            .stringMatcher("code", "[A-Z]{3}\\d{2}")
                    ).build())
            .headers(jsonHeader())
            .toPact();
}

// 因为类添加了@PactTestFor注解, 消费者发起一个 http 请求时, 并不会真实到生产者,而是会走mock,根据上面设定的reponse body 返回。 
@Test
public void runTestRetrieveUserTask() {
    String a = JSONObject.toJSONString(
            restTemplate.getForObject("http://localhost:8586/user-tasks/{id}", UserInformationDto.class, 1),
            SerializerFeature.PrettyFormat,
            SerializerFeature.WriteDateUseDateFormat,SerializerFeature.WriteMapNullValue,
            SerializerFeature.WriteNullListAsEmpty);
    System.out.println(a);
}

}

执行后可以获取到一个 消费者-生产者.json 的契约文件,例如上面生成 queryUser-user.json文件并放于 pacts文件夹下:<br />json 文件定义了路径、请求方法、入参、出差和参数校验规则等。
```json
{
  "provider": {
    "name": "user"
  },
  "consumer": {
    "name": "queryUser"
  },
  "interactions": [
    {
      "description": "用户查询的描述",
      "request": {
        "method": "GET",
        "path": "/user-tasks/1"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json;charset\u003dUTF-8"
        },
        "body": {
          "flag": true,
          "phoneNumber": 100,
          "address": "string",
          "code": "ZTS88",
          "company": "TEST",
          "id": 1,
          "delete": true
        },
        "matchingRules": {
          "body": {
            "$.phoneNumber": {
              "matchers": [
                {
                  "match": "number"
                }
              ],
              "combine": "AND"
            },
            "$.address": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            },
            "$.delete": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            },
            "$.code": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "[A-Z]{3}\\d{2}"
                }
              ],
              "combine": "AND"
            }
          },
          "header": {
            "Content-Type": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "application/json(;\\s?charset\u003d[\\w\\-]+)?"
                }
              ],
              "combine": "AND"
            }
          }
        },
        "generators": {
          "body": {
            "$.phoneNumber": {
              "type": "RandomInt",
              "min": 0,
              "max": 2147483647
            },
            "$.address": {
              "type": "RandomString",
              "size": 20
            },
            "$.code": {
              "type": "Regex",
              "regex": "[A-Z]{3}\\d{2}"
            }
          }
        }
      },
      "providerStates": [
        {
          "name": "查询用户数据"
        }
      ]
    }
  ],
  "metadata": {
    "pactSpecification": {
      "version": "3.0.0"
    },
    "pact-jvm": {
      "version": "4.0.4"
    }
  }
}

生成者定义

生产者类似单测一样来校验自己编写的接口是否满足消费者的定义。

  • @Provider(“user”) 设置测试中的 生产者名称, 需要与消费者定义的名字一样,也就是 @Pact注解
  • @PactFolder(“pacts”) 指定契约文件的路径
  • @State(“查询用户数据”) value 必须跟消费者定义的 given 值一致
  • @TestTemplate @ExtendWith(PactVerificationInvocationContextProvider.class) 用于验证生产者生成是否满足消费者 ```java

import au.com.dius.pact.provider.junit.Provider; import au.com.dius.pact.provider.junit.State; import au.com.dius.pact.provider.junit.loader.PactBroker; import au.com.dius.pact.provider.junit.loader.PactFolder; import au.com.dius.pact.provider.junit5.HttpTestTarget; import au.com.dius.pact.provider.junit5.PactVerificationContext; import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider; import com.pact.demo.entity.get.object.UserInformationDto; import com.pact.demo.service.get.object.UserTaskService; import org.apache.http.HttpRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.web.server.LocalServerPort;

import static org.mockito.Mockito.doReturn;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Provider(“user”) @Tag(“ContractTest”) @PactFolder(“/Users/meyoung/Documents/IdeaProjects/contract-test/pacts”) public class ProviderTest { @LocalServerPort int localServerPort;

@MockBean
UserTaskService userTaskService;

@BeforeEach
void setupTestTarget(PactVerificationContext context) {
    context.setTarget(new HttpTestTarget("localhost", localServerPort, "/"));
}

@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context, HttpRequest request) {
    context.verifyInteraction();
}

@State("查询用户数据")
public void retrieveUserTaskVerify() {
    UserInformationDto expectUserTaskDto = UserInformationDto.builder()
            .id(1)
            .company("TEST")
            .flag(true)

            .phoneNumber(1234222256)
            .address("address test")
            .delete(false)

            .code("ABC01")

            .build();
    doReturn(expectUserTaskDto).when(userTaskService).findById(1);
}

}


<a name="X0y0y"></a>
### 官方文档
帮助文档: [https://docs.pact.io/](https://docs.pact.io/)

<a name="E0GAM"></a>
## PACT Broker
通常情况下,消费者和生产者不是一个团队开发,或者通过 git 等一类去管理消费者产生的契约文件也非常麻烦,所以PACT 还提供了Broker 来管理生成的json文件。 
<a name="HnVhN"></a>
### Broker 安装
可以docker compose 方式安装, yml文件如下
```yaml
version: "3"

services:
  postgres:
    image: postgres
    healthcheck:
      test: psql postgres --command "select 1" -U postgres
    volumes:
      - postgres-volume:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: postgres

  pact-broker:
    image: pactfoundation/pact-broker:2.98.0.0
    ports:
      - "9292:9292"
    depends_on:
      - postgres
    environment:
      PACT_BROKER_PORT: '9292'
      PACT_BROKER_DATABASE_URL: "postgres://postgres:password@postgres/postgres"
      PACT_BROKER_LOG_LEVEL: INFO
      PACT_BROKER_SQL_LOG_LEVEL: DEBUG
      # PACT_BROKER_DATABASE_CONNECT_MAX_RETRIES is only needed for docker-compose
      # because the database takes longer to start up than the puma process
      # Should not be needed in production.
      PACT_BROKER_DATABASE_CONNECT_MAX_RETRIES: "5"
      # The list of allowed base URLs (not setting this makes the app vulnerable to cache poisoning)
      # This allows the app to be addressed from the host from within another docker container correctly
      PACT_BROKER_BASE_URL: 'https://localhost http://localhost http://localhost:9292 http://pact-broker:9292'

  # Nginx is not necessary, but demonstrates how
  # one might use a reverse proxy in front of the broker,
  # and includes the use of a self-signed TLS certificate
  nginx:
    image: nginx:alpine
    depends_on:
      - pact-broker
    volumes:
      - ../nginx/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./ssl:/etc/nginx/ssl
    ports:
      - "443:443"
      - "80:80"
volumes:
  postgres-volume:
maven 插件

消费者添加插件

  • packBrokerUrl 设置 broker 的地址
  • pactDirectory 设置生成的文件路径
              <plugin>
                  <groupId>au.com.dius</groupId>
                  <artifactId>pact-jvm-provider-maven</artifactId>
                  <version>4.0.10</version>
                  <configuration>
                      <pactBrokerUrl>http://localhost:9292/</pactBrokerUrl>
                      <pactDirectory>./pacts</pactDirectory>
                  </configuration>
              </plugin>
    

    上传契约文件

    通过插件提供的 指令:publish 上传
    image.png

    broker 查看契约信息

    通过broker站点可以查看到接口文档信息:
    image.png
    服务间调用关系:
    image.png
    等等

    生产者使用broker

  1. 修改 @PactFolder 注解为 @PactBroker(host = “localhost”,port = “9292”) 并设定broker host和端口
  2. 常规执行单测便可

    契约测试的价值

  • 一致性,保证生产者和消费者遵循契约
  • 提供Mock
  • 测试前移,不用等消费者和生产者双方都准备就绪,可以在任一方完成便可以进行测试
  • 提供了便于维护的接口文档
  • 避免了低级错误,提高开发和测试的效率
  • 消费者驱动,一方变动另一方得以感知
  • 等等