就这么神奇!自动生成的微服务API测试:实现篇

前言

本文将介绍如何利用代码的自动生成,对微服务API进行快速高效的测试,全文分成三部分:需求篇设计篇实现篇。需求篇将介绍项目背景,微服务API测试中的痛点,以及我们理想的解决方案。设计篇将介绍如何针对需求,设计可行的实现方案。实现篇将从代码着手,介绍具体的实现。小伙伴们可以自行点开感兴趣的部分。

测试用核心库

我们将通用的功能放在核心库实现,需要在测试项目中引用这个核心库,作为生成的测试代码的基础。核心库提供测试基类,功能包括:

  • 从测试用例规格文件提取配置数据
  • 读取模板并利用参数进行渲染
  • 将定义在测试用例规格、文件的数据转换为JSON字符串
  • 将CSV或者SQL文件的测试数据保存到数据库中
  • 校验在CSV文件中设定数据库期望值数据
  • 日志输出

测试用例规格文件

一个规格文件将对应一个测试类,规格文件中可定义多个测试用例。每个用例中可定义输入、输出、预置数据、期待数据等。

tests:
  # test case1
  - name: getPetById
    pathParams:
      petId: 123
    request:
      headers:
        - name: Content-Type
          value: application/json
    response:
      status: 200
      headers:
        - name: Content-Type
          value: application/json
      body:
        object:
          id: "123"
    assertOptions:
      - IGNORING_EXTRA_FIELDS
复制代码

读取模板并渲染

读取 Mustache 模板,并传入在用例规格中定义的模板参数。


protected String getTemplateResourceAsJsonString(Class<?> testClass, String templateFile,
                                                 Map<String, String> properties) {
  // check arguments
  Objects.requireNonNull(testClass, String.format(NULL_MSG, TEST_CLASS));
  Objects.requireNonNull(templateFile, String.format(NULL_MSG, "templateFile"));

  // mustache
  if (templateFile.endsWith(MUSTACHE_EXT)) {
    MustacheFactory mf = new DefaultMustacheFactory();
    try {
      Mustache m = mf.compile(Objects.requireNonNull(testClass.getResource(templateFile)).getPath());
      Writer out = new StringWriter();
      m.execute(out, properties).flush();
      return out.toString();
    } catch (Exception e) {
      System.out.printf("read template file failed. [%s]%n", templateFile);
      throw new RuntimeException("read template file failed.", e);
    }
  }
  // unsupported
  else {
    System.out.printf("unsupported template. templateFile[%s]%n", templateFile);
    throw new RuntimeException("unsupported template.");
  }
}
复制代码

保存预置数据

利用DBUnit将CSV文件中的预置数据保存到数据库。这里没有使用@DatabaseSetup 注解,而是直接调用DBUnit的 DataSourceDatabaseTester,对数据库进行操作。需要注意的是,测试计划在专用的数据库中运行,所以这里采用了 CLEAN_INSERT 模式,即先清空数据再插入数据。


protected static void executeDbSetup(Class<?> testClass, DataSource dataSource, String dataSetDirectory) {
  // check arguments
  if (Objects.isNull(dataSource) || Objects.isNull(dataSetDirectory)) {
    return;
  }
  // database setup by dbunit
  DataSourceDatabaseTester database = new DataSourceDatabaseTester(dataSource);
  database.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
  CsvDataSetLoader
    csvDataSetLoader = new CsvDataSetLoader();
  try {
    IDataSet dataSet = csvDataSetLoader.loadDataSet(testClass, dataSetDirectory);
    database.setDataSet(dataSet);
    database.onSetup();
  } catch (Exception e) {
    System.out.printf("load csv dataset failed. [%s]%n", dataSetDirectory);
    throw new RuntimeException("load csv dataset failed.", e);
  }
}
复制代码

校验期望数据

利用DBUnit将CSV文件中的数据和数据库中的快照进行对比。这里也没有使用@ExpectedDatabase 注解,而是直接调用DBUnit的 DataSourceDatabaseTester,对数据库进行比对。

protected static void assertDb(Class<?> testClass, DataSource dataSource, String expectedDataSetDirectory) {
    // check arguments
    if (Objects.isNull(testClass) || Objects.isNull(dataSource) || Objects.isNull(expectedDataSetDirectory)) {
        return;
    }
    // assert database by dbunit
    DataSourceDatabaseTester database = new DataSourceDatabaseTester(dataSource);
    CsvDataSetLoader
        csvDataSetLoader = new CsvDataSetLoader();
    try {
        IDataSet expectedDataSet = csvDataSetLoader.loadDataSet(testClass, expectedDataSetDirectory);
        IDataSet actualDataSet = database.getConnection().createDataSet();
        for (String table : expectedDataSet.getTableNames()) {
            ITable expectedTable = expectedDataSet.getTable(table);
            ITable actualTable = DefaultColumnFilter
                .includedColumnsTable(actualDataSet.getTable(table), expectedTable.getTableMetaData().getColumns());
            Assertion.assertEquals(expectedTable, actualTable);
        }
    } catch (Exception e) {
        System.out.printf("assert database failed. [%s]%n", expectedDataSetDirectory);
        throw new RuntimeException("assert database failed.", e);
    }
}
复制代码

