Skip to content

第13讲:如何基于REST服务实现集成测试

本课时将介绍"如何实现 REST 服务的集成测试"。

在第 12 课时中,介绍了微服务实现中的单元测试。单元测试只对单个对象进行测试,被测试对象所依赖的其他对象,一般使用 mock 对象来代替,单元测试可以确保每个对象的行为满足对它的期望。不过,这些对象集成在一起之后的行为是否正确,则需要由另外的测试来验证。这就是集成测试要解决的问题,与单元测试不同的是,集成测试一般由测试人员编写。本课时将介绍如何进行服务的集成测试。

集成测试的目标是微服务本身,也就是说,把整个微服务看成是一个黑盒子,只通过微服务暴露的接口来进行测试,这个暴露的接口就是微服务的 API。对微服务的集成测试,实际上是对其 API 的测试。由于本专栏的微服务使用的是 REST API,所以着重介绍 REST API 的集成测试。

REST API 的测试本身并不复杂,只需要发送特定的 HTTP 请求到 REST API 服务器,再验证 HTTP 响应的内容即可。在 Java 中,已经有 Apache HttpClient 和 OkHttp 这样的 HTTP 客户端,可以帮助在 Java 代码中发送 HTTP 请求。在测试中,推荐使用 REST-assured 这样的工具来验证 REST API。

我们可以利用 Spring Boot 提供的集成测试支持来测试微服务的 REST API。在进行测试时,Spring Boot 可以在一个随机端口启动 REST API 服务器来作为测试的目标。本课时选择的测试目标是乘客管理服务的 REST API。

下面介绍 3 种不同的测试 REST API 的方式。

手动发送 HTTP 请求

我们可以用 Spring Test 提供了的 WebTestClient 来编写 REST API 的验证代码。下面代码中的 PassengerControllerTest 类是乘客管理服务 REST API 的测试用例。

PassengerControllerTest 类上的注解与第 12 课时中出现的 PassengerServiceTest 类上的注解存在很多相似性,一个显著的区别是 @SpringBootTest 注解中 webEnvironment 属性的值 WebEnvironment.RANDOM_PORT。这使得 Spring Boot 可以在一个随机的端口上启动 REST API 服务。PassengerControllerTest 类通过依赖注入的方式声明了 WebTestClient 类和 TestRestTemplate 类对象。

WebTestClient 类提供了一个简单的 API 来发送 HTTP 请求并验证响应。在 testCreatePassenger 方法中,使用了 WebTestClient 类的 post 方法发送一个 POST 请求来创建乘客,并验证 HTTP 响应的状态码和 HTTP 头 Location。

TestRestTemplate 类则用来获取 HTTP 响应的内容。在 testRemoveAddress 方法中,TestRestTemplate 类的 postForLocation 方法用来发送 POST 请求,并获取到 HTTP 头 Location 的内容,该内容是访问新创建的乘客对象的 URL。TestRestTemplate 类的 getForObject 方法访问这个 URL 并获取到表示乘客的 PassengerVO 对象。从 PassengerVO 对象中找到乘客的第一个地址的 ID,再构建出访问该地址的 URL。WebTestClient 类的 delete 方法用来发送 DELETE 请求到该地址对应的 URL,接着使用 WebTestClient 类的 get 方法获取乘客信息并验证地址数量。

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ComponentScan
@Import(EmbeddedPostgresConfiguration.class)
@ImportAutoConfiguration(classes = {
    EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
    EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
    "embedded.postgresql.docker-image=postgres:12-alpine"
})
@DisplayName("Passenger controller test")
public class PassengerControllerTest {

  private final String baseUri = "/api/v1";

  @Autowired
  WebTestClient webClient;

  @Autowired
  TestRestTemplate restTemplate;

