@Excel+TestNG+HttpClient+ExtentReport实现接口自动化
这个接口自动化项目基于Java语言,以excel作为主要数据源,httpClient作为发起接口请求的api, TestNG作为测试框架,ExtentReport生成测试报告。
数据源Excel
用excel作为主要数据源,excel中存放接口测试数据的基本信息,比如此条接口测试数据是否要运行,接口的url,接口的请求方式(get/post),接口的请求参数,接口的响应的处理方式
如下图
之后,利用Apache Poi中的api读取excel中的数据读到内存中的list,将这个list构造成一个泛型是对象的一维数组的list即List<Obejct[]>,将这个list的迭代器iterator作为数据源dataProvider提供给TestNG框架。
/**
* 所有api测试用例数据
*/
protected List<ApiDataBean> dataList = new ArrayList<ApiDataBean>();
/**
* @return void
* @Author Siyue Chen
* @Description 将api-data.xls中的接口数据读入内存List<ApiDataBean> dataList
* @Date 15:52 2023/6/9
* @Param [excelPath, sheetName]
**/
@Parameters({"excelPath", "sheetName"})
@BeforeTest
public void readData(@Optional("case/api-data.xls") String excelPath, @Optional("Sheet1") String sheetName) throws DocumentException {
dataList = readExcelData(ApiDataBean.class, excelPath.split(";"),
sheetName.split(";"));
}
/**
* 提供TestNG框架中的数据源,一个对象数组的list的迭代器
* 过滤数据,run标记为Y的执行。
*
* @return
* @throws DocumentException
*/
@DataProvider(name = "apiDatas")
public Iterator<Object[]> getApiData(ITestContext context)
throws DocumentException {
List<Object[]> dataProvider = new ArrayList<Object[]>();
for (ApiDataBean data : dataList) {
if (data.isRun()) {
dataProvider.add(new Object[]{data});
}
}
return dataProvider.iterator();
}
@Test(dataProvider = "apiDatas")
public void apiTest(ApiDataBean apiDataBean) throws Exception {
ReportUtil.log("--- test start ---");
...
}
如何处理请求参数中的特殊变量值
请求参数里不一定都是固定不变的参数值,比如"123",会有一些特殊的请求参数,比如B接口的某个请求参数可能是A接口的响应参数,也有可能接口的请求参数是用随机函数生成的字符串,先考虑这些特殊的请求参数。
如何处理关联场景下的请求参数值
B接口的请求参数是A接口的响应参数被称作关联。
这里可以类比Jmeter里的关联场景来处理。
- 首先是要保存下A接口的响应参数,类似于Jmeter里的json提取器。
在excel里增加一列save,相应的bean里也要有save这一成员属性。
用正则表达式
([;=]*)=([;]*)
去匹配save字段,等式左边匹配变量名,等式右边匹配jsonPath,程序读取出jsonPath对应的响应值后,以变量名作key,响应参数值作为value存入一个类的全局变量HashMap,可以给这个hashMap起个名字叫公共参数池防止之后和另一个处理函数的HashMap混淆。
这个公共参数池类似于Jmeter的内置参数vars
/**
* 公共参数数据池(全局可用)
*/
private static Map<String, String> saveDatas = new HashMap<String, String>();
/**过程参数存入公共参数池
* 提取json串中的值保存至公共池中
*
* @param json
* json响应
* @param allSave 也就是excel里的save字段 msg_id=$.jsonPath.xx
* 所有将被保存的数据:xx=$.jsonpath.xx;oo=$.jsonpath.oo,将$.jsonpath.
* xx提取出来的值存放至公共池的xx中,将$.jsonpath.oo提取出来的值存放至公共池的oo中
*/
protected void saveResult(String json, String allSave) {
if (null == json || "".equals(json) || null == allSave
|| "".equals(allSave)) {
return;
}
allSave = getCommonParam(allSave);
//first_currency=$.result[0].currency;first_name=$.result[0].name
//将excel里save msg_id=$.jsonPath.xx;字符串分隔开来
String[] saves = allSave.split(";");
String key, value;
//遍历save msg_id=$.jsonPath.xx;
for (String save : saves) {
// 正则表达式 由0-n个非;=组成的字符串 = 0-n个非;组成的字符串
//对msg_id=$.jsonPath.xx做正则匹配
Pattern pattern = Pattern.compile("([^;=]*)=([^;]*)");
Matcher m = pattern.matcher(save.trim());
while (m.find()) {
//将excel save字段里等式左边的key作为key
//msg_id是匹配到到第一个分组group(1)
//对左边调用getBuildValue函数可能只起到一个trim的作用
key = getBuildValue(json, m.group(1));
//根据excel里save字段的jsonPath提取响应里对应着jsonPath的值作为value
//$.jsonPath.xx是匹配到到第二个分组group(2)
value = getBuildValue(json, m.group(2));
ReportUtil.log(String.format("存储公共参数 %s值为:%s.", key, value));
saveDatas.put(key, value);
}
}
}
- 其次是将B接口请求参数中的变量替换为公共参数池中的变量值
B接口请求参数中引用变量的格式参考了Jmeter引用变量的格式。
举个🌰
{
"userName":"${userName}"
}
程序会在发起请求前处理请求参数,处理请求参数时用正则表达式
\$\{(.*?)\}
去匹配
${paramName}
这种模式的请求参数,匹配到变量名之后,以变量名作key去公共参数池中取值并替换掉${xx}。
/**
* 替换符,如果数据中包含“${}”则会被替换成公共参数中存储的数据
*/
protected Pattern replaceParamPattern = Pattern.compile("\\$\\{(.*?)\\}");
/**公共参数的取
* 取公共参数 并替换参数
* 将参数param中的${xxx}替换为公共参数池map中以${xxx}中的xxx作key的value,并返回处理过的参数
* @param param
* @return
*/
protected String getCommonParam(String param) {
//如果参数为空,返回空字符串
if (StringUtil.isEmpty(param)) {
return "";
}
//这个正则表达式模式匹配的是${}
Matcher m = replaceParamPattern.matcher(param);// 取公共参数正则
while (m.find()) {
//取匹配到到的所有子字符串中的第一个分组,其实这个正则表达式就只有一个分组,( ${} )
String replaceKey = m.group(1);
String value;
// 从公共参数池中获取值,以${xxx}中的xxx作key,取key对应的value
value = getSaveData(replaceKey);
// 如果公共参数池中未能找到对应的值,该用例失败。
Assert.assertNotNull(value,
String.format("格式化参数失败,公共参数中找不到%s。", replaceKey));
//将参数中的${xxx}替换为公共参数池中的值
param = param.replace(m.group(), value);
}
return param;
}
如何处理函数生成的请求参数值
有一些请求参数值,需要用函数生成,比如如果需要随机生成用户名,或者对密码进行MD5加密的话。
函数生成的请求参数格式参考了Jmeter的函数格式,__funName(arg1,arg2,…)
- 首先要在程序里定义好函数
函数接口,两个抽象方法,分别是获取函数名和函数的具体执行方法excute()方法
package com.sen.api.functions;
/*
函数分成两个部分,第一个部分是函数的名字,第二个部分是函数的具体执行
*/
public interface Function {
/*
函数的具体执行
args是函数的参数
*/
String execute(String[] args);
/*
函数的名字
*/
String getReferenceKey();
}
所有的函数都去实现这个函数接口
举个🌰,随机函数
package com.sen.api.functions;
import com.sen.api.utils.RandomUtil;
/*
定义一个随机函数,__random()
*/
public class RandomFunction implements Function {
/**
* @Author Siyue Chen
* @Description 如果__random()无参数,默认生成一个长度为6 [a-zA-Z0-9]{6}的字符串;__random(arg1)arg1指定随机字符串长度;
* __random(arg1,arg2)第二个参数指定了随机字符串是否只由数字组成
* @Date 15:03 2023/6/6
* @Param
* @return
**/
@Override
public String execute(String[] args) {
int len = args.length;
int length = 6;// 默认为6
boolean flag = false;// 默认为false
//如果有参数
if (len > 0) {// 第一个参数字符串长度
length = Integer.valueOf(args[0]);
}
//如果有2个及以上参数
if (len > 1) {// 第二个参数是否纯字符串
flag = Boolean.valueOf(args[1]);
}
return RandomUtil.getRandom(length, flag);
}
@Override
public String getReferenceKey() {
return "random";
}
}
MD5加密函数
package com.sen.api.functions;
import java.io.File;
import java.io.FileInputStream;
import java.net.URL;
import org.apache.commons.codec.digest.DigestUtils;
public class Md5Function implements Function{
/*
定义一个名为__md5(文件路径)的函数,获取文件的md5码
*/
@Override
public String execute(String[] args) {
try {
String filePath = args[0];
if (filePath.startsWith("http")) {
return DigestUtils.md5Hex(new URL(filePath).openStream());
} else {
return DigestUtils.md5Hex(new FileInputStream(new File(
filePath)));
}
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return null;
}
@Override
public String getReferenceKey() {
// TODO Auto-generated method stub
return "md5";
}
}
- 定义好函数之后,规定在excel里用__funName(arg1,arg2,…)这种特殊格式的字符串去标识某个请求参数值是用函数生成的,程序就要去识别这种特殊格式的字符串,也就是用正则表达式去匹配到函数名和函数的参数
/**
* 截取自定义方法正则表达式:__xxx(param1,param2,...)
*/
protected Pattern funPattern = Pattern
.compile("__(\\w*?)\\((([\\w\\\\\\/:\\.\\$]*,?)*)\\)");
/**
* 组件预参数(处理__fucn()以及${xxxx})
*
* @param apiDataBean
* @return
*/
protected String buildParam(String param) {
// 处理${}
param = getCommonParam(param);
//这个funPattern是指__fun(xxx,...)的正则匹配模式
Matcher m = funPattern.matcher(param);
while (m.find()) {
//匹配到的子字符串的第一个分组是(\w*?),也就是函数名
String funcName = m.group(1);
//匹配到的子字符串的第二个分组是(([\w\\\/:\$\.]*,?)*),也就是param1,param2...
String args = m.group(2);
String value;
// bodyfile属于特殊情况,不进行匹配,在post请求的时候进行处理
if (FunctionUtil.isFunction(funcName)
&& !funcName.equals("bodyfile")) {
// 属于函数助手,调用那个函数助手获取。
value = FunctionUtil.getValue(funcName, args.split(","));
// 解析对应的函数失败
Assert.assertNotNull(value,
String.format("解析函数失败:%s。", funcName));
param = StringUtil.replaceFirst(param, m.group(), value);
}
}
return param;
}
上面有一个if判断,根据函数名判断是否是程序里定义的函数以及 直接根据函数名和参数值获取到函数执行结果的,要想实现根据函数名和参数值获取到函数执行结果,需要借助反射机制
大致思路是:将函数名作为key,函数接口实现类的反射对象作为value存入到一个类全局的静态的hashMap里,这个哈希map我们可以叫它函数池,以将它与公共参数池区分开来,之后便可以直接根据函数名作为key,取到函数接口实现类的反射对象,再用newInstance()方法new一个函数接口实现类的对象实例,再用对象实例去调用函数接口实现类的执行方法excute();
如下:
//key为函数名,value为com.sen.api.functions路径下所有继承/实现/等于Function.class的类的反射对象
private static final Map<String, Class<? extends Function>> functionsMap = new HashMap<String, Class<? extends Function>>();
/**
* @Author Siyue Chen
* @Description 执行这个函数,返回函数执行结果
* @Date 09:50 2023/6/9
* @Param [functionName, 函数名
* args]函数的参数
* @return java.lang.String
**/
public static String getValue(String functionName,String[] args){
try {
//通过函数名取到这个函数类的反射对象,构造一个新的实例对象,并且调用实例的excute方法
return functionsMap.get(functionName).newInstance().execute(args);
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
return "";
}
}
这里还有一个很关键的地方,就是functionsMap是如何形成的?
大致思路是:
首先获取到函数接口所在包的全限定名cls.getPackage().getName(),之后调用应用程序类加载的getResource(包的全限定名).getPath()的方法获取到函数接口所在包的绝对路径,之后去遍历绝对路径下的所有文件,如果有文件是以.class后缀结尾的,那么就用Class.forName(“com.xx”)去生成类的反射对象,之后再去判断所有类的反射对象是否实现了函数接口cls.isAssignableFrom©,如果实现类函数接口,那么这个类一定是函数接口实现类,到此已经获得了所有的函数接口实现类的反射对象;
之后写一个静态代码块初始化一下函数池就好,用函数接口实现类的反射对象newInstance()一个函数接口实现类的对象实例,用对象实例去调用获取函数名的方法,将函数名作为key,函数接口实现类的反射对象作为value存入函数池,函数池到此初始化完成。✌️
static {
//bodyfile 特殊处理
functionsMap.put("bodyfile", null);
//获取Function.class所在包com.sen.api.functions路径下所有继承/实现/等于Function.class的类的反射对象
List<Class<?>> clazzes = ClassFinder.getAllAssignedClass(Function.class);
clazzes.forEach((clazz) -> {
try {
//通过构造clazz对象的实例去调用getRefrenceKey方法
//tips:在反射中,一定要构造一个实例才能调用类中的方法,即便是method.invoke(object)也是如此
// function
Function tempFunc = (Function) clazz.newInstance();
String referenceKey = tempFunc.getReferenceKey();
if (referenceKey.length() > 0) { // ignore self
functionsMap.put(referenceKey, tempFunc.getClass());
}
} catch (Exception ex) {
ex.printStackTrace();
//TODO
}
});
}
/**
* 获取同一路径下所有子类或接口实现类
* 获取到cls所在包路径下所有继承/实现/等于cls的类的反射对象
* @param intf
* @return
* @throws IOException
* @throws ClassNotFoundException
*/
public static List<Class<?>> getAllAssignedClass(Class<?> cls) {
/*A.isAssignableFrom(B)
A和B均为Class对象,判断B是否等于/继承/实现A,是返回true,否返回false*/
List<Class<?>> classes = new ArrayList<Class<?>>();
// 获取到当前类所在包下所有类到反射对象
for (Class<?> c : getClasses(cls)) {
//如果有类=/继承/实现当前类cls,那么将这个类加入到返回列表中
if (cls.isAssignableFrom(c) && !cls.equals(c)) {
classes.add(c);
}
}
return classes;
}
/**
* 取得当前类所在路径下的所有类 的反射对象
* 获取到当前类所在包路径下的所有类的反射对象包括自身
* @param cls
* @return
* @throws IOException
* @throws ClassNotFoundException
*/
public static List<Class<?>> getClasses(Class<?> cls) {
//首先获取类的包路径
String pk = cls.getPackage().getName();
String path = pk.replace('.', '/');
// URL url = classloader.getResource(path);
// return getClasses(new File(url.getFile()), pk);
try {
//接着通过包路径获取到包所在的绝对路径
String dirPath = URLDecoder.decode(classloader.getResource(path).getPath(),"utf-8");
return getClasses(new File(dirPath), pk);
} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return new ArrayList<Class<?>>();
}
/**
* 迭代查找类
* 获取到pk包路径(也就是绝对路径dir)下的所有类的反射对象
* @param dir 一个用包的绝对路径作为参数构造的目录
* @param pk 包的路径 com.xx
* @return
* @throws ClassNotFoundException
*/
private static List<Class<?>> getClasses(File dir, String pk) {
List<Class<?>> classes = new ArrayList<Class<?>>();
//如果包的不存在,那么返回空列表
if (!dir.exists()) {
return classes;
}
//开始遍历包下面每一个文件、目录
for (File f : dir.listFiles()) {
//如果是目录的话递归调用当前方法
if (f.isDirectory()) {
classes.addAll(getClasses(f, pk + "." + f.getName()));
}
//如果是文件,获取文件名
String name = f.getName();
//如果是类的二进制文件,那么通过反射方法Class.forName获取到类的反射对象,并且将其加入到返回列表中
if (name.endsWith(".class")) {
try{
classes.add(Class.forName(pk + "." + name.substring(0, name.length() - 6)));
}catch(Exception ex){
//TODO console warn
}
}
}
return classes;
}
发起请求
在处理好请求参数之后,就开始调用HttpClient发起http请求了
@Test(dataProvider = "apiDatas")
public void apiTest(ApiDataBean apiDataBean) throws Exception {
ReportUtil.log("--- test start ---");
//处理请求参数
String apiParam = buildRequestParam(apiDataBean);
// 封装请求方法
HttpUriRequest method = parseHttpRequest(apiDataBean.getUrl(),
apiDataBean.getMethod(), apiParam);
String responseData;
try {
// 执行
HttpResponse response = client.execute(method);
int responseStatus = response.getStatusLine().getStatusCode();
ReportUtil.log("返回状态码:" + responseStatus);
if (apiDataBean.getStatus() != 0) {
Assert.assertEquals(responseStatus, apiDataBean.getStatus(),
"返回状态码与预期不符合!");
}
HttpEntity respEntity = response.getEntity();
responseData = EntityUtils.toString(respEntity, "UTF-8");
// 输出返回数据log
ReportUtil.log("resp:" + responseData);
// 验证预期信息
verifyResult(responseData, apiDataBean.getVerify(),
apiDataBean.isContains());
// 对返回结果进行提取保存。
saveResult(responseData, apiDataBean.getSave());
}
发起http请求得到响应之后,就需要判断响应是否符合预期了
如何进行响应断言
主要写了两种判断响应报文是否符合预期的方式
- 第一种,包含模式,类似jmeter的响应断言里的包括模式;
判断响应报文是否包含某个字符串
这种响应验证方式在excel里是这样标识的。
// 验证预期信息
verifyResult(responseData, apiDataBean.getVerify(),
apiDataBean.isContains());
public class AssertUtil {
public static void contains(String source, String search) {
Assert.assertTrue(source.contains(search),
String.format("期待'%s'包含'%s',实际为不包含.", source, search));
}
}
/**
* @Author Siyue Chen
* @Description 验证接口响应结果
* @Date 14:14 2023/6/9
* @Param [sourchData,
* verifyStr, $.jsonPath.xx="abc";$.jsonPath.yy=${mm};
* contains]
* @return void
**/
protected void verifyResult(String sourchData, String verifyStr,
boolean contains) {
if (StringUtil.isEmpty(verifyStr)) {
return;
}
//将需要判定的内容中的${}替换成公共参数池中的值
String allVerify = getCommonParam(verifyStr);
ReportUtil.log("验证数据:" + allVerify);
//如果是以验证响应中包含verify字段内容的话
if (contains) {
// 验证结果包含
AssertUtil.contains(sourchData, allVerify);
}
//else里是第二种验证反方式了
else {...}
}
- 第二种验证方式:类似于jmeter的json断言,判断某个jsonPath下的参数值是否等于预期值
//书接上文
else {
//如果是以$.jsonPath.xx="abc"这种方式验证响应结果,而不是验证响应结果中包含verify的话
Pattern pattern = Pattern.compile("([^;]*)=([^;]*)");
Matcher m = pattern.matcher(allVerify.trim());
while (m.find()) {
//group(1)是等式左边的,需要去真实响应中获取jsonPath对应的真实值
String actualValue = getBuildValue(sourchData, m.group(1));
//group(2)是等式右边的
String exceptValue = getBuildValue(sourchData, m.group(2));
ReportUtil.log(String.format("验证转换后的值%s=%s", actualValue,
exceptValue));
Assert.assertEquals(actualValue, exceptValue, "验证预期结果失败。");
}
}
Extent Report生成测试报告
引入依赖
<dependency>
<groupId>com.aventstack</groupId>
<artifactId>extentreports</artifactId>
<version>3.0.3</version>
</dependency>
监听器
package com.sen.api.listeners;
import com.aventstack.extentreports.ExtentReports;
import com.aventstack.extentreports.ExtentTest;
import com.aventstack.extentreports.ResourceCDN;
import com.aventstack.extentreports.Status;
import com.aventstack.extentreports.model.Log;
import com.aventstack.extentreports.model.TestAttribute;
import com.aventstack.extentreports.reporter.ExtentHtmlReporter;
import com.aventstack.extentreports.reporter.configuration.ChartLocation;
import com.aventstack.extentreports.reporter.configuration.Theme;
import com.sen.api.utils.ReportUtil;
import org.testng.*;
import org.testng.xml.XmlSuite;
import java.io.File;
import java.util.*;
//测试报告监听器
public class ExtentTestNGIReporterListener implements IReporter {
//生成的路径以及文件名
private static final String OUTPUT_FOLDER = "test-output/";
private static final String FILE_NAME = "index.html";
private ExtentReports extent;
@Override
public void generateReport(List<XmlSuite> xmlSuites, List<ISuite> suites, String outputDirectory) {
init();
boolean createSuiteNode = false;
if(suites.size()>1){
createSuiteNode=true;
}
for (ISuite suite : suites) {
Map<String, ISuiteResult> result = suite.getResults();
//如果suite里面没有任何用例,直接跳过,不在报告里生成
if(result.size()==0){
continue;
}
//统计suite下的成功、失败、跳过的总用例数
int suiteFailSize=0;
int suitePassSize=0;
int suiteSkipSize=0;
ExtentTest suiteTest=null;
//存在多个suite的情况下,在报告中将同一个一个suite的测试结果归为一类,创建一级节点。
if(createSuiteNode){
suiteTest = extent.createTest(suite.getName()).assignCategory(suite.getName());
}
boolean createSuiteResultNode = false;
if(result.size()>1){
createSuiteResultNode=true;
}
Date suiteStartTime = null,suiteEndTime=new Date();
for (ISuiteResult r : result.values()) {
ExtentTest resultNode;
ITestContext context = r.getTestContext();
if(createSuiteResultNode){
//没有创建suite的情况下,将在SuiteResult的创建为一级节点,否则创建为suite的一个子节点。
if( null == suiteTest){
resultNode = extent.createTest(context.getName());
}else{
resultNode = suiteTest.createNode(context.getName());
}
}else{
resultNode = suiteTest;
}
if(resultNode != null){
resultNode.assignCategory(suite.getName(),context.getName());
if(suiteStartTime == null){
suiteStartTime = context.getStartDate();
}
suiteEndTime = context.getEndDate();
resultNode.getModel().setStartTime(context.getStartDate());
resultNode.getModel().setEndTime(context.getEndDate());
//统计SuiteResult下的数据
int passSize = context.getPassedTests().size();
int failSize = context.getFailedTests().size();
int skipSize = context.getSkippedTests().size();
suitePassSize += passSize;
suiteFailSize += failSize;
suiteSkipSize += skipSize;
if(failSize>0){
resultNode.getModel().setStatus(Status.FAIL);
}
resultNode.getModel().setDescription(String.format("Pass: %s ; Fail: %s ; Skip: %s ;",passSize,failSize,skipSize));
}
buildTestNodes(resultNode,context.getFailedTests(), Status.FAIL);
buildTestNodes(resultNode,context.getSkippedTests(), Status.SKIP);
buildTestNodes(resultNode,context.getPassedTests(), Status.PASS);
}
if(suiteTest!= null){
suiteTest.getModel().setDescription(String.format("Pass: %s ; Fail: %s ; Skip: %s ;",suitePassSize,suiteFailSize,suiteSkipSize));
suiteTest.getModel().setStartTime(suiteStartTime==null?new Date():suiteStartTime);
suiteTest.getModel().setEndTime(suiteEndTime);
if(suiteFailSize>0){
suiteTest.getModel().setStatus(Status.FAIL);
}
}
}
extent.flush();
}
private void init() {
//文件夹不存在的话进行创建
File reportDir= new File(OUTPUT_FOLDER);
if(!reportDir.exists()&& !reportDir .isDirectory()){
reportDir.mkdir();
}
ExtentHtmlReporter htmlReporter = new ExtentHtmlReporter(OUTPUT_FOLDER + FILE_NAME);
htmlReporter.config().setDocumentTitle(ReportUtil.getReportName());
htmlReporter.config().setReportName(ReportUtil.getReportName());
htmlReporter.config().setChartVisibilityOnOpen(true);
htmlReporter.config().setTestViewChartLocation(ChartLocation.TOP);
htmlReporter.config().setTheme(Theme.STANDARD);
//设置点击效果:.node.level-1 ul{ display:none;} .node.level-1.active ul{display:block;}
//设置系统信息样式:.card-panel.environment th:first-child{ width:30%;}
htmlReporter.config().setCSS(".node.level-1 ul{ display:none;} .node.level-1.active ul{display:block;} .card-panel.environment th:first-child{ width:30%;}");
// 移除按键监听事件
htmlReporter.config().setJS("$(window).off(\"keydown\");");
//设置静态文件的DNS 如果cdn.rawgit.com访问不了,可以设置为:ResourceCDN.EXTENTREPORTS 或者ResourceCDN.GITHUB
htmlReporter.config().setResourceCDN(ResourceCDN.EXTENTREPORTS);
extent = new ExtentReports();
extent.attachReporter(htmlReporter);
extent.setReportUsesManualConfiguration(true);
// 设置系统信息
Properties properties = System.getProperties();
extent.setSystemInfo("os.name",properties.getProperty("os.name","未知"));
extent.setSystemInfo("os.arch",properties.getProperty("os.arch","未知"));
extent.setSystemInfo("os.version",properties.getProperty("os.version","未知"));
extent.setSystemInfo("java.version",properties.getProperty("java.version","未知"));
extent.setSystemInfo("java.home",properties.getProperty("java.home","未知"));
extent.setSystemInfo("user.name",properties.getProperty("user.name","未知"));
extent.setSystemInfo("user.dir",properties.getProperty("user.dir","未知"));
}
private void buildTestNodes(ExtentTest extenttest,IResultMap tests, Status status) {
//存在父节点时,获取父节点的标签
String[] categories=new String[0];
if(extenttest != null ){
List<TestAttribute> categoryList = extenttest.getModel().getCategoryContext().getAll();
categories = new String[categoryList.size()];
for(int index=0;index<categoryList.size();index++){
categories[index] = categoryList.get(index).getName();
}
}
ExtentTest test;
if (tests.size() > 0) {
//调整用例排序,按时间排序
Set<ITestResult> treeSet = new TreeSet<ITestResult>(new Comparator<ITestResult>() {
@Override
public int compare(ITestResult o1, ITestResult o2) {
return o1.getStartMillis()<o2.getStartMillis()?-1:1;
}
});
treeSet.addAll(tests.getAllResults());
for (ITestResult result : treeSet) {
Object[] parameters = result.getParameters();
String name="";
//如果有参数,则使用参数的toString组合代替报告中的name
for(Object param:parameters){
name+=param.toString();
}
if(name.length()>0){
if(name.length()>50){
name= name.substring(0,49)+"...";
}
}else{
name = result.getMethod().getMethodName();
}
if(extenttest==null){
test = extent.createTest(name);
}else{
//作为子节点进行创建时,设置同父节点的标签一致,便于报告检索。
test = extenttest.createNode(name).assignCategory(categories);
}
//test.getModel().setDescription(description.toString());
//test = extent.createTest(result.getMethod().getMethodName());
for (String group : result.getMethod().getGroups())
test.assignCategory(group);
List<String> outputList = Reporter.getOutput(result);
for(String output:outputList){
//将用例的log输出报告中
test.debug(output.replaceAll("<","<").replaceAll(">",">"));
}
if (result.getThrowable() != null) {
test.log(status, result.getThrowable());
}
else {
test.log(status, "Test " + status.toString().toLowerCase() + "ed");
}
//设置log的时间,根据ReportUtil.log()的特定格式进行处理获取数据log的时间
Iterator logIterator = test.getModel().getLogContext().getIterator();
while (logIterator.hasNext()){
Log log = (Log) logIterator.next();
String details = log.getDetails();
if(details.contains(ReportUtil.getSpiltTimeAndMsg())){
String time = details.split(ReportUtil.getSpiltTimeAndMsg())[0];
log.setTimestamp(getTime(Long.valueOf(time)));
log.setDetails(details.substring(time.length()+ReportUtil.getSpiltTimeAndMsg().length()));
}else{
log.setTimestamp(getTime(result.getEndMillis()));
}
}
test.getModel().setStartTime(getTime(result.getStartMillis()));
test.getModel().setEndTime(getTime(result.getEndMillis()));
}
}
}
private Date getTime(long millis) {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(millis);
return calendar.getTime();
}
}
最后xml文件里引入一下监听器
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd" >
<suite name="接口自动化测试" verbose="1" preserve-order="true" parallel="false">
<test name="自动化测试用例">
<parameter name="excelPath" value="case/api-data.xls"></parameter>
<parameter name="sheetName" value="Sheet1"></parameter>
<classes>
<class name="test.com.sen.api.ApiTest">
<methods>
<include name="apiTest"></include>
</methods>
</class>
</classes>
</test>
<listeners>
<listener class-name="com.sen.api.listeners.AutoTestListener"></listener>
<listener class-name="com.sen.api.listeners.RetryListener"></listener>
<!-- ExtentReport 报告 -->
<listener class-name="com.sen.api.listeners.ExtentTestNGIReporterListener"></listener>
</listeners>
</suite>
如何实现测试用例失败自动重跑机制
TestNG如何处理失败的测试结果
如果测试结果被断言为失败,TestNG会调用IRetryAnalyzer的retry()方法,如果retry方法返回true,那么就会重新执行这个测试用例,如果retry方法为false,那么就不会再执行这个失败的测试用例;
如下所示
IRetryAnalyzer retryAnalyzer = testMethod.getRetryAnalyzer();
boolean willRetry = retryAnalyzer != null && status == ITestResult.FAILURE && failure.instances != null && retryAnalyzer.retry(testResult);
if (willRetry) {
resultsToRetry.add(testResult);
failure.count++;
failure.instances.add(testResult.getInstance());
testResult.setStatus(ITestResult.SKIP);
} else {
testResult.setStatus(status);
if (status == ITestResult.FAILURE && !handled) {
handleException(ite, testMethod, testResult, failure.count++);
}
所以如果我们想要让失败的测试用例自动重新运行,需要重写IRetryAnalyzer的retry方法,自己定义返回true/false的规则
package com.sen.api.listeners;
import org.testng.IRetryAnalyzer;
import org.testng.ITestResult;
import org.testng.Reporter;
import com.sen.api.excepions.ErrorRespStatusException;
//失败用例重新执行
public class TestngRetry implements IRetryAnalyzer {
private int count = 1;
private int max_count = 3;
@Override
public boolean retry(ITestResult result) {
System.out.println("Test case :" + result.getName() + ",retry time: " + count + "");
if (count < max_count) {
count++;
return true;
}else{
resetRetryCount();
}
return false;
}
public void resetRetryCount() {
count = 1;
}
}
之后还需要将这个重写过的IRetryAnalyzer接口实现类放到@Test属性中
@Test(retryAnalyzer=RetryImpl.class)
但这也造成了一个问题,就是如果有多个测试方法,难道每个@Test注解里都要写上这个属性吗?
有一个接口IAnnotationTransformer,接口有一个抽象方法transform可以修改@Test属性。
package com.sen.api.listeners;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import org.testng.IAnnotationTransformer;
import org.testng.IRetryAnalyzer;
import org.testng.annotations.ITestAnnotation;
public class RetryListener implements IAnnotationTransformer {
@SuppressWarnings("rawtypes")
public void transform(ITestAnnotation annotation, Class testClass,
Constructor testConstructor, Method testMethod) {
IRetryAnalyzer retry = annotation.getRetryAnalyzer();
if (retry == null) {
annotation.setRetryAnalyzer(TestngRetry.class);
}
}
}
之后只需要在xml文件里加上这个监听器,或者在测试类中通过@Listeners({RetryListener.class})既可
@Listeners({RetryListener.class})
public class ApiTest extends TestBase
<listeners>
<listener class-name="com.sen.api.listeners.RetryListener"></listener>
</listeners>
测试报告最后如下