测试代码生成器

基于Java开发的代码生成器将根据OAS文件的定义,采用JavaPoet生成测试代码,代码的粒度如下:

  • 以Controller Class为单位,自动生成测试类
  • 以API的端点(Endpoint)为单位,在测试类中自动生成测试方法

OAS文件

以下是标准的OAS文件,文件来自Swagger UI Demo 。这里我们使用了自定义属性 x-test-with-active-profile ,用来指定是否在测试类中使用 @ActiveProfiles 设置专用的配置文件。如果改属性设置为 true ,则会在测试类中增加 @ActiveProfiles("pet") 注解,使用名为 application-pet.yaml 配置文件。

openapi: 3.0.1
tags:
  - name: Pet
    description: Everything about your Pets
    externalDocs:
      description: Find out more
      url: http://swagger.io
    x-test-with-active-profile: true
    
paths:
    /pet/{petId}:
      get:
        tags:
          - Pet
        summary: Find pet by ID
        description: Returns a single pet
        operationId: getPetById
        parameters:
          - name: petId
            in: path
            description: ID of pet to return
            required: true
            schema:
              type: integer
              format: int64
        responses:
          200:
            description: successful operation
            content:
              application/xml:
                schema:
                  $ref: '#/components/schemas/Pet'
              application/json:
                schema:
                  $ref: '#/components/schemas/Pet'
          400:
            description: Invalid ID supplied
            content: {}
          404:
            description: Pet not found
            content: {}
        security:
          - api_key: []
复制代码

生成的测试类

根据上述OAS文件中的 Tag 生成的测试类的示例如下,继承了核心库中提供的测试基类 BaseApiTest


@TestMethodOrder(org.junit.jupiter.api.MethodOrderer.MethodName.class)
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@ActiveProfiles("pet")
public class PetApiTest extends BaseApiTest {

}
复制代码

生成测试方法

根据上述OAS文件中的 paths 生成的测试方法如下,方法上使用了 @ParameterizedTest 注解,用来将测试用例规格中的参数作为测试输入。在该方法中,调用了测试基类中的一些公共方法。主要分为 Given, When, Then 三个部分。Given部分设置前置条件,When执行测试动作,Then校验测试结果。