  @Test
  @DisplayName("Create a new passenger")
  public void testCreatePassenger() {
    webClient.post()
        .uri(baseUri)
        .bodyValue(PassengerUtils.buildCreatePassengerRequest(1))
        .accept(MediaType.APPLICATION_JSON)
        .exchange()
        .expectStatus().isCreated()
        .expectHeader().exists(HttpHeaders.LOCATION);
  }

  @Test
  @DisplayName("Add a new address")
  public void testAddUserAddress() {
    URI passengerUri = restTemplate
        .postForLocation(baseUri,
            PassengerUtils.buildCreatePassengerRequest(1));
    URI addressesUri = ServletUriComponentsBuilder.fromUri(passengerUri)
        .path("/addresses").build().toUri();
    webClient.post().uri(addressesUri)
        .bodyValue(PassengerUtils.buildCreateUserAddressRequest())
        .exchange()
        .expectStatus().isOk()
        .expectBody(PassengerVO.class)
        .value(hasProperty("userAddresses", hasSize(2)));
  }

  @Test
  @DisplayName("Remove an address")
  public void testRemoveAddress() {
    URI passengerUri = restTemplate
        .postForLocation(baseUri,
            PassengerUtils.buildCreatePassengerRequest(3));
    PassengerVO passenger = restTemplate
        .getForObject(passengerUri, PassengerVO.class);
    String addressId = passenger.getUserAddresses().get(0).getId();
    URI addressUri = ServletUriComponentsBuilder.fromUri(passengerUri)
        .path("/addresses/" + addressId).build().toUri();
    webClient.delete().uri(addressUri)
        .exchange()
        .expectStatus().isNoContent();
    webClient.get().uri(passengerUri)
        .exchange()
        .expectStatus().isOk()
        .expectBody(PassengerVO.class)
        .value(hasProperty("userAddresses", hasSize(2)));
  }

}

使用 Swagger 客户端

因为微服务实现采用 API 优先的策略,在有 OpenAPI 文档的前提下,我们可以使用 Swagger 代码生成工具来产生客户端代码,并在测试中使用。这种方式的好处是客户端代码屏蔽了 API 的一些细节,比如 API 的访问路径。如果 API 的访问路径发生了变化,那么测试代码并不需要修改,只需要使用新版本的客户端即可。在上一节的代码中,我们需要手动构建不同操作对应的 API 路径,这就产生了不必要的耦合。

使用客户端的另外一个好处是,如果 OpenAPI 文档发生变化,则会造成客户端的接口变化。重新生成新的客户端代码之后,已有测试代码会无法通过编译,开发人员可以在第一时间发现问题并更新测试代码。而使用 HTTP 请求的测试代码,则需要在运行测试时才能发现问题。

乘客管理服务 API 的客户端是示例应用的一个模块,只不过代码都是自动生成的。这里我们需要用到 Swagger 代码生成工具的 Maven 插件,下面的代码给出了该插件的使用方式:

html
<plugin>
  <groupId>io.swagger.codegen.v3</groupId>
  <artifactId>swagger-codegen-maven-plugin</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>generate</goal>
      </goals>
      <configuration>
        <inputSpec>${project.basedir}/src/main/resources/openapi.yml</inputSpec>
        <language>java</language>
        <apiPackage>io.vividcode.happyride.passengerservice.client.api</apiPackage>
        <generateModels>false</generateModels>
        <generateApiTests>false</generateApiTests>
        <importMappings>
          <importMapping>CreatePassengerRequest=io.vividcode.happyride.passengerservice.api.web.CreatePassengerRequest</importMapping>
          <importMapping>CreateUserAddressRequest=io.vividcode.happyride.passengerservice.api.web.CreateUserAddressRequest</importMapping>
          <importMapping>PassengerVO=io.vividcode.happyride.passengerservice.api.web.PassengerVO</importMapping>
          <importMapping>UserAddressVO=io.vividcode.happyride.passengerservice.api.web.UserAddressVO</importMapping>
        </importMappings>
        <configOptions>
        </configOptions>
      </configuration>
    </execution>
  </executions>
