作者:魏士超 & 乔鹏阳
团队:测试团队团队
接口自动化一直以来都是质量保障的重要一环,在接口自动化日常工作中,我们致力于场景的覆盖与结果校验。随着业务的高速发展,高效保质的迭代自动化用例成了我们的一个研究方向,其中用例结果校验的及时性、完整性、可维护性是我们遇到的一个很大的难题。
笔者所属团队,日常工作是围绕商品相关业务展开。在平时的自动化脚本编写中,我们发现:
传统校验方式我们一般只会校验核心字段或者用例相关字段,比如:价格、库存等等。但是基于上述第3、4点原因,我们发现需要去做全字段校验,而全字段校验学习成本高、维护代价大、代码熟悉程度要求高是面临的三大难题,那么如何做到快速、优雅全字段校验成为我们必须去解决的问题。
我们的目标是争取对用例返回字段进行全量校验,同时也要大幅提升用例编写效率。
我们对现有的自动化用例场景进行分析,得到以下结论:
接下来我们分别针对操作、查询这两类接口进行处理。
为了让大家更好的理解后续内容,我们先对有赞目前的测试环境进行一个概述:(详细内容可参见:有赞环境解决方案),环境示意图如下:
目前有赞测试环境采取的是弱隔离策略,分为基础环境和测试环境。基础环境部署应用的代码分支版本同线上一致,项目环境部署的则是应用特性分支代码,两个环境共用一套存储。当一个业务请求进来时,根据一个标志位(内部简称sc)来判定是否要走到项目环境,如果请求的是项目环境且项目环境有该应用,那么此请求会被路由到项目环境中,否则请求到基础环境里。
下面介绍一下我们整体思路:
读接口校验相对简单,分别请求基础环境和项目环境,根据返回值的异同来判定用例是否通过。我们可以借鉴AOP的思路,切入点为dubbo请求前后,在切面中分别请求基础环境和sc环境,根据两次返回值来判定用例是否通过。
整体流程如下:
PS:sc环境即为部署了应用特性分支代码的环境
根据上述流程图,可以看到重点在忽略字段生成以及比对逻辑,思路如下:
public class ItemSavedModel {
private Long itemId;
private Long shopId;
}
假设返回对象itemId为1,shopId为2,那么拆解出Map为{"/itemId":"1","/shopId":"2"},对象的比较转换为Map的比较。通过两次基础环境返回值的比较,不同的路径值对应的路径,就是下次比较要忽略的路径。
对象拆解成Map核心代码如下:
/**
* 获取对象路径
*
* @param obj
* @return
*/
public static HashMap<String, Object> getObjectPathMap(Object obj) {
HashMap<String, Object> map = new HashMap<>();
getPathMap(obj, "", map);
return map;
}
private static void getPathMap(Object obj, String path, HashMap<String, Object> pathMap) {
if (obj == null) {
return;
}
Class<?> clazz = obj.getClass();
//基本类型
if (clazz.isPrimitive()) {
pathMap.put(path, obj);
return;
}
//包装类型
if (ReflectUtil.isBasicType(clazz)) {
pathMap.put(path, obj);
return;
}
//集合或者map
if (ReflectUtil.isCollectionOrMap(clazz)) {
//todo:默认key为基础类型
if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) obj;
map.forEach((k, v) -> {
if (k != null) {
getPathMap(v, path + "/" + k.toString(), pathMap);
}
});
} else {
Object[] array = ReflectUtil.convertToArray(obj);
for (int i = 0; i < array.length; i++) {
getPathMap(array[i], path + "/" + i, pathMap);
}
}
return;
}
//pojo
//获取对象所有的非静态变量字段
List<Field> fields = ReflectUtil.getAllFields(clazz);
fields.forEach(field -> getPathMap(ReflectUtil.getField(obj, field), path + "/" + field.getName(), pathMap));
return;
}
写接口相对读接口会复杂一些,篇幅所限,主要讲解核心逻辑。写接口校验整体逻辑与读接口类似:总共触发三次请求,前两次所有读写接口在基础环境执行,计算出忽略字段以及记录下来基础环境返回值。第三次所有请求都在项目环境,获取接口在项目环境的返回值,接下来排除掉忽略字段,比较基础环境和项目环境接口对应的返回值即可完成校验。
整体流程如下:
//触发三次请求
public class GlobalCoverISuiteListener implements ISuiteListener {
public static ConcurrentHashMap<String,Integer> suiteFinishMap=new ConcurrentHashMap<>();
@Override
public void onStart(ISuite suite) {
if(suiteFinishMap.size()==0 ){
//第一次进来 设置globalCoverFlag为1 后面会new testng两次
System.setProperty("globalCoverFlag", "1");
if(System.getProperty("globalCoverFlag").equals("1")) {
suiteFinishMap.put(suite.getXmlSuite().getName(),1);
TestNG tng = new TestNG();
tng.setXmlSuites(Arrays.asList(suite.getXmlSuite()));
tng.run();
}
}
}
@Override
public void onFinish(ISuite suite) {
suite.getResults().forEach((suitename, suiteResult)->{
ITestContext context = suiteResult.getTestContext();
if(System.getProperty("globalCoverFlag").equals("1")) {
int before = suiteFinishMap.get(suite.getXmlSuite().getName());
//第二次结束 表示计算忽略字段已经结束 可以进行正常的跑case了
if(suiteFinishMap.get(suite.getXmlSuite().getName())==2){
suiteFinishMap.put(suite.getXmlSuite().getName(),++before);
System.setProperty("globalCoverFlag", "0");
return;
}
suiteFinishMap.put(suite.getXmlSuite().getName(),++before);
TestNG tng = new TestNG();
tng.setXmlSuites(Arrays.asList(context.getCurrentXmlTest().getSuite()));
tng.run();
}
});
}
}
//case层方法加上注解
@WriteCaseDiffAnnotation
@Test
public void testAdd(){
//写操作
//读操作
}
// IMethodInterceptor.intercept中判断注解,获取写操作用例
@Override
public List<IMethodInstance> intercept(List<IMethodInstance> methods, ITestContext context) {
List<IMethodInstance> testMethods = new ArrayList<>();
//获取写操作测试用例方法
for (IMethodInstance methodInstance : methods) {
if (isQualified(methodInstance.getMethod())) {
testMethods.add(methodInstance);
}
}
if (System.getProperty(GlobalOperatorType.GLOBAL_COVER.getStr()).
equals(GlobalOperatorType.GLOBAL_COVER.getFlag())) {
//前两次suite,只返回写操作测试方法
return testMethods;
} else {
//最后一次suite,返回所有的测试方法
return methods;
}
}
//判断测试方法是否有WriteCaseDiffAnnotation注解
public boolean isQualified(ITestNGMethod iTestNGMethod) {
Method m = iTestNGMethod.getConstructorOrMethod().getMethod();
WriteCaseDiffAnnotation writeAnnotation = m.getAnnotation(WriteCaseDiffAnnotation.class);
Test test = m.getAnnotation(Test.class);
if (writeAnnotation != null && test != null) {
return true;
}
return false;
}