前言
本文将介绍如何利用代码的自动生成,对微服务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"
}
复制代码
总结
本篇介绍了测试代码生成器的具体实现和代码样例。
码字不易,如果对你有帮助,请点赞关注加分享,感谢!
近期评论