</plugin>

下表给出了该插件的配置项说明。

Swagger 的代码生成工具可以从 OpenAPI 文档的模式声明中产生对应的 Java 模型类。示例应用已经定义了相关的模型类,因此把配置项 generateModels 的值设置为 false 来禁用模型类的生成,同时使用 importMappings 来声明映射关系。

当需要编写 REST API 测试时,我们只需要依赖这个 API 客户端模块即可。下面代码中的 PassengerControllerClientTest 类是使用 API 客户端的测试类。由于 API 服务器的端口是随机的,构造器中的 @LocalServerPort 注解的作用是获取到实际运行时的端口,该端口用来构建 API 客户端访问的服务器地址。

PassengerApi 类是 API 客户端中自动生成的访问 API 的类。在测试方法中,我使用 PassengerApi 类的方法来执行不同的操作,并验证结果。相对于上一节中 WebTestClient 类的用法,使用 PassengerApi 类的代码更加直观易懂。

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ComponentScan
@Import(EmbeddedPostgresConfiguration.class)
@ImportAutoConfiguration(classes = {
    EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
    EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
    "embedded.postgresql.docker-image=postgres:12-alpine"
})
@DisplayName("Passenger controller test")
public class PassengerControllerClientTest {

  private final PassengerApi passengerApi;

  public PassengerControllerClientTest(@LocalServerPort int serverPort) {
    ApiClient apiClient = Configuration.getDefaultApiClient();
    apiClient.setBasePath("http://localhost:" + serverPort + "/api/v1");
    passengerApi = new PassengerApi(apiClient);
  }

  @Test
  @DisplayName("Create a new passenger")
  void testCreatePassenger() {
    try {
      ApiResponse<Void> response = passengerApi
          .createPassengerWithHttpInfo(
              PassengerUtils.buildCreatePassengerRequest(1));
      assertThat(response.getStatusCode()).isEqualTo(201);
      assertThat(response.getHeaders()).containsKey("Location");
    } catch (ApiException e) {
      fail(e);
    }
  }

  @Test
  @DisplayName("Add a new address")
  public void testAddUserAddress() {
    try {
      String passengerId = createPassenger(1);
      PassengerVO passenger = passengerApi
        .createAddress(PassengerUtils.buildCreateUserAddressRequest(),
              passengerId);
      assertThat(passenger.getUserAddresses()).hasSize(2);
    } catch (ApiException e) {
      fail(e);
    }
  }

  @Test
  @DisplayName("Remove an address")
  public void testRemoveAddress() {
    try {
      String passengerId = createPassenger(3);
      PassengerVO passenger = passengerApi.getPassenger(passengerId);
      String addressId = passenger.getUserAddresses().get(0).getId();
      passengerApi.deleteAddress(passengerId, addressId);
      passenger = passengerApi.getPassenger(passengerId);
      assertThat(passenger.getUserAddresses()).hasSize(2);
    } catch (ApiException e) {
      fail(e);
    }
  }

  private String createPassenger(int numberOfAddresses) throws ApiException {
    ApiResponse<Void> response = passengerApi
        .createPassengerWithHttpInfo(
         PassengerUtils.buildCreatePassengerRequest(numberOfAddresses));
    assertThat(response.getHeaders()).containsKey("Location");
    String location = response.getHeaders().get("Location").get(0);
    return StringUtils.substringAfterLast(location, "/");
  }
}

使用 BDD

不管是手动发送 HTTP 请求或是使用 Swagger 客户端,相关的测试用例都需要由测试人员来编写。当应用的业务逻辑比较复杂时,测试人员可能需要了解很多的业务知识,才能编写出正确的测试用例。以保险业务为例,一个理赔申请能否被批准,背后有复杂的业务逻辑来确定。这样的测试用例,如果由测试人员来编写,则可能的结果是测试用例所验证的情况,从业务逻辑上来说是错误的,起不到测试的效果。

