阅读本文大约需要10分钟
执行自动化测试用例时,经常会因为网络、环境等不确定因素导致执行结果不稳定。
为解决该问题,TestNG提供了失败用例立即重试的机制,此处的立即,指的是1个用例失败后,用户可以自定义操作之后决定是否重新执行该用例;执行完指定次数的重试或者在指定次数内成功之后,再执行下1个用例。上述描述只需要编写自定义的Retry类implements IRetryAnalyzer即可快速实现。已经有很多成功的案例,直接网上搜索即可快速找到教程,此处不再赘述。
然而,ci流水线上,大部分情况下,如果1个用例在某一时刻(秒级别)是失败的,再次重试80%以上也会是失败的。面对这种情况,我们一般都是在本地重试这些失败的case,往往又是可以成功通过的。那么,我们是否可以在ci流水上模拟这种操作, 等所有的用例执行完毕之后,重置环境,再统一重试执行失败的用例 呢?
由此需要先看看TestNG的源码的执行原理。
GitHub下载 TestNG最新源码
TestNG.run()方法
由以上代码可知, 只要将所有失败的case设置到一个新的xmlSuite上,调用TestNG.run()方法即可重试失败的case。
众所周知,自定义listener implements ITestListener可以监听测试结果,onFinish方法中,能够获取到所有用例的执行结果。只要重写这个方法,就能够将失败后重试成功的用例结果重置。
原生的报告是由XMLReporter.java生成的,从源码中不难看出,xml报告是基于ITestContext生成的。而ITestContext这个熟悉的对象,不正是上一步监听器中刚刚出现过吗?将源码中的XMLReporter.java复制出来,稍作修改,即可将覆盖原生的报告。也可以使用开源框架,或者自己编写代码生成自定义格式的报告。
TestRetryConfig.java,主要用来储存重试的一些配置及过程数据,如当前重试次数、重试最大次数、每次执行的ITestContext等。
public class TestRetryConfig {
public static final String TEST_PACKAGE_PATH = "com.xxx.xxx.testcases.";
public static final String RETRY_SUIT_NAME = "TestNG Retry Test";
public static int retryCount = 0;
public static int retryMax = 0;
public static List<ITestContext> finishTestContextList = new ArrayList();
public static ITestContext testContext = null;
public static void setRetryMax(int max) {
retryMax = max;
}
}
TestNgController.java,根据指定的Method,设置suite,并使用TestNG.run()方法执行用例。
public class TestNgController {
/***
* 需要重试的testNGMethod
*/
List<ITestNGMethod> testNGMethodList;
/***
* 按类名将ITestNGMethod分类
*/
Map<String, List<String>> failClassMethodMap;
public TestNgController(Collection<ITestNGMethod> testNGMethodSet) {
this.testNGMethodList = new ArrayList<>(testNGMethodSet);
}
private void getFialMap() {
if (this.testNGMethodList != null && this.testNGMethodList.size() > 0) {
failClassMethodMap = new HashMap<>();
for (ITestNGMethod iTestNGMethod : testNGMethodList) {
String className = iTestNGMethod.getTestClass().getName();
if (failClassMethodMap.get(className) != null) {
failClassMethodMap.get(className).add(iTestNGMethod.getMethodName());
} else {
List<String> methodList = new ArrayList<>();
methodList.add(iTestNGMethod.getMethodName());
failClassMethodMap.put(className, methodList);
}
}
}
}
/**
* TestNG测试程序化调用
*/
public boolean executeTests() {
//获取失败用例map
getFialMap();
//没有失败的用例
if (failClassMethodMap == null || failClassMethodMap.keySet().size() == 0) {
return false;
}
//构建testng.xml内存对象
try {
List<XmlSuite> suites = new ArrayList<XmlSuite>();
XmlSuite suite = new XmlSuite();
suite.setName(TestRetryConfig.RETRY_SUIT_NAME + TestRetryConfig.retryCount);
List<String> suiteListeners = new ArrayList<String>();
suiteListeners.add(TestListener.class.getName());
suite.setListeners(suiteListeners);
suites.add(suite);
XmlTest test = new XmlTest(suite);
test.setName(TestRetryConfig.RETRY_SUIT_NAME + TestRetryConfig.retryCount);
List<XmlClass> classes = new ArrayList<XmlClass>();
Set<String> failClassSet = failClassMethodMap.keySet();
for (String className : failClassSet) {
List<String> methodNameList = failClassMethodMap.get(className);
List<XmlInclude> includedMethodList = new ArrayList<>();
for (String methodName : methodNameList) {
includedMethodList.add(new XmlInclude(methodName));
}
XmlClass testClass = new XmlClass(className);
testClass.setIncludedMethods(includedMethodList);
classes.add(testClass);
}
test.setXmlClasses(classes);
LogUtils.log(String.format("第%s次执行批量重试测试用例开始!!!!!!!!! ",TestRetryConfig.retryCount));
//设置TestNG,并开始执行用例
TestNG testNG = new TestNG();
testNG.setXmlSuites(suites);
testNG.run();
return true;
} catch (Exception e) {
LogUtils.log("批量重试测试用例执行失败: " + e.getMessage());
e.printStackTrace();
}
return false;
}
public static void main(String[] args) {
TestNgController testNgController = new TestNgController(Collections.EMPTY_LIST);
testNgController.executeTests();
}
}
TestListener.java,主要记录每次执行完毕的结果,以及遇到失败时调用TestNgController进行执行失败case。
public class TestListener implements ITestListener {
@Override
public void onTestStart(ITestResult iTestResult) {
}
@Override
public void onTestSuccess(ITestResult iTestResult) {
}
@Override
public void onTestFailure(ITestResult iTestResult) {
}
@Override
public void onTestSkipped(ITestResult iTestResult) {
}
@Override
public void onTestFailedButWithinSuccessPercentage(ITestResult iTestResult) {
}
@Override
public void onStart(ITestContext iTestContext) {
}
@Override
public void onFinish(ITestContext iTestContext) {
TestRetryConfig.finishTestContextList.add(iTestContext);
//记录首次执行的测试集
if (TestRetryConfig.testContext == null) {
TestRetryConfig.testContext = iTestContext;
}
//已重试次数<最大重试次数,继续执行失败case
if (TestRetryConfig.retryCount < TestRetryConfig.retryMax) {
TestRetryConfig.retryCount++;
TestNgController testNgController = new TestNgController(iTestContext.getFailedTests().getAllMethods());
//有失败case,继续执行,无需处理结果
if (testNgController.executeTests()) {
return;
}
}
//若无失败case,没有继续执行,则需要继续处理结果
//指向首次执行的测试集
iTestContext = TestRetryConfig.testContext;
List<ITestResult> testsToBeRemoved = new ArrayList<>();
Set<Integer> passedTestIds = new HashSet<>();
//筛选出多次重试之后所有成功的用例
for (ITestContext testContext : TestRetryConfig.finishTestContextList) {
for (ITestResult passedTest : testContext.getPassedTests().getAllResults()) {
// LogUtils.log("PassedTests = " + passedTest.getMethod().getTestClass().getName() + "." + passedTest.getMethod().getMethodName());
passedTestIds.add(getId(passedTest));
}
}
//筛选出多次重试之后所有失败的用例
Set<Integer> failedTestIds = new HashSet<Integer>();
for (ITestResult failedTest : iTestContext.getFailedTests().getAllResults()) {
// LogUtils.log("failedTest = " + failedTest.getMethod().getTestClass().getName() + "." + failedTest.getMethod().getMethodName());
int failedTestId = getId(failedTest);
if (failedTestIds.contains(failedTestId) || passedTestIds.contains(failedTestId)) {
testsToBeRemoved.add(failedTest);
} else {
failedTestIds.add(failedTestId);
}
}
//将重试之后成功的用例,从失败结果集中删除,并添加到成功的结果集
for (Iterator<ITestResult> iterator = iTestContext.getFailedTests().getAllResults().iterator(); iterator
.hasNext(); ) {
ITestResult testResult = iterator.next();
if (testsToBeRemoved.contains(testResult)) {
LogUtils.log("Change failed test to passed: " + testResult.getMethod().getTestClass().getName() + "." + testResult.getMethod().getMethodName());
iterator.remove();
iTestContext.getPassedTests().addResult(testResult, testResult.getMethod());
}
}
}
private int getId(ITestResult result) {
int id = result.getTestClass().getName().hashCode();
id = id + result.getMethod().getMethodName().hashCode();
id = id + (result.getParameters() != null ? Arrays.hashCode(result.getParameters()) : 0);
return id;
}
XMLReporter.java,从源码中复制出来,稍作修改。
/**
* The main entry for the XML generation operation
*/
public class XMLReporter implements IReporter {
public static final String FILE_NAME = "testng-results.xml";
private final XMLReporterConfig config = new XMLReporterConfig();
private XMLStringBuffer rootBuffer;
@Override
public void generateReport(List<XmlSuite> xmlSuites, List<ISuite> suites, String outputDirectory) {
ITestContext testContext = TestRetryConfig.testContext;
if (Utils.isStringEmpty(config.getOutputDirectory())) {
config.setOutputDirectory(outputDirectory);
}
// Calculate passed/failed/skipped
int passed = testContext.getPassedTests().size();
int failed = testContext.getFailedTests().size();
int skipped = testContext.getSkippedTests().size();
int ignored = 0;
int retried = 0;
rootBuffer = new XMLStringBuffer();
Properties p = new Properties();
p.put("passed", passed);
p.put("failed", failed);
p.put("skipped", skipped);
if (retried > 0) {
p.put("retried", retried);
}
p.put("ignored", ignored);
p.put("total", passed + failed + skipped + ignored + retried);
rootBuffer.push(XMLReporterConfig.TAG_TESTNG_RESULTS, p);
writeReporterOutput(rootBuffer);
for (ISuite suite : suites) {
if (suite.getName().contains(TestRetryConfig.RETRY_SUIT_NAME)) {
continue;
}
writeSuite(suite);
}
rootBuffer.pop();
Utils.writeUtf8File(config.getOutputDirectory(), fileName(), rootBuffer, null /* no prefix */);
}
private static String fileName() {
return FILE_NAME;
}
private void writeReporterOutput(XMLStringBuffer xmlBuffer) {
// TODO: Cosmin - maybe a <line> element isn't indicated for each line
xmlBuffer.push(XMLReporterConfig.TAG_REPORTER_OUTPUT);
List<String> output = Reporter.getOutput();
for (String line : output) {
if (line != null) {
xmlBuffer.push(XMLReporterConfig.TAG_LINE);
xmlBuffer.addCDATA(line);
xmlBuffer.pop();
}
}
xmlBuffer.pop();
}
private void writeSuite(ISuite suite) {
switch (config.getFileFragmentationLevel()) {
case XMLReporterConfig.FF_LEVEL_NONE:
writeSuiteToBuffer(rootBuffer, suite);
break;
case XMLReporterConfig.FF_LEVEL_SUITE:
case XMLReporterConfig.FF_LEVEL_SUITE_RESULT:
File suiteFile = referenceSuite(rootBuffer, suite);
writeSuiteToFile(suiteFile, suite);
break;
default:
throw new AssertionError("Unexpected value: " + config.getFileFragmentationLevel());
}
}
private void writeSuiteToFile(File suiteFile, ISuite suite) {
XMLStringBuffer xmlBuffer = new XMLStringBuffer();
writeSuiteToBuffer(xmlBuffer, suite);
File parentDir = suiteFile.getParentFile();
suiteFile.getParentFile().mkdirs();
if (parentDir.exists() || suiteFile.getParentFile().exists()) {
Utils.writeUtf8File(parentDir.getAbsolutePath(), fileName(), xmlBuffer.toXML());
}
}
private File referenceSuite(XMLStringBuffer xmlBuffer, ISuite suite) {
String relativePath = suite.getName() + File.separatorChar + fileName();
File suiteFile = new File(config.getOutputDirectory(), relativePath);
Properties attrs = new Properties();
attrs.setProperty(XMLReporterConfig.ATTR_URL, relativePath);
xmlBuffer.addEmptyElement(XMLReporterConfig.TAG_SUITE, attrs);
return suiteFile;
}
private void writeSuiteToBuffer(XMLStringBuffer xmlBuffer, ISuite suite) {
xmlBuffer.push(XMLReporterConfig.TAG_SUITE, getSuiteAttributes(suite));
writeSuiteGroups(xmlBuffer, suite);
Map<String, ISuiteResult> results = suite.getResults();
XMLSuiteResultWriter suiteResultWriter = new XMLSuiteResultWriter(config);
for (Map.Entry<String, ISuiteResult> result : results.entrySet()) {
suiteResultWriter.writeSuiteResult(xmlBuffer, result.getValue());
}
xmlBuffer.pop();
}
private void writeSuiteGroups(XMLStringBuffer xmlBuffer, ISuite suite) {
xmlBuffer.push(XMLReporterConfig.TAG_GROUPS);
Map<String, Collection<ITestNGMethod>> methodsByGroups = suite.getMethodsByGroups();
for (Map.Entry<String, Collection<ITestNGMethod>> entry : methodsByGroups.entrySet()) {
Properties groupAttrs = new Properties();
groupAttrs.setProperty(XMLReporterConfig.ATTR_NAME, entry.getKey());
xmlBuffer.push(XMLReporterConfig.TAG_GROUP, groupAttrs);
Set<ITestNGMethod> groupMethods = getUniqueMethodSet(entry.getValue());
for (ITestNGMethod groupMethod : groupMethods) {
Properties methodAttrs = new Properties();
methodAttrs.setProperty(XMLReporterConfig.ATTR_NAME, groupMethod.getMethodName());
methodAttrs.setProperty(XMLReporterConfig.ATTR_METHOD_SIG, groupMethod.toString());
methodAttrs.setProperty(XMLReporterConfig.ATTR_CLASS, groupMethod.getRealClass().getName());
xmlBuffer.addEmptyElement(XMLReporterConfig.TAG_METHOD, methodAttrs);
}
xmlBuffer.pop();
}
xmlBuffer.pop();
}
private Properties getSuiteAttributes(ISuite suite) {
Properties props = new Properties();
props.setProperty(XMLReporterConfig.ATTR_NAME, suite.getName());
// Calculate the duration
Map<String, ISuiteResult> results = suite.getResults();
Date minStartDate = new Date();
Date maxEndDate = null;
// TODO: We could probably optimize this in order not to traverse this twice
for (Map.Entry<String, ISuiteResult> result : results.entrySet()) {
ITestContext testContext = result.getValue().getTestContext();
Date startDate = testContext.getStartDate();
Date endDate = testContext.getEndDate();
if (minStartDate.after(startDate)) {
minStartDate = startDate;
}
if (maxEndDate == null || maxEndDate.before(endDate)) {
maxEndDate = endDate != null ? endDate : startDate;
}
}
// The suite could be completely empty
if (maxEndDate == null) {
maxEndDate = minStartDate;
}
addDurationAttributes(config, props, minStartDate, maxEndDate);
return props;
}
/**
* Add started-at, finished-at and duration-ms attributes to the <suite> tag
*/
public static void addDurationAttributes(
XMLReporterConfig config, Properties attributes, Date minStartDate, Date maxEndDate) {
String startTime = DateUtils.formatDate(minStartDate.getTime());
String endTime = DateUtils.formatDate(maxEndDate.getTime());
long duration = maxEndDate.getTime() - minStartDate.getTime();
attributes.setProperty(XMLReporterConfig.ATTR_STARTED_AT, startTime);
attributes.setProperty(XMLReporterConfig.ATTR_FINISHED_AT, endTime);
attributes.setProperty(XMLReporterConfig.ATTR_DURATION_MS, Long.toString(duration));
}
private Set<ITestNGMethod> getUniqueMethodSet(Collection<ITestNGMethod> methods) {
return new LinkedHashSet<>(methods);
}
}
HtmlReporter,使用开源框架extentreports生成html报告。
public class HtmlReporter implements IReporter {
//生成的路径以及文件名
private static final String OUTPUT_FOLDER = "test-output/";
private static final String FILE_NAME = "report.html";
private ExtentReports extent;
@Override
public void generateReport(List<XmlSuite> suites, List<ISuite> list1, String s) {
init();
//统计suite下的成功、失败、跳过的总用例数
int suiteFailSize = 0;
int suitePassSize = 0;
int suiteSkipSize = 0;
ExtentTest suiteTest = null;
ExtentTest resultNode;
ITestContext context = TestRetryConfig.testContext;
boolean createSuiteResultNode = false;
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.getModel().setName(context.getSuite().getName() + " : " + context.getName());
if (resultNode.getModel().hasCategory()) {
resultNode.assignCategory(context.getName());
} else {
resultNode.assignCategory(context.getSuite().getName(), context.getName());
}
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));
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);
// 设置静态文件的DNS
htmlReporter.config().setResourceCDN(ResourceCDN.EXTENTREPORTS);
htmlReporter.config().setDocumentTitle("TEST RESULT");
htmlReporter.config().setReportName("TEST RESULT");
htmlReporter.config().setChartVisibilityOnOpen(true);
htmlReporter.config().setTestViewChartLocation(ChartLocation.TOP);
htmlReporter.config().setTheme(Theme.STANDARD);
htmlReporter.config().setCSS(".node.level-1 ul{ display:none;} .node.level-1.active ul{display:block;}");
htmlReporter.config().setEncoding("gbk");
extent = new ExtentReports();
extent.attachReporter(htmlReporter);
extent.setReportUsesManualConfiguration(true);
}
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() > 50) {
name = name.substring(0, 49) + "...";
} else {
name = result.getTestClass().getName().replace(TestRetryConfig.TEST_PACKAGE_PATH, "") + "." + result.getMethod().getMethodName();
}
if (extenttest == null) {
test = extent.createTest(name);
} else {
//作为子节点进行创建时,设置同父节点的标签一致,便于报告检索。
test = extenttest.createNode(name).assignCategory(categories);
}
for (String group : result.getMethod().getGroups())
test.assignCategory(group);
List<String> outputList = Reporter.getOutput(result);
for (String output : outputList) {
//将用例的log输出报告中
test.debug(output);
}
if (result.getThrowable() != null) {
test.log(status, result.getThrowable());
} else {
test.log(status, "Test " + status.toString().toLowerCase() + "ed");
}
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();
}
}
public class Test1 {
static int n = 0;
static int i = 0;
static int j = 0;
static int k = 0;
@Test(description = "重试3次会成功")
public void test4() {
k++;
assertEquals(4, k);
}
@Test(description = "重试2次会成功")
public void test3() {
j++;
assertEquals(3, j);
}
@Test(description = "重试1次会成功")
public void test2() {
i++;
assertEquals(2, i);
}
@Test(description = "首次成功")
public void test1() {
n++;
assertEquals(1, n);
}
}
public class Test2 {
static int n = 0;
static int i = 0;
@Test(description = "重试1次会成功")
public void test2() {
i++;
assertEquals(2, i);
}
@Test(description = "首次成功")
public void test1() {
n++;
assertEquals(1, n);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="Surefire_suite" verbose="1" configfailurepolicy="continue">
<listeners>
<listener class-name="com.xxx.xxx.testcases.test.TestListener"/>
<listener class-name="com.xxx.xxx..testcases.test.HtmlReporter"/>
<listener class-name="com.xxx.xxx..testcases.test.XMLReporter"/>
</listeners>
<test name="test">
<packages>
<package name="com.xxx.xxx..testcases.test.*"/>
</packages>
</test>
</suite>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
<configuration>
<properties>
<property>
<name>usedefaultlisteners</name>
<value>false</value> <!-- disabling default listeners is optional -->
</property>
</properties>
<testFailureIgnore>true</testFailureIgnore>
<suiteXmlFiles>${suiteXmlFile}</suiteXmlFiles>
</configuration>
</plugin>
生成TestNG报告,报告pattern:“target/surefire-reports/testng-results.xml”