Skip to content

第45讲:消费者驱动的服务契约测试

本课时作为云原生微服务专栏的最后一个课时,将介绍如何进行消费者驱动的服务契约测试。

API 测试

在云原生微服务架构应用的开发中,一个很重要的问题是如何对单个微服务的 API 进行测试。第 10 课时介绍了 API 优先的设计策略,也就是从 OpenAPI 规范文档出发,让 API 的消费者和提供者可以并行工作。OpenAPI 规范成为 API 的消费者和提供者之间的契约,OpenAPI 规范文档通过消费者和提供者的协商和沟通来确定,这种方式虽然保证了 API 契约的稳定性,但是存在一个很大的问题,那就是如何验证提供者所实际提供的 API 满足契约的要求。

下图给出了一个微服务架构应用中的不同微服务之间的 API 调用关系,其中服务 A 需要调用服务 B 和 D 的 API。当需要测试服务 A 时,一种做法是在所有服务都部署之后,再进行集成测试。这种做法的问题是测试环境的搭建很复杂,除了每个服务自身之外,还需要运行其他支撑服务。

另外一种做法是为服务 B 和 D 分别创建模拟对象(Mock),由 Mock 来模拟服务 B 和 D 的功能。这种做法的好处是运行测试的环境简单,测试的执行速度也很快,也是一般使用的做法。

模拟对象一般由 API 的消费者来创建,比如,服务 A 需要创建服务 B 和 D 的模拟对象。由于模拟对象由 API 消费者来创建,所以模拟对象反映的是消费者对于 API 的理解,与 API 提供者对 API 的理解可能存在偏差。当服务 A 完成测试,并与服务 B 和 D 进行集成时,可能会发现在运行时出现错误。这种问题的出现,会大大降低服务 A 的测试的可信度。

消费者驱动的契约

消费者驱动的契约(Consumer Driven Contract)是一种 API 开发的实践,把行为驱动开发的思想应用到了 API 的设计中,消费者驱动的含义是由 API 的消费者来驱动 API 的设计。如果 API 的目的是满足消费者的需求,那么 API 的消费者对于 API 有决定权,包括 API 中包含的全部路径,以及每个路径的请求和响应的内容格式。API 的提供者需要按照消费者指定的契约,来完成 API 的具体实现。

消费者驱动的契约的不足之处在于,它并不适合于开放 API 的设计,因为开放 API 有非常多的消费者,不太可能为了单个消费者而做出改变。微服务之间的 API 则没有这个限制,可以使用消费者驱动的方式来设计。

本课时通过 Spring Cloud Contract 来说明消费者驱动的契约的做法。Spring Cloud Contract 的特点是从声明式的契约中自动创建出可执行的存根代码,以及测试用例。

在下图中,服务 B 和 D 分别被替换成相应的存根,测试运行起来更简单。

本课时所介绍的示例与专栏所使用的示例存在一定的关联性,但是对应用的场景进行了简化。本课时的示例中各有一个 API 的提供者和消费者,API 的提供者是验证行程的服务,而消费者是行程管理服务。当行程管理服务接收到创建行程的请求时,需要调到行程验证服务来进行验证,并根据返回的结果来进行不同的处理。这个示例的场景非常简单,可以展示 Spring Cloud Contract 的用法。完整的代码请在 GitHub中下载。

从实际的业务场景来说,API 的提供者和消费者之间的契约很简单,在验证行程时,需要提供行程的金额数量,返回的结果则说明行程是否有效。当金额小于或等于 1000 时,行程被认为有效;否则,行程被认为是无效的。虽然场景简单,但实际的契约需要更加具体的描述。如果以 API 优先的方式来设计,会先从 OpenAPI 文档规范开始。下面我们来看一下如何使用消费者驱动的契约来完成。

创建契约

首先需要由消费者来创建契约,契约并不是以 OpenAPI 规范文档这样的形式化方式来描述,而只是一些请求和对应的响应示例,每个示例称为一个契约。API 的消费者根据需求,定义出需要使用的 API 路径、请求和响应的内容。从这里就可以看出契约和 OpenAPI 规范的最大区别:OpenAPI 规范描述的是 API 的请求和响应内容的格式,而契约则描述的是具体的请求和期望的响应实例。

下面的代码给出了行程验证服务的 OpenAPI 规范文档的声明,其中包含了一个路径 /trip_validation,以及 POST 方法的请求格式和响应格式。从这个规范中我们可以看到,表示请求的 TripValidationRequest 类型中有一个 double 类型的 amount 属性,而表示响应的 TripValidationResponse 类型中有一个 boolean 类型的 valid 属性。很明显,表示金额的 amount 属性的值,与作为结果的 valid 属性之间,存在业务逻辑上的对应关系,但是这个对应关系没有体现在 OpenAPI 文档规范中。

