需 求 背 景
在转转,接口测试分为简单的单接口测试和复杂的业务场景测试。
-
单接口测试一般在接口测试平台直接配置
-
复杂的场景测试则需要QA另起工程自己开发
但由于测试环境的IP地址是动态分配的,以及转转RPC架构的服务调用配置方式不够灵活,QA的接口用例工程只能发挥新接口"测试"和定时在稳定环境执行的"监控"作用。缺少服务有改动部署时自动"回归"的能力。
为了能让我们的接口用例发挥更大的作用,能对服务改动做出及时响应,就需要一个在服务部署结束后自动执行接口用例的能力。
需 求 分 析
服务部署结束后自动执行测试用例,要求服务有以下能力:
1、知道服务什么时候部署结束,经过调研,beetle在服务部署结束后会发送部署成功Mq。
2、监听到部署通过mq后,执行用例。
执行用例有两种方式:
-
直接在代码里调用TestNG执行本工程写的测试用例。
-
将接口用例拉取到本地,编译后通过命令行调用TestNG执行用例。
转转QA的用例工程一般都是数据构造和接口用例一体,本身就是一个可启动集群,自身有可监听mq能力。
第二种方式需要固定拉取分支,不利于开发,且需要额外拉取一份代码,编译后才能执行,资源浪费,且效率低。因此采用第一种。
3、服务测试环境是动态分配的,在收到mq之后才知道具体部署哪个ip,因此需要动态请求服务不同节点的能力。
4、执行结束之后需要及时通知开发和测试执行结果。
总结一下:
技 术 实 现
代码结构
上面说过需要在代码里面调用TestNG,因此要将接口用例和数据构造代码放在一起。方便TestNG调用。
-
├── contract // 数据构造接口定义
-
└── service
-
└── src.main.java
-
└── com.zhuanzhuan.mpqa
-
├── Boot.java // 启动服务
-
├── component // 数据构造接口实现
-
├── system // 自动注入RPC接口bean
-
├── RpcProxyHandler.java // RpcProxyHandler
-
├── RpcBeanRegistry.java // RpcBeanRegistry
-
├── MqComsumer.java // Mq消费者
-
├── TestNGSpringContext.java
-
├── TestContextManager.java
-
├── wrapper // 三方接口封装
-
└── zztest // 用例目录
-
├── BaseTest.java // 本地测试时,初始化spring依赖
-
├── TestNGHelper.class
-
├── case // 用例
部署成功mq
MqComsumer.java
-
@Component
-
public class MqComsumer {
-
@ZZMQListener(group = "Consumer", subscribe = @Subscribe(topic = "deploySuccessTopic"))
-
public void beetleDeploy(@Body List<AutoRunCases> beetleDeploys) {
-
AutoRunCases beetleDeploy = beetleDeploys.get(0);
-
TestNGHelper.run(beetleDeploy.getCluster(), beetleDeploy.getIp());
-
sendResult();
-
}
-
}
代码调用TestNG
TestNGHelper.class
-
public class TestNGHelper {
-
public static boolean run(String serviceName, String ip) {
-
-
// 获取服务配置的用例
-
List<Case> cases = caseConfigMap.get(serviceName);
-
// suit
-
XmlSuite xmlSuite = new XmlSuite();
-
xmlSuite.setName(serviceName + "#" + ip);
-
Map<String,String> parameters = new HashMap<>();
-
// 这里将ip传入TestNG
-
parameters.put("ip", ip);
-
xmlSuite.setParameters(parameters);
-
// test
-
XmlTest xmlTest = new XmlTest(xmlSuite);
-
// classes
-
List<XmlClass> classes = new ArrayList<>();
-
cases.forEach(testCase -> {
-
XmlClass xmlClass = new XmlClass(testCase.getClazz());
-
classes.add(xmlClass);
-
// include
-
List<XmlInclude> xmlIncludes = new ArrayList<>();
-
testCase.getMethods().forEach(method -> {
-
XmlInclude xmlInclude = new XmlInclude(method);
-
xmlIncludes.add(xmlInclude);
-
});
-
xmlClass.setIncludedMethods(xmlIncludes);
-
});
-
xmlTest.setXmlClasses(classes);
-
TestNG testNG = new TestNG();
-
List<XmlSuite> suites = new ArrayList<>();
-
suites.add(xmlSuite);
-
testNG.setXmlSuites(suites);
-
testNG.setOutputDirectory("/home/work/test_report");
-
testNG.run();
-
return true;
-
}
-
}
注意:这里需要通过xmlSuite.setParameter传递IP地址
这里直接run的话,会有一个坑,后面会讲到。
动态调用服务不同节点(ip)
转转的RPC框架提供了两种不同的初始化方式。XML和API。
XML配置时,ip信息是写死的,不符合我们的需求。因此需要采用api调用的
方式。ip通过之前TestNG的XmlSuite.setParameters获取。
这种方式,每添加一个接口,都需要手写一个bean,不够优雅。为了能够简化用例编写和减少代码冗余,我们可以实现一个BeanDefinitionRegistryPostProcessor统一处理。后续调用可以跟其他Bean一样,直接@Resoures或者@Autowired即可。
BeanDefinitionRegistryPostProcessor 和FactoryBean
RpcBeanRegistry.java
-
@Component
-
public class RpcBeanRegistry implements BeanDefinitionRegistryPostProcessor {
-
private static final String MP_PACKAGE = "com.zhuanzhuan.mpqa";
-
@Override
-
@PostConstruct
-
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry beanDefinitionRegistry) throws BeansException {
-
scanResourceScfContract().forEach(contract -> {
-
// 生成BeanDefinition
-
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(RpcBeanFactory.class);
-
// 解析后注入 registry 即: beanDefinitionMap.put (beanName, beanDefinition);
-
AbstractBeanDefinition beanDefinition = builder.getBeanDefinition();
-
// 注入属性
-
beanDefinition.getPropertyValues().add("contract", contract);
-
// 自定义 beanDefinition
-
String beanName = contract.getName() + "$ByScfBeanRegistry";
-
beanDefinitionRegistry.registerBeanDefinition(beanName, beanDefinition);
-
});
-
}
-
@Override
-
public void postProcessBeanFactory(ConfigurableListableBeanFactory configurableListableBeanFactory) throws BeansException {
-
}
-
/**
-
* 扫描有@Resource 和 @Autowired的field, 并判断是否是接口
-
*/
-
private Set<Class<?>> scanResourceRpcContract() {
-
Set<Class<?>> classes = ClassScanner.scanPackage(MP_PACKAGE);
-
Set<Class<?>> contractBean = new HashSet<>();
-
classes.forEach(clazz -> {
-
Field[] fields = clazz.getDeclaredFields();
-
Arrays.asList(fields).forEach(field -> {
-
Annotation resource = field.getDeclaredAnnotation(Resource.class);
-
Annotation autoWire = field.getDeclaredAnnotation(Autowired.class);
-
if(resource == null && autoWire == null) {
-
return;
-
}
-
Class<?> type = field.getType();
-
if(!type.isInterface()) {
-
return;
-
}
-
// 当前package
-
if(type.getName().startsWith(MP_PACKAGE)) {
-
return;
-
}
-
if(type.getAnnotation(ServiceContract.class) == null) {
-
return;
-
}
-
contractBean.add(type);
-
});
-
});
-
return contractBean;
-
}
-
}
-
@Setter
-
class RpcBeanFactory implements FactoryBean<Object> {
-
private Class<?> contract;
-
@Override
-
public Object getObject() {
-
ScfProxyHandler handler = new RpcProxyHandler(contract);
-
return handler.getProxy();
-
}
-
@Override
-
public Class<?> getObjectType() {
-
return contract;
-
}
-
@Override
-
public boolean isSingleton() {
-
return true;
-
}
-
}
InvocationHandler
ScfProxyHandler.java
-
public class ScfProxyHandler implements InvocationHandler {
-
private static final int SCF_TIMEOUT = 200000;
-
private Class<?> contract;
-
public ScfProxyHandler(Class<?> contract) {
-
this.contract = contract;
-
}
-
public Object getProxy() {
-
return Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class[] {contract}, this);
-
}
-
@Override
-
public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
-
String methodName = method.getName();
-
ReferenceArgs referenceArgs = new ReferenceArgs(contract);
-
ApplicationConfig applicationConfig = SpringContext.getApplicationContext().getBean(ApplicationConfig.class);
-
ServiceReferenceConfig serviceReferenceConfig = new ServiceReferenceConfig();
-
serviceReferenceConfig.setServiceName(referenceArgs.getServiceName());
-
serviceReferenceConfig.setServiceRpcArgs(new ServiceRpcArgs());
-
serviceReferenceConfig.getServiceRpcArgs().setTimeout(SCF_TIMEOUT);
-
ServerNode serverNode = new ServerNode();
-
// 获取当前suite试用的ip
-
String ip = Reporter.getCurrentTestResult().getTestContext().getSuite().getParameter("ip");
-
serverNode.setHost(ip);
-
serverNode.setPort(referenceArgs.getTcpPort());
-
serviceReferenceConfig.setServerNodes(Collections.singletonList(serverNode));
-
Object refer = new Reference.ReferenceBuilder<>()
-
.applicationConfig(applicationConfig)
-
.interfaceName(contract.getName())
-
.serviceName(referenceArgs.getServiceName())
-
.localReferenceConfig(serviceReferenceConfig)
-
.build()
-
.refer();
-
return method.invoke(refer, args);
-
}
-
}
输出测试报告
执行结束之后会在设置 testNG.setOutputDirectory("/home/work/test_report") 的目录/home/work/test_report中生成测试报告。如果你是web服务,可以直接通过企业微信群发或者发送告警消息,如果是其他服务可以发送邮件。默认的报告不太美观,可以使用其他插件优化。
整体流程
踩 坑
application has already bean instanced
前面说过,直接调用TestNG.run会有坑。坑就是TestNG本身无法在已经启动的spring实例中执行😭。原因是:在服务启动的时候,实例已经启动,相关的依赖已经注入,而TestNG在执行用例前会再次注入依赖。
经过查看TestNG启动的源码,梳理出TestNG的启动调用链和注入依赖的代码如下:
在这四个AbstractTestExecutionListener中的
DependencyInjectionTestExecutionListener是负责依赖注入的,而且
AbstractTestNGSpringContextTests和TestContextManager是比较独立
的,因此我们可以切个"分支"(重写AbstractTestNGSpringContextTests和
TestContextManager)。
步骤:
1、复制一份org.springframework.test.context.TestContextManager
添加以下判断
2、复制一份
org.springframework.test.context.testng.AbstractTestNGSpringContext
Tests命名为TestNGSpringContext,将import
org.springframework.test.context.TestContextManager 修改为 import
com.zhuanzhuan.mpqa.system.TestContextManager
3、BaseTest继承com.zhuanzhuan.mpqa.system.TestNGSpringContext
其他问题
在实际操作中,还有许多需要注意的地方:
1、多服务时,如何维护稳定节点和动态节点。需要维护三套环境:稳定环
境、动态测试环境和执行用例的环境,通过区分使用请求归属,从而决定使用
哪个ip。或者通过流量路由标签设置也可以。
2、用例服务多节点时,如何处理并发。需要在监听mq部分加上分布式锁和
幂等校验
3、区分数据构造请求和用例执行请求。TestNGContext.getTestContext()
== null ? 数据构造请求 : 用例请求。
4、记得定时删除测试报告,避免磁盘被过期资源占用。
感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:
这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取