更好的做法是由业务人员来编写测试用例,这样可以保证应用的实际行为,满足真实业务的期望。但是业务人员并不懂得编写代码。为了解决这个问题, 我们需要让业务人员以他们所能理解的方式来描述对不同行为的期望,这就是行为驱动开发(Behaviour Driven Development,BDD)的思想。

BDD 的出发点是提供了一种自然语言的方式来描述应用的行为,对行为的描述由 3 个部分组成,分别是前置条件动作期望结果,也就是 Given-When-Then 结构,该结构表达的是当对象处于某个状态中时,如果执行该对象的某个动作,所应该产生的结果是什么。比如,对于一个数据结构中常用的栈对象来说,在栈为空的前提下,如果执行栈的弹出动作,那么应该抛出异常;在栈不为空的前提下,如果执行栈的弹出动作,那么返回值应该是栈顶的元素。这样的行为描述,可以很容易转换成测试用例,来验证对象的实际行为。

BDD 一般使用自然语言来描述行为,业务人员使用自然语言来描述行为,形成 BDD 文档,这个文档是业务知识的具体化。我们只需要把这个文档转换成可执行的测试用例,就可以验证代码实现是否满足业务的需求。这个转换的过程需要工具的支持,本课时介绍的工具是 Cucumber

Cucumber 使用名为 Gherkin 的语言来描述行为,Gherkin 语言使用半结构化的形式。下表给出了 Gherkin 语言中的常用结构。

下面的代码给出了乘客管理服务的 BDD 文档示例,其中的第一个场景描述的是添加地址的行为。前置条件是乘客有 1 个地址,动作是添加一个新的地址,期望的结果是乘客有 2 个地址。第二个场景描述的是删除地址的行为,与第一个场景有类似的描述。

sql
Feature: Address management
  Manage a passenger's addresses

  Scenario: Add a new address
    Given a passenger with 1 addresses
    When the passenger adds a new address
    Then the passenger has 2 addresses

  Scenario: Delete an address
    Given a passenger with 3 addresses
    When the passenger deletes an address
    Then the passenger has 2 addresses

在有了 BDD 文档之后,下一步是如何把文档变成可执行的测试用例,这就需要用到 Cucumber 了。Cucumber 使用步骤定义把 Gherkin 语言中的结构与实际的代码关联起来。Gherkin 语言中的 Given、When 和 Then 等语句,都有与之对应的步骤定义代码。Cucumber 在运行 BDD 文档的场景时,会找到每个语句对应的步骤定义并执行,步骤定义中包含了进行验证的代码。

下面代码中的 AddressStepdefs 类是上面 BDD 场景的步骤定义。AddressStepdefs 类上的注解与上面 PassengerControllerTest 类的注解是相似的,PassengerClient 类是 Swagger 客户端的一个封装,用来执行不同的操作。

在步骤定义中,passengerWithAddresses 方法对应的是 Given 语句"a passenger with {int} addresses"。语句中的 {int} 用来提取语句中 int 类型的变量,作为参数 numberOfAddresses 的值。在对应的步骤中,调用 PassengerClient 类的 createPassenger 方法来创建一个包含指定数量地址的乘客对象,并把乘客 ID 保存在 passengerId 中。passengerAddsAddress 和 passengerDeletesAddress 方法分别对应两个 When 语句,分别执行添加和删除地址的动作;passengerHasAddresses 方法则与 Then 语句相对应,验证乘客的地址数量。

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ContextConfiguration(classes = {
    BddTestApplication.class,
    PassengerServiceApplication.class
})
@ComponentScan
@Import(EmbeddedPostgresConfiguration.class)
@ImportAutoConfiguration(classes = {
    EmbeddedPostgreSQLDependenciesAutoConfiguration.class,
    EmbeddedPostgreSQLBootstrapConfiguration.class
})
@TestPropertySource(properties = {
    "embedded.postgresql.docker-image=postgres:12-alpine"
})
public class AddressStepdefs {