@DisplayName("GET(/pet/{petId})")
@MethodSource("testGetPetByIdParameters")
@ParameterizedTest(
        name = "{0}"
)
void testGetPetById(ApiTestParameter testParameter) {
    //  --- AutoGeneration --- 
    if (config.isUndefined() && autogenerated) {
        createTestConfiguration(PetApiTest.class, "PetApiTest/test-config.yaml", testResourcesDir);
    }
    if (testParameter.isUndefined() && autogenerated) {
        createTestSpecification(PetApiTest.class, "PetApiTest/tests/getPetById/test-spec.yaml", testResourcesDir);
    }
    //  --- Skip Test --- 
    if (testParameter.isUndefined()) {
        return;
    }
    //  --- Before --- 
    if (Objects.nonNull(testParameter.getBeforeDbSetup())) {
        executeDbSetup(PetApiTest.class, dataSource, "PetApiTest/tests/getPetById/" + testParameter.getBeforeDbSetup());
    }
    if (Objects.nonNull(testParameter.getBeforeSqlScripts())) {
        executeSqlScripts(PetApiTest.class, dataSource, "PetApiTest/tests/getPetById/", testParameter.getBeforeSqlScripts());
    }
    //  --- Given --- 
    RequestSpecification testSpec = RestAssured.given();
    testSpec.port(port);
    testSpec.basePath(basePath);
    testParameter.getQueryParams().forEach(testSpec::queryParam);
    if (Objects.nonNull(testParameter.getRequest())) {
        testParameter.getRequest().getHttpHeaders().forEach(testSpec::header);
        if (Objects.nonNull(testParameter.getRequest().getBody())) {
            testSpec.body(getHttpMessageBodyAsJsonString(testParameter.getRequest().getBody()));
        }
        else if (Objects.nonNull(testParameter.getRequest().getBodyJson())) {
            testSpec.body(getResourceAsJsonString("PetApiTest/tests/getPetById/" + testParameter.getRequest().getBodyJson()));
        }
        else if (Objects.nonNull(testParameter.getRequest().getBodyTemplate())) {
            testSpec.body(getTemplateResourceAsJsonString(PetApiTest.class, "PetApiTest/tests/getPetById/" + testParameter.getRequest().getBodyTemplate(), testParameter.getRequest().getTemplateProps()));
        }
    }
    if (testParameter.isLogging()) {
        testSpec.filter(new ApiTestLogger(PetApiTest.class, "PetApiTest/tests/getPetById/", testParameter));
    }
    //  --- When --- 
    ValidatableResponse response = testSpec.when().get("/pet/{petId}", testParameter.getPathParams()).then();
    //  --- Then --- 
    try {
        if (Objects.nonNull(testParameter.getResponse())) {
            // assert http status
            response.statusCode(testParameter.getResponse().getStatus());
            // assert response header
            testParameter.getResponse().getHttpHeaders().forEach(header -> response.header(header.getName(), header.getValue()));
            // assert response body
            if (Objects.nonNull(testParameter.getResponse().getBody())) {
                if (Objects.nonNull(testParameter.getResponse().getBody().getText())) {
                    // plain text
                    response.body(Matchers.equalTo(testParameter.getResponse().getBody().getText()));
                }
                else {
                    // json
                    String expectedBody = getHttpMessageBodyAsJsonString(testParameter.getResponse().getBody());
                    response.body(JsonMatchers.jsonStringEquals(expectedBody).withOptions(testParameter.getJsonUnitOptions()));
                }
            }
            else if (Objects.nonNull(testParameter.getResponse().getBodyJson())) {
                String expectedBody = getResourceAsJsonString("PetApiTest/tests/getPetById/" + testParameter.getResponse().getBodyJson());
                response.body(JsonMatchers.jsonStringEquals(expectedBody).withOptions(testParameter.getJsonUnitOptions()));
            }
            else if (Objects.nonNull(testParameter.getResponse().getBodyTemplate())) {
                String expectedBody = getTemplateResourceAsJsonString(PetApiTest.class, "PetApiTest/tests/getPetById/" + testParameter.getResponse().getBodyTemplate(), testParameter.getResponse().getTemplateProps());
                response.body(JsonMatchers.jsonStringEquals(expectedBody).withOptions(testParameter.getJsonUnitOptions()));
            }
        }
        // assert database
        if (Objects.nonNull(testParameter.getAssertDb())) {
            assertDb(PetApiTest.class, dataSource, "PetApiTest/tests/getPetById/" + testParameter.getAssertDb());
        }
    }
    //  --- After --- 
    finally {
        executeSqlScripts(PetApiTest.class, dataSource, "PetApiTest/tests/getPetById/", testParameter.getAfterSqlScripts());
    }
}
复制代码

Gradle插件

基于Groovy开发的插件将创建自定义Gradle的任务(Task),并且调用代码生成器。

自定义插件

插件中注册了自定义的任务(Task),并且定义了插件对应的配置参数。

class ApiTesterGeneratorPlugin implements Plugin<ProjectInternal> {

    @Override
    void apply(ProjectInternal project) {
        project.logger.info "Configuring API Test Code Generator for project: $project.name"

        // create gradle task
        ApiTesterGeneratorTask genTask = project.tasks.register("genApiTest", ApiTesterGeneratorTask.class).get()
        // create gradle configuration
        project.configurations.create('apiTesterGenerator')
        // create gradle extension
        project.extensions.create("apiTesterGenerator", ApiTesterGeneratorExtension)

        genTask.conventionMapping.with {
            inputSpec = { project.apiTesterGenerator.inputSpec }
            outputDir = { project.apiTesterGenerator.outputDir }
            apiPackage  = { project.apiTesterGenerator.apiPackage }
        }
    }

}
复制代码

Gradle任务

任务中调用了基于Java开发的代码生成器。

class ApiTesterGeneratorTask extends ConventionTask {

    ApiTesterGeneratorTask(){
        description = 'Api Test code Generation Task'
        group = 'api tester'
    }

    @Input
    def inputSpec
    @Input
    def outputDir
    @Input
    def apiPackage


    @TaskAction
    void genApiTest() {
        Parameter parameter = new Parameter()
        parameter.outputDir = getOutputDir()
        parameter.inputSpec = getInputSpec()
        parameter.apiPackage = getApiPackage()


        System.out.println "   > generate test code..."
        ApiTesterGenerator.apply(parameter)
    }

}
复制代码

项目配置

在项目的 build.gradle 中配置了插件相关的参数。

// Configuration for API Tester Generator
apiTesterGenerator {
    // Path of OpenAPI Specification
    inputSpec = "${projectDir}/src/main/resources/open-api-spec.yaml"
    // Output directory for generated source code
    outputDir = "${projectDir}/build/generated/apitest"
    // Package of API controllers
    apiPackage = "indv.jianlinsun.apitester.sample.gen.controller"
}
复制代码

总结

本篇介绍了测试代码生成器的具体实现和代码样例。

码字不易,如果对你有帮助,请点赞关注加分享,感谢!

相关文章

参考链接