一、前言
继去年下半年给BPM项目做了一个UI自动化,从落地到比较满意的收益来看,合适的自动化对于测试效率的提升还是比较明显的。
这次因为接了另外一个项目的部分需求,也是比较复杂且定制化程度较高的需求,然后在测试的过程中跟开发沟通时了解到了Controller层的接口都是开发自己加Swagger注解自测的,同时也了解到开发只会测一下他认为改动代码时会影响到的接口,而且开发自测基本也是只测下接口能调通就不管了,接口的健壮性和其他接口都可能存在潜在风险,然后数据交互基本都是json格式的,加上有Swagger注解,比较好开展接口自动化,于是和项目组负责人和开发沟通之后便由我来做接口测试并落地接口自动化,就打算利用工作之余和晚上加加班来搞下接口自动化。
二,技术选型
前段时间刚好在Testerhome上看到一篇关于Rest-Assured框架的接口自动化思路,研究了几天之后就用企业微信的api跑了下demo,自定义注解+动态代理方式的思路确实是值得借鉴学习,于是打算应用到这次的项目上。
三,实施过程
3.1,准备工作
做接口自动化之前,首先要明确自己项目中的需求:
1)测试用例数据的存放与加载
2)测试用例的数据支持多轮运行,有些测试用例数据不能写死,我这边是写成可变标识符${变量名},然后根据反射来生成需要的测试数据
3)我这边有用到restful风格的接口,所以针对这类url可变的接口,定义接口时为{},在执行请求前替换成实际的值即可
4)针对于我们自己的业务流程测试,每一支流程都有不同的processDefKey,所以需要根据每个流程来写各自的业务流程脚本和单独设计各自的测试用例数据
5)用例执行出错时,有详细的日志帮助定位
6)较好的可维护性和可扩展性
3.2,框架搭建
3.2.1 环境搭建
环境搭建时,主要使用以下技术:
- SVN:管理代码工程
- TestNG:作为测试框架
- MySql:存放测试用例数据
- JDBCUtils:访问数据库
- FastJson:处理json格式数据
- Rest-Assured:请求发送与响应断言
- Maven:管理依赖包
- Log4j:管理日志
- ExtentTestNGIReporter:适用于接口自动化的测试报告展示
3.2.2 项目结构:分层设计、解耦
项目中的各个包的作用已经标示在图中 ,来看一下动态代理+自定义注解方式的具体实现
1,首先是接口定义,直接从SwaggerUI上复制过来,非常方便,然后加上需要的自定义注解,一般一个接口需要请求url、请求方法,请求参数/请求体、接口描述等,参照SwaggerUI上的即可
自定义注解POST
接口定义
2,接口定义好之后,在具体的接口测试类中,通过动态代理生成实体,直接调用接口方法,有参数传入参数,就可以拿到response进行断言了
3,ProxyUtils动态代理实现的主要细节:拿到各自的注解后解析,再把解析后的数据封装到测试执行实体中,调用请求封装工具类HttpUtils的方法传递测试执行实体数据即可,HttpUtils篇幅比较长就不贴出来了,可以去文末的github地址里看源码
以上便完成了一个接口从定义到写测试类到完成接口调用的过程了
3.2.3 测试用例数据的存放与加载
存放:我这边是把测试用例数据存放到MySql数据库中,根据Controller->DataProvider->表,一个Controller对应一个DataProvider对应一张表,这样做目录结构统一,可以很直观的编写及维护测试用例数据
加载:BaseTest中setUp时加载所有表的测试用例数据到集合中,DataProvider里根据每个接口测试类的唯一api_id来加载对应接口测试类的数据给到各个接口测试类
实现细节如下:
表设计/controller层目录结构/dataprovider层目录结构
表结构设计
部分新增接口测试用例数据
BaseTest中
@BeforeSuite
public void setUp() {
logger.info("加载所有测试用例数据");
loadDBTestCaseDatas();
logger.info("其他动作");
}
private void loadDBTestCaseDatas() {
String tableNames = PropertiesUtils.getProperty("testdataTables.properties", "tableNames");
if (tableNames == null || tableNames.trim().length() == 0) {
throw new RuntimeException("测试用例数据表为空!");
}
for (String tableName : tableNames.split(",")) {
logger.info("加载" + tableName + "表测试用例数据");
JDBCUtils.loadTestData(tableName);
}
logger.info("加载参数化变量数据");
JDBCUtils.loadVariable();
logger.info("加载业务流程测试用例数据");
JDBCUtils.getProcessFormDatas(testCases);
}
DataProvider中
// 正常新增数据字典
private static String normal_add_api_id = "DDNormalAdd";
// 异常新增数据字典
private static String abnormal_add_api_id = "DDAbnormalAdd";
@DataProvider(name = "normalAddDataProvider")
public Object[][] normalAddDataProvider() {
Object[][] datas = TestCaseUtils.getCaseDataByApiId(normal_add_api_id, fieldNames);
return datas;
}
@DataProvider(name = "abnormalAddDataProvider")
public Object[][] abnormalAddDataProvider() {
Object[][] datas = TestCaseUtils.getCaseDataByApiId(abnormal_add_api_id, fieldNames);
return datas;
}
测试类中接收DataProvider提供的数据来执行测试即可
@AfterClass
public void tearDown() {
// 调用删除接口删掉新增的数据
dataDictionaryDelete.dataDictionaryDelete(Arrays.toString(dataIds)).then().statusCode(200);
}
@Test(dataProvider = "normalAddDataProvider", dataProviderClass = DataDictionaryAddDataProvider.class, description = "正常新增数据字典")
public void normalAdd(String case_id, String api_id, String query_param, String query_body, Integer expected_code,
String expected_message, String expected_targeted, Integer positive, String description) {
query_body = VariableUtils.handleVariable(query_body);
response = dataDictionaryAdd.dataDictionaryAdd(query_body);
dataIds[i++] = response.jsonPath().getInt("data.id");
// 断言
Assert.assertEquals(true, AssertUtils.getAssertResult(response, expected_code, expected_message, expected_targeted));
}
3.2.4 通过反射生成可变参数
可以看到上面的用例表里有一个可变参数${datadictionaryvalue},可变参数表中存储反射相关信息, 在执行请求之前,先调用处理可变参数方法处理完可变参数
VariableUtils中
/**
* 处理可变参数
* @param variableStr
* @return
*/
public static String handleVariable(String variableStr) {
for (Variable variable : BaseTest.variables) {
String variable_name = variable.getVariable_name();
if (variableStr.contains(variable_name)) {
// 获取反射类名和反射类方法
String reflect_class = variable.getReflect_class();
String reflect_method = variable.getReflect_method();
try {
Class clazz = Class.forName(reflect_class);
Object obj = clazz.newInstance();
Method method = clazz.getMethod(reflect_method);
String result = (String) method.invoke(obj);
variableStr = variableStr.replace(variable_name, result);
} catch (Exception e) {
e.printStackTrace();
GlobalVar.logger.info("反射类【%s】的反射方法【%s】执行失败,未生成可变参数%s", reflect_class,reflect_method,variableStr);
}
}
}
return variableStr;
}
3.2.5 处理restful风格的接口的可变URL
先定义一个URL可变参数的注解
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) public @interface PathVariable { String value() default ""; }
然后在接口定义上添加这个注解
public interface CoreFormTemplate { @POST(path = "/core/form/template/{}", description = "获取表单模板") Response coreFormTemplate(@PathVariable String processDefKey, @Body String body); }
动态代理工具类里解析方法注解时处理
else if (annos[0] instanceof PathVariable) { path = path.replaceFirst("\\{}", args[i].toString()); }
这样我们在发起请求时直接传入实际的参数即可
response = coreFormTemplate.coreFormTemplate(processDefKey, query_body);
3.2.6 业务流程测试
因为业务流程测试是需要调用几个接口组装到一起执行的,在不改表结构的情况下,使用DataProvider不能满足需求了,所以一个业务流程的测试用例数据,会放到对应的测试用例的集合中
// 正向审批全流程api_id和测试用例数据集合
private final String positiveApproval_api_id = "SATPositiveApproval";
private Map<String, Object> positiveApprovalMap = new HashMap<>();
// 重新分派审批全流程api_id和测试用例数据集合
private final String reassignApproval_api_id = "SATReassignApproval";
private Map<String, Object> reassignApprovalMap = new HashMap<>();
// 审批过程中作废api_id和测试用例数据集合
private final String invalidApproval_api_id = "SATInvalidApproval";
private Map<String, Object> invalidApprovalMap = new HashMap<>();
表数据设计
在测试开始前加载对应的测试用例数据到对应的集合中
@BeforeClass
public void setUp() {
// 加载各个业务流程用例的测试数据
BaseTest.processDatas.forEach((key, value) -> {
String targetApiId = key.split("_")[0];
switch (targetApiId) {
case positiveApproval_api_id:
positiveApprovalMap.put(key, value);
break;
case reassignApproval_api_id:
reassignApprovalMap.put(key, value);
break;
case invalidApproval_api_id:
invalidApprovalMap.put(key, value);
break;
}
});
}
测试用例脚本
@Test(description = "正向审批全流程")
public void positiveApproval() {
// 调模板接口,获取formDefId
TestCase coreFormTemplateTestCase = (TestCase) positiveApprovalMap.get(positiveApproval_api_id + "_CoreFormTemplate");
String coreFormTemplateBody = coreFormTemplateTestCase.getQuery_body();
response = coreFormTemplate.coreFormTemplate(processDefKey, coreFormTemplateBody);
String formDefId = response.jsonPath().getString("data.formDefId");
// 调保存接口,获取processInstanceId
TestCase coreSaveTestCase = (TestCase) positiveApprovalMap.get(positiveApproval_api_id + "_CoreSave");
String coreSaveBody = coreSaveTestCase.getQuery_body();
response = coreSave.coreSave(formDefId, VariableUtils.handleVariable(coreSaveBody));
processInstanceId = response.jsonPath().getString("data.processInstanceId");
// 调提交接口
TestCase coreCompleteTestCase = (TestCase) positiveApprovalMap.get(positiveApproval_api_id + "_CoreComplete");
String coreCompleteBody = coreCompleteTestCase.getQuery_body();
response = coreComplete.coreComplete(processInstanceId, VariableUtils.handleVariable(coreCompleteBody));
// 调同意接口
TestCase coreAgreeTestCase = (TestCase) positiveApprovalMap.get(positiveApproval_api_id + "_CoreAgree");
String coreAgreeBody = coreAgreeTestCase.getQuery_body();
// 三次同意
for (int i = 0; i < 3; i++) {
response = coreAgree.coreAgree(processInstanceId, VariableUtils.handleVariable(coreAgreeBody));
}
// 调提交接口
response = coreComplete.coreComplete(processInstanceId, VariableUtils.handleVariable(coreCompleteBody));
// 断言docStatusLabel=已完成
response.then().body("data.docStatusLabel", equalTo("已完成"));
}
3.2.7 日志记录
良好的日志输出是帮助定位问题的关键环节,接口测试主要需要记录的日志包括:请求的url、参数、方法、请求头、实际响应、期望响应等等,Rest-Assured提供了log().all()能log请求/响应的所有数据。贴一下我的log4j配置
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE log4j:configuration PUBLIC "-//APACHE//DTD LOG4J 1.2//EN" "log4j.dtd"> <log4j:configuration> <!-- Appenders --> <appender name="CONSOLE.ERR" class="org.apache.log4j.ConsoleAppender"> <param name="target" value="System.err" /> <layout class="org.apache.log4j.PatternLayout"> <param name="ConversionPattern" value="%d [%p] %c - %m%n" /> </layout> <filter class="org.apache.log4j.varia.LevelRangeFilter"> <param name="LevelMin" value="warn" /> <param name="LevelMax" value="fatal" /> <param name="AcceptOnMatch" value="false" /> </filter> </appender> <appender name="CONSOLE.OUT" class="org.apache.log4j.ConsoleAppender"> <param name="target" value="System.out" /> <layout class="org.apache.log4j.PatternLayout"> <param name="ConversionPattern" value="%d [%p] %c - %m%n" /> </layout> <filter class="org.apache.log4j.varia.LevelRangeFilter"> <param name="LevelMin" value="debug" /> <param name="LevelMax" value="info" /> <param name="AcceptOnMatch" value="false" /> </filter> </appender> <!--按天生成日志文件--> <appender name="dailyRollingFile" class="org.apache.log4j.DailyRollingFileAppender"> <param name="Threshold" value="debug"/> <param name="File" value="test-output/logs/log.log"/> <param name="DatePattern" value="'.'yyyy-MM-dd'.log'"/> <param name="Encoding" value="UTF-8"/> <layout class="org.apache.log4j.PatternLayout"> <param name="ConversionPattern" value="%d [%p] %c - %m%n"/> </layout> </appender> <logger name="com.errout"> <level value="debug" /> </logger> <!-- Root Logger --> <root> <priority value="info" /> <appender-ref ref="CONSOLE.ERR" /> <appender-ref ref="CONSOLE.OUT" /> <appender-ref ref="dailyRollingFile" /> </root> </log4j:configuration>
4,成效
首先就是Bug修复后的回归验证了,还是能发现一些修bug引起的其他问题
然后作为开发冒烟的一部分,目前这套接口自动化已经搭建到Jenkins上,开发在提测前会先去跑一遍,没有问题才会提测,对于测试和开发算是双赢
再就是线上监控,测试用例集里的positive_case这部分用例也放在Jenkins上单独配置了一个定时任务,H/8每八小时执行一次,有失败用例会发V消息报警
最后项目上线的验证也全部交给自动化来验证了
5,源码地址(实战企业微信服务端API)
https://github.com/lynnk1ng37/qywx-service-api.git