  @Autowired
  PassengerClient passengerClient;

  private String passengerId;

  @Given("a passenger with {int} addresses")
  public void passengerWithAddresses(int numberOfAddresses) {
    try {
      passengerId = passengerClient.createPassenger(numberOfAddresses);
    } catch (ApiException e) {
      fail(e);
    }
  }

  @When("the passenger adds a new address")
  public void passengerAddsAddress() {
    try {
      passengerClient.addAddress(passengerId);
    } catch (ApiException e) {
      fail(e);
    }
  }

  @When("the passenger deletes an address")
  public void passengerDeletesAddress() {
    try {
      passengerClient.removeAddress(passengerId);
    } catch (ApiException e) {
      fail(e);
    }
  }

  @Then("the passenger has {int} addresses")
  public void passengerHasAddresses(int numberOfAddresses) {
    try {
      PassengerVO passenger = passengerClient.getPassenger(passengerId);
      assertThat(passenger.getUserAddresses()).hasSize(numberOfAddresses);
    } catch (ApiException e) {
      fail(e);
    }
  }

}

实际的测试由 Cucumber 来运行,下面代码中的 PassengerIntegrationTest 类是由 Cucumber 运行的测试类。需要注意的是,Cucumber 只支持 JUnit 4,因此在 Spring Boot 应用中需要添加对 junit-vintage-engine 模块的依赖。

java
@RunWith(Cucumber.class)
@CucumberOptions(strict = true,
    features = "src/test/resources/features",
    plugin = {"pretty", "html:target/cucumber"})
public class PassengerIntegrationTest {

}

数据准备

集成测试的一个特点是在运行测试之前,对应用的当前状态有一定的要求,而不是从零开始。比如,如果乘客管理服务的 API 支持对地址的分页、查询和过滤,那么在测试这些功能时,则需要被测试的乘客有足够数量的地址来测试不同的情况。这就要求进行一些数据准备工作。下面是 3 种不同的准备数据的做法。

第一种做法是在测试中通过 JUnit 5 的 @BeforeEach 和 @BeforeAll 注解来标注进行数据准备的方法,数据准备通过测试代码中的 API 调用来完成。比如, 可以在每个测试方法执行之前创建一个包含 100 个地址的乘客对象。

第二种做法是通过 SQL 脚本来初始化测试数据库,一个测试用例可以有与之相对应的 SQL 初始化脚本。在运行测试时,SQL 脚本会被执行来添加测试数据。如果测试人员更习惯编写 SQL 脚本,这种方式比第一种更好。Spring Boot 提供了初始化数据库的支持,会自动查找并执行 CLASSPATH 中的 schema.sql 和 data.sql 文件。

第三种做法是使用专门的数据库 Docker 镜像。在之前的测试中,我们使用的是标准的 PostgreSQL 镜像,其中不包含任何数据,不过可以创建一个包含了完整数据的自定义 PostgreSQL 镜像,这样可以测试应用在更新时的行为。比如,新版本的代码中对数据库的模式进行了修改,一个很重要的测试用例就是已有数据的升级。通过使用包含当前版本数据的 PostgreSQL 镜像,可以很容易的测试数据库的升级。Docker 镜像的标签标识了每个镜像包含的数据集的含义,这些都是本地开发和编写测试用例时可以使用的资产。很多数据库的 Docker 镜像都提供了运行初始化脚本的能力。以 PostgreSQL 的 Docker 镜像为例,只需要把 *.sh 或 *.sql 文件添加到 /docker-entrypoint-initdb.d 目录,就可以初始化数据库。

总结

微服务的集成测试把微服务当成一个黑盒子,并使用其 REST API 来进行测试。本课时介绍了 3 种不同的 REST API 集成测试方式,包括手动发送 HTTP 请求并验证,使用 Swagger 客户端来发送请求,以及使用 BDD 来编写测试用例。