yaml
openapi: '3.0.3' 
info: 
  title: 行程验证服务 
  version: '1.0' 
servers: 
  - url: http://localhost:8090/ 
tags: 
  - name: trip 
    description: 行程 
paths: 
  /trip_validation: 
    post: 
      tags: 
        - trip 
      summary: 验证行程 
      operationId: validateTrip 
      requestBody: 
        content: 
          application/json: 
            schema: 
              $ref: "#/components/schemas/TripValidationRequest" 
        required: true 
      responses: 
        '200': 
          description: 验证结果 
          content: 
            application/json: 
              schema: 
                $ref: "#/components/schemas/TripValidationResponse" 
   
components: 
  schemas: 
    TripValidationRequest: 
      type: object 
      properties: 
        amount: 
          type: number 
          format: double 
      required: 
        - amount 
    TripValidationResponse: 
      type: object 
      properties: 
        valid: 
          type: boolean 
      required: 
        - valid

与 OpenAPI 规范不同,契约描述的是请求和响应的示例。我们可以使用 Groovy 或 YAML 来描述契约,推荐使用 Groovy,因为可以使用 IDE 来进行代码提示和编译检查。下面是一个契约的 Groovy 代码的示例。

Groovy 文件的名称是 tripValidationPassed.groovy,并保存在 API 提供者项目的 src/test/resources/contracts/trip 目录中。根据命名惯例,契约文件被存放在 src/test/resources/contracts 目录中,不同的子目录代表不同的功能集。

Groovy 文件通过 DSL 来描述请求和响应的契约。Contract.make 方法用来创建新的 Contract 对象。在 DSL 中,request 描述 HTTP 请求的详情,包括 HTTP 方法、URL、请求内容和 HTTP 头;response 描述 HTTP 响应的详情,包括 HTTP 状态码、响应内容和 HTTP 头。该契约描述的场景是,当请求的 amount 属性的值为 999 时,返回的响应中的 valid 属性的值为 true。

groovy
package contracts.trip 
import org.springframework.cloud.contract.spec.Contract 
Contract.make { 
  request { 
    method 'POST' 
    url '/trip_validation' 
    body([ 
        amount: 999.00 
    ]) 
    headers { 
      contentType('application/json') 
    } 
  } 
  response { 
    status OK() 
    body([ 
        valid: true 
    ]) 
    headers { 
      contentType('application/json') 
    } 
  } 
}

与上述契约相似的是另外一个名为 tripValidationFailed.groovy 的契约,用来描述行程验证不通过的场景,该契约的请求中 amount 的值为 1100,而响应中 valid 的值为 false。

需要注意的是,我们只添加了两个契约,分别对应行程验证的结果为有效和无效这两种情况。在契约中所指定的 amount 的具体值并不重要,只需要满足所限定的条件即可。

生成存根代码

契约被添加在 API 提供者项目中,我们接着从契约中生成存根代码。存根代码的生成由 Spring Cloud Contract 的 Maven 插件来完成,下面的代码展示了如何配置该 Maven 插件。

xml
<plugin> 
  <groupId>org.springframework.cloud</groupId> 
  <artifactId>spring-cloud-contract-maven-plugin</artifactId> 
  <version>${spring-cloud-contract.version}</version> 
  <extensions>true</extensions> 
  <configuration> 
    <testFramework>JUNIT5</testFramework> 
    <packageWithBaseClasses>io.vividcode.contract.trip 
    </packageWithBaseClasses> 
  </configuration> 
</plugin>

在运行了 mvn clean install -DskipTests 命令之后,会在 targets 目录生成一个带 stubs 后缀的 JAR 文件,该文件就是生成的存根代码,它被安装到了本地 Maven 仓库中。

API 消费者的测试

我们再回到 API 的消费者,也就是行程管理服务,这其中的 REST 控制器如下面的代码所示。在 createTrip 方法中,首先使用 TripService 来计算出行程的费用金额,再调用行程验证服务的 API 来进行验证,最后根据验证结果返回不同的响应。行程验证服务的 URL 通过配置项 trip_validation.url 来指定。

