生成TestNG可邮件发送的html测试报告
使用EmailableReporter2生成TestNG可邮件发送的html测试报告
本次使用TestNG为当前最新版本7.3.0,XML中的DTD文件需要使用https协议
针对源码中的EmailableReporter2进行优化,生成自定义的可邮件发送的html测试报告
生成报告样式举例:
支持多suite,用例集为suite标题,测试用例为test标题。
依赖包:
<dependency> <groupId>org.testng</groupId> <artifactId>testng</artifactId> <version>7.3.0</version> </dependency>
源码:
package com.shein.dms.config; import org.testng.*; import org.testng.collections.Lists; import org.testng.internal.Utils; import org.testng.log4testng.Logger; import org.testng.reporters.RuntimeBehavior; import org.testng.xml.XmlSuite; import org.testng.xml.XmlSuite.ParallelMode; import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.text.NumberFormat; import java.util.*; import java.util.stream.Collectors; import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.Files.newBufferedWriter; /** * Reporter that generates a single-page HTML report of the test results. * 根据TestNG源码修改(org.testng.reporters.EmailableReporter2) */ public class EmailableReporter2 implements IReporter { private static final Logger LOG = Logger.getLogger(EmailableReporter2.class); protected PrintWriter writer; protected final List<SuiteResult> suiteResults = Lists.newArrayList(); // Reusable buffer private final StringBuilder buffer = new StringBuilder(); private static final String OUTPUT_FOLDER = "test-output/"; private String fileName = "emailable-report.html"; private String pageTitle = "测试报告"; @Override public void generateReport( List<XmlSuite> xmlSuites, List<ISuite> suites, String outputDirectory) { try { writer = createWriter(OUTPUT_FOLDER); } catch (IOException e) { LOG.error("Unable to create output file", e); return; } // for (ISuite suite : suites) { for (int i = suites.size(); i > 0; i--) { ISuite suite = suites.get(i - 1); suiteResults.add(new SuiteResult(suite)); } writeDocumentStart(); writeHead(); writeBody(); writeDocumentEnd(); writer.close(); } protected PrintWriter createWriter(String outdir) throws IOException { new File(outdir).mkdirs(); String jvmArg = RuntimeBehavior.getDefaultEmailableReport2Name(); if (jvmArg != null && !jvmArg.trim().isEmpty()) { fileName = jvmArg; } return new PrintWriter(newBufferedWriter(new File(outdir, fileName).toPath(), UTF_8)); } protected void writeDocumentStart() { writer.println( "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"https://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">"); writer.println("<html xmlns=\"https://www.w3.org/1999/xhtml\">"); } protected void writeHead() { writer.println("<head>"); writer.println("<meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\"/>"); // writer.println("<title>TestNG Report</title>"); writer.println("<title>" + pageTitle + "</title>"); writeStylesheet(); writer.println("</head>"); } protected void writeStylesheet() { writer.print("<style type=\"text/css\">"); writer.print("table {margin-bottom:10px;border-collapse:collapse;empty-cells:show;text-align:center} "); writer.print("th,td {border:1px solid #BEBEBE;padding:.25em .5em} "); writer.print("th {vertical-align:middle;background-color:#FFDAB9}} "); writer.print("td {vertical-align:middle;text-align:center} "); // writer.print("table a {font-weight:bold} "); writer.print(".stripe td {background-color: #FFEFDB} "); // 换行变色 writer.print("td.attn {background: #FF6A6A} "); // foxmail中标红样式 writer.print(".num {text-align:center} "); // writer.print(".passedodd td {background-color: #3F3} "); // writer.print(".passedeven td {background-color: #0A0} "); // writer.print(".skippedodd td {background-color: #DDD} "); // writer.print(".skippedeven td {background-color: #CCC} "); writer.print(".failedodd td,.attn {background-color: #FF6A6A} "); writer.print(".failedeven td,.stripe .attn {background-color: #FF6A6A} "); writer.print(".stacktrace {white-space:pre;font-family:monospace} "); writer.print(".totop {font-size:85%;text-align:center;border-bottom:2px solid #000} "); writer.print(".invisible {display:none} "); writer.println("</style>"); } protected void writeBody() { writer.println("<body>"); writeSuiteSummary(); // 功能模块维度概览 writer.println("</body>"); } protected void writeDocumentEnd() { writer.println("</html>"); } protected void writeSuiteSummary() { NumberFormat integerFormat = NumberFormat.getIntegerInstance(); NumberFormat decimalFormat = NumberFormat.getNumberInstance(); int totalPassedTests = 0; int totalSkippedTests = 0; int totalFailedTests = 0; int totalRetriedTests = 0; long totalDuration = 0; writer.println("<table>"); writer.print("<tr>"); writer.print("<th>功能</th>"); writer.print("<th>总数</th>"); writer.print("<th>成功</th>"); writer.print("<th>失败</th>"); writer.print("<th>跳过</th>"); writer.print("<th>重试</th>"); writer.print("<th>耗时(s)</th>"); // writer.print("<th>Included Groups</th>"); writer.print("<th>包含组</th>"); // writer.print("<th>Excluded Groups</th>"); writer.print("<th>排除组</th>"); writer.println("</tr>"); int testIndex = 0; for (SuiteResult suiteResult : suiteResults) { // 没有用例的suite标题不显示 if (suiteResult.getTestResults().size() < 1) { continue; } // 输出suite名称 writer.print("<tr><th colspan=\"9\">"); writer.print(Utils.escapeHtml(suiteResult.getSuiteName())); writer.println("</th></tr>"); for (TestResult testResult : suiteResult.getTestResults()) { int passedTests = testResult.getPassedTestCount(); int skippedTests = testResult.getSkippedTestCount(); int failedTests = testResult.getFailedTestCount(); int retriedTests = testResult.getRetriedTestCount(); int totalTests = passedTests + skippedTests + failedTests + retriedTests; // 时间换算为秒,不足一秒按一秒算 long duration = testResult.getDuration(); duration = duration / 1000 < 1 ? 1 : duration / 1000; writer.print("<tr"); if ((testIndex % 2) == 1) { writer.print(" class=\"stripe\""); } writer.print(">"); buffer.setLength(0); writeTableData( buffer.append(Utils.escapeHtml(testResult.getTestName())).toString()); writeTableData(integerFormat.format(totalTests), "num"); writeTableData(integerFormat.format(passedTests), "num"); writeTableData(integerFormat.format(failedTests), (failedTests > 0 ? "num attn" : "num")); writeTableData(integerFormat.format(skippedTests), (skippedTests > 0 ? "num attn" : "num")); writeTableData(integerFormat.format(retriedTests), (retriedTests > 0 ? "num attn" : "num")); writeTableData(decimalFormat.format(duration), "num"); writeTableData(testResult.getIncludedGroups()); writeTableData(testResult.getExcludedGroups()); writer.println("</tr>"); totalPassedTests += passedTests; totalSkippedTests += skippedTests; totalFailedTests += failedTests; totalRetriedTests += retriedTests; totalDuration += duration; testIndex++; } boolean testsInParallel = ParallelMode.TESTS.equals(suiteResult.getParallelMode()); if (testsInParallel) { Optional<TestResult> maxValue = suiteResult.testResults.stream() .max(Comparator.comparing(TestResult::getDuration)); if (maxValue.isPresent()) { totalDuration = Math.max(totalDuration, maxValue.get().duration); } } } // Print totals if there was more than one test if (testIndex > 1) { writer.print("<tr>"); writer.print("<th>Total</th>"); int allTotalTests = totalPassedTests + totalFailedTests + totalSkippedTests + totalRetriedTests; writeTableHeader(integerFormat.format(allTotalTests), "num"); writeTableHeader(integerFormat.format(totalPassedTests), "num"); writeTableHeader( integerFormat.format(totalFailedTests), (totalFailedTests > 0 ? "num attn" : "num")); writeTableHeader( integerFormat.format(totalSkippedTests), (totalSkippedTests > 0 ? "num attn" : "num")); writeTableHeader( integerFormat.format(totalRetriedTests), (totalRetriedTests > 0 ? "num attn" : "num")); writeTableHeader(decimalFormat.format(totalDuration), "num"); writer.print("<th colspan=\"2\"></th>"); writer.println("</tr>"); } writer.println("</table>"); } /** * Writes a TH element with the specified contents and CSS class names. * * @param html the HTML contents * @param cssClasses the space-delimited CSS classes or null if there are no classes to apply */ protected void writeTableHeader(String html, String cssClasses) { writeTag("th", html, cssClasses); } /** * Writes a TD element with the specified contents. * * @param html the HTML contents */ protected void writeTableData(String html) { writeTableData(html, null); } /** * Writes a TD element with the specified contents and CSS class names. * * @param html the HTML contents * @param cssClasses the space-delimited CSS classes or null if there are no classes to apply */ protected void writeTableData(String html, String cssClasses) { writeTag("td", html, cssClasses); } /** * Writes an arbitrary HTML element with the specified contents and CSS class names. * * @param tag the tag name * @param html the HTML contents * @param cssClasses the space-delimited CSS classes or null if there are no classes to apply */ protected void writeTag(String tag, String html, String cssClasses) { writer.print("<"); writer.print(tag); if (cssClasses != null) { writer.print(" class=\""); writer.print(cssClasses); writer.print("\""); } writer.print(">"); writer.print(html); writer.print("</"); writer.print(tag); writer.print(">"); } /** * Groups {@link TestResult}s by suite. */ protected static class SuiteResult { private final String suiteName; private final List<TestResult> testResults = Lists.newArrayList(); private final ParallelMode mode; public SuiteResult(ISuite suite) { suiteName = suite.getName(); mode = suite.getXmlSuite().getParallel(); for (ISuiteResult suiteResult : suite.getResults().values()) { testResults.add(new TestResult(suiteResult.getTestContext())); } } public String getSuiteName() { return suiteName; } /** * @return the test results (possibly empty) */ public List<TestResult> getTestResults() { return testResults; } public ParallelMode getParallelMode() { return mode; } } /** * Groups {@link ClassResult}s by test, type (configuration or test), and status. */ protected static class TestResult { /** * Orders test results by class name and then by method name (in lexicographic order). */ protected static final Comparator<ITestResult> RESULT_COMPARATOR = Comparator.comparing((ITestResult o) -> o.getTestClass().getName()) .thenComparing(o -> o.getMethod().getMethodName()); private final String testName; private final List<ClassResult> failedConfigurationResults; private final List<ClassResult> failedTestResults; private final List<ClassResult> skippedConfigurationResults; private final List<ClassResult> skippedTestResults; private final List<ClassResult> retriedTestResults; private final List<ClassResult> passedTestResults; private final int failedTestCount; private final int retriedTestCount; private final int skippedTestCount; private final int passedTestCount; private final long duration; private final String includedGroups; private final String excludedGroups; public TestResult(ITestContext context) { testName = context.getName(); Set<ITestResult> failedConfigurations = context.getFailedConfigurations().getAllResults(); Set<ITestResult> failedTests = context.getFailedTests().getAllResults(); Set<ITestResult> skippedConfigurations = context.getSkippedConfigurations().getAllResults(); Set<ITestResult> rawSkipped = context.getSkippedTests().getAllResults(); Set<ITestResult> skippedTests = pruneSkipped(rawSkipped); Set<ITestResult> retriedTests = pruneRetried(rawSkipped); Set<ITestResult> passedTests = context.getPassedTests().getAllResults(); failedConfigurationResults = groupResults(failedConfigurations); failedTestResults = groupResults(failedTests); skippedConfigurationResults = groupResults(skippedConfigurations); skippedTestResults = groupResults(skippedTests); retriedTestResults = groupResults(retriedTests); passedTestResults = groupResults(passedTests); failedTestCount = failedTests.size(); retriedTestCount = retriedTests.size(); skippedTestCount = skippedTests.size(); passedTestCount = passedTests.size(); duration = context.getEndDate().getTime() - context.getStartDate().getTime(); includedGroups = formatGroups(context.getIncludedGroups()); excludedGroups = formatGroups(context.getExcludedGroups()); } private static Set<ITestResult> pruneSkipped(Set<ITestResult> results) { return results.stream().filter(result -> !result.wasRetried()).collect(Collectors.toSet()); } private static Set<ITestResult> pruneRetried(Set<ITestResult> results) { return results.stream().filter(ITestResult::wasRetried).collect(Collectors.toSet()); } /** * Groups test results by method and then by class. * * @param results All test results * @return Test result grouped by method and class */ protected List<ClassResult> groupResults(Set<ITestResult> results) { List<ClassResult> classResults = Lists.newArrayList(); if (!results.isEmpty()) { List<MethodResult> resultsPerClass = Lists.newArrayList(); List<ITestResult> resultsPerMethod = Lists.newArrayList(); List<ITestResult> resultsList = Lists.newArrayList(results); resultsList.sort(RESULT_COMPARATOR); Iterator<ITestResult> resultsIterator = resultsList.iterator(); assert resultsIterator.hasNext(); ITestResult result = resultsIterator.next(); resultsPerMethod.add(result); String previousClassName = result.getTestClass().getName(); String previousMethodName = result.getMethod().getMethodName(); while (resultsIterator.hasNext()) { result = resultsIterator.next(); String className = result.getTestClass().getName(); if (!previousClassName.equals(className)) { // Different class implies different method assert !resultsPerMethod.isEmpty(); resultsPerClass.add(new MethodResult(resultsPerMethod)); resultsPerMethod = Lists.newArrayList(); assert !resultsPerClass.isEmpty(); classResults.add(new ClassResult(previousClassName, resultsPerClass)); resultsPerClass = Lists.newArrayList(); previousClassName = className; previousMethodName = result.getMethod().getMethodName(); } else { String methodName = result.getMethod().getMethodName(); if (!previousMethodName.equals(methodName)) { assert !resultsPerMethod.isEmpty(); resultsPerClass.add(new MethodResult(resultsPerMethod)); resultsPerMethod = Lists.newArrayList(); previousMethodName = methodName; } } resultsPerMethod.add(result); } assert !resultsPerMethod.isEmpty(); resultsPerClass.add(new MethodResult(resultsPerMethod)); assert !resultsPerClass.isEmpty(); classResults.add(new ClassResult(previousClassName, resultsPerClass)); } return classResults; } public String getTestName() { return testName; } public int getFailedTestCount() { return failedTestCount; } public int getSkippedTestCount() { return skippedTestCount; } public int getRetriedTestCount() { return retriedTestCount; } public int getPassedTestCount() { return passedTestCount; } public long getDuration() { return duration; } public String getIncludedGroups() { return includedGroups; } public String getExcludedGroups() { return excludedGroups; } /** * Formats an array of groups for display. * * @param groups The groups * @return The String value of the groups */ protected String formatGroups(String[] groups) { if (groups.length == 0) { return ""; } StringBuilder builder = new StringBuilder(); builder.append(groups[0]); for (int i = 1; i < groups.length; i++) { builder.append(", ").append(groups[i]); } return builder.toString(); } } /** * Groups {@link MethodResult}s by class. */ protected static class ClassResult { private final String className; private final List<MethodResult> methodResults; /** * @param className the class name * @param methodResults the non-null, non-empty {@link MethodResult} list */ public ClassResult(String className, List<MethodResult> methodResults) { this.className = className; this.methodResults = methodResults; } } /** * Groups test results by method. */ protected static class MethodResult { private final List<ITestResult> results; /** * @param results the non-null, non-empty result list */ public MethodResult(List<ITestResult> results) { this.results = results; } } }
配置Listener
<listeners>
<!--testng的XML配置文件中添加这些内容-->
<listener class-name="xxx.xxx.EmailableReporter2"/>
</listeners>