java
@RestController 
public class TripController { 
  @Value("${trip_validation.url:}") 
  String tripValidationServiceUrl; 
  @Autowired 
  TripService tripService; 
  @Autowired 
  RestTemplate restTemplate; 
  @PostMapping(value = "/trip", consumes = MediaType.APPLICATION_JSON_VALUE) 
  public ResponseEntity<Response<CreateTripData>> createTrip( 
      @RequestBody CreateTripRequest request) { 
    double amount = this.tripService 
        .calculate(request.getStart(), request.getEnd()); 
    ResponseEntity<TripValidationResponse> response = this.restTemplate 
        .exchange(RequestEntity 
                .post( 
                    URI.create(this.tripValidationServiceUrl + "/trip_validation")) 
                .contentType(MediaType.APPLICATION_JSON) 
                .body(new TripValidationRequest(amount)), 
            TripValidationResponse.class); 
    if (response.getBody() != null 
        && response.getBody().isValid()) { 
      return ResponseEntity.ok(Response 
          .success(new CreateTripData(UUID.randomUUID().toString()))); 
    } 
    return ResponseEntity.ok(Response.error(new Error(100, "invalid trip"))); 
  } 
}

在 API 消费者项目中,我们添加对 TripController 的测试用例,测试用例的完整代码如下所示。该测试用例是一个标准的 Spring Boot 测试,在测试时,会在随机端口启动 Spring Boot 服务器,并通过 REST Assured 发送 HTTP 请求到 TripController,然后验证响应的内容是否正确。变量 serverPort 表示的是 Spring Boot 服务器实际运行的端口。

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL, ids = "io.vividcode:producer") 
@DisplayName("Trip validation") 
public class TripValidationTest { 
  @Autowired 
  TripController tripController; 
  @LocalServerPort 
  int serverPort; 
  @StubRunnerPort("producer") 
  int producerPort; 
  @BeforeEach 
  public void setupProducer() { 
    this.tripController.tripValidationServiceUrl = 
        "http://localhost:" + this.producerPort; 
  } 
  @Test 
  @DisplayName("validation success") 
  public void testTripValidationSuccess() { 
    given() 
        .body(new CreateTripRequest("test1", "test", "normal")) 
        .contentType("application/json") 
        .post("http://localhost:" + this.serverPort + "/trip") 
        .then() 
        .statusCode(200) 
        .body("status", is("SUCCESS")); 
  } 
  @Test 
  @DisplayName("validation failed") 
  public void testTripValidationFailed() { 
    given() 
        .body(new CreateTripRequest("test1", "test", "long")) 
        .contentType("application/json") 
        .post("http://localhost:" + this.serverPort + "/trip") 
        .then() 
        .statusCode(200) 
        .body("status", is("ERROR")); 
  } 
}

在这个测试用例中,我们用到了一个特殊的注解 @AutoConfigureStubRunner 来运行契约对应的存根代码,该注解的属性 stubsMode 表示的是获取存根代码的方式,LOCAL 表示从本地 Maven 仓库中获取,而 REMOTE 则表示从远程 Maven 仓库中获取。属性 ids 表示的是存根代码的 Maven 工件的标识符,当不指定版本号时,默认使用最新版本的工件,属性 ids 的值与 API 提供者相对应。

在测试的运行过程中,Spring Cloud Contract 会启动一个 WireMock服务器,并把契约中声明的请求和响应规则,添加到 WireMock 中。当 TripController 访问行程验证服务时,实际访问的是 WireMock 服务器,而 WireMock 会根据契约来返回不同请求对应的响应。由于 WireMock 会在随机端口启动,@StubRunnerPort 注解用来获取实际运行的端口,并绑定到字段 producerPort 中。在进行测试之前,会使用 producerPort 字段来设置 TripController 所连接的行程验证服务的 URL。从底层的实现来说,Spring Cloud Contract 实际上把契约文件,转换成了 WireMock 的 JSON 配置文件,由 WireMock 模拟 API 提供者的行为。

为了运行存根代码,需要添加下面代码中给出的 Maven 依赖。

xml
<dependency> 
  <groupId>org.springframework.cloud</groupId> 
  <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> 
  <scope>test</scope> 
  <exclusions> 
    <exclusion> 
      <groupId>junit</groupId> 
      <artifactId>junit</artifactId> 
    </exclusion> 
  </exclusions> 
</dependency>

使用 mvn test 命令就可以进行 API 消费者的测试,通过这种方式,API 消费者可以确保遵循了契约来调用 API。

API 提供者的测试

我们接着需要确保 API 的提供者正确的实现了契约。下面代码给出了行程验证服务的控制器的实现,该控制器的实现非常简单,只是根据 amount 的值来进行判断。

java
@RestController 
public class TripValidationController { 
  @PostMapping("/trip_validation") 
  public TripValidationResponse validate( 
      @RequestBody TripValidationRequest request) { 
    boolean valid = request.amount <= 1000; 
    return new TripValidationResponse(valid); 
  } 
}

我们可以使用 Spring Cloud Contract 的 Maven 插件来从契约中自动生成测试用例,只需要使用 mvn generate-test-sources 命令即可。在生成之前,首先需要创建一个类作为生成的测试用例的基类。

下面代码中的 TripBase 类是行程验证服务的测试的基类。在 setup 方法中,通过 RestAssuredMockMvc 的 standaloneSetup 方法来配置需要访问的控制器对象;基类的名称 TripBase 是通过命名惯例得到的,其中的后缀 Base 是固定的,而 Trip 来自契约文件所在的目录的名称。

java
public class TripBase { 
  @BeforeEach 
  public void setup() { 
    RestAssuredMockMvc.standaloneSetup(new TripValidationController()); 
  } 
}

下面代码中的 TripTest 是自动生成的测试类,其中的每个测试方法对应于一个契约。在每个测试中,根据契约中声明的请求和响应的内容,REST Assured 会发送请求到控制器,并验证响应的内容。

java
public class TripTest extends TripBase { 
 @Test 
 public void validate_tripValidationFailed() throws Exception { 
  // given: 
   MockMvcRequestSpecification request = given() 
     .header("Content-Type", "application/json") 
     .body("{\"amount\":1100.00}"); 
  // when: 
   ResponseOptions response = given().spec(request) 
     .post("/trip_validation"); 
  // then: 
   assertThat(response.statusCode()).isEqualTo(200); 
   assertThat(response.header("Content-Type")).matches("application/json.*"); 
  // and: 
   DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); 
   assertThatJson(parsedJson).field("['valid']").isEqualTo(false); 
 } 
 @Test 
 public void validate_tripValidationPassed() throws Exception { 
  // given: 
   MockMvcRequestSpecification request = given() 
     .header("Content-Type", "application/json") 
     .body("{\"amount\":100.00}"); 
  // when: 
   ResponseOptions response = given().spec(request) 
     .post("/trip_validation"); 
  // then: 
   assertThat(response.statusCode()).isEqualTo(200); 
   assertThat(response.header("Content-Type")).matches("application/json.*"); 
  // and: 
   DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); 
   assertThatJson(parsedJson).field("['valid']").isEqualTo(true); 
 } 
}

通过自动化单元测试的做法,我们可以保证 API 提供者的实现,满足消费者所声明的契约的要求。考虑下面一种情况,如果 API 提供者认为行程金额的上限 1000 的值过高,而需要改成 900,那么可以修改 TripValidationController 的实现,把其中的一行修改成如下所示:

java
boolean valid = request.amount <= 900;

再运行测试时会发现测试失败,产生的错误如下所示。从错误中可以看到,契约中的声明是,当 amount 值为 999 时,响应中的 valid 的值应该为 true,而 TripValidationController 实际返回的响应中的 valid 的值是 false,这就表示了契约被破坏。

html
[ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 1.767 s <<< FAILURE! - in io.vividcode.contract.trip.TripTest 
[ERROR] validate_tripValidationPassed  Time elapsed: 0.033 s  <<< ERROR! 
java.lang.IllegalStateException: Parsed JSON [{"valid":false}] doesn't match the JSON path [$[?(@.['valid'] == true)]] 
	at io.vividcode.contract.trip.TripTest.validate_tripValidationPassed(TripTest.java:56)

契约被破坏的原因有很多,有可能是 API 提供者的业务逻辑产生了变化,也有可能是 API 提供者的实现产生了 bug。如果是前者,那么契约应该被更新,以反映业务逻辑的变化;如果是后者,则 API 提供者应该修改自身的实现。不管是哪种情况,契约的存在,以及确保契约被遵守的自动化测试,都可以保证 API 的稳定性。

工作模式

最后介绍一下使用消费者驱动的契约时的工作模式,契约虽然是保存在 API 提供者项目中,但是由 API 消费者来创建的。消费者团队的开发人员可以直接对提供者的项目进行修改,也可以通过 Pull Request 的方式来提交改动,这就明确了消费者对契约的所有权。

在实际的开发中,契约的存根 JAR 文件会被发布到远程的 Maven 仓库中,这样保证了团队的所有人员都可以使用存根代码来运行测试。每次对契约进行了修改之后,只需要重新运行 Maven 插件来生成存根代码即可。存根代码有自己的版本,可以追踪变化的历史记录。

总结

微服务架构的应用中的集成测试是一个很复杂的问题,使用消费者驱动的服务契约测试,可以很好地解决测试的运行速度和可靠性的问题。通过本课时的学习,你可以了解消费者驱动的契约测试的基本概念,以及如何用 Spring Cloud Contract 来实现。

最后呢,成老师邀请你为本专栏课程进行结课评价,因为你的每一个观点都是我们最关注的点。 点击链接,即可参与课程评价