编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

使用wkhtmltopdf生成基于Jira的测试报告

wxchong 2024-08-07 01:32:14 开源技术 30 ℃ 0 评论

实践过程中,我们使用Jira做统一的项目管理和缺陷管理。在测试完成之后,测试同学需要发送测试报告或者release notes给项目组成员,虽然Jira自身提供了基于release管理的release notes的自动生成,但其界面不太友好,所以有必要我们做一下二次开发。

一般测试报告的生成,采用word格式(直接套用定义好的模板,填入数据)、html格式或者mardown等格式,其中html和markdown格式非常方便程序的自动生成,我们两种方式都支持。

而在测试报告的发送这一块,可以发送一个链接,可以发送一个附件,也可以在邮件内部链接上报告的内容。如此,html格式最为适合。其中在附件类型选择上,html或者pdf都具有很好的显示效果和跨平台性。此处为了方便存档,我们增加了pdf格式的测试报告的自动生成。

wkhtmltopdf简介

其官网为:https://wkhtmltopdf.org/index.html。

首先我们复制一下官网介绍:

What is it?

wkhtmltopdf and wkhtmltoimage are open source (LGPLv3) command line tools to render HTML into PDF and various image formats using the Qt WebKit rendering engine. These run entirely "headless" and do not require a display or display service.

There is also a C library, if you're into that kind of thing.

简单翻译一下:wkhtmltopdf和wkhtmltoimage是基于开源LGPLv3协议的命令行工具,它使用Qt webkit渲染引擎把HTML渲染为PDF。运行的时候,是“无头”的并不需要显示器或者显示服务。

同时该工具也提供了基于C的库文件。

How do I use it?

  1. Download a precompiled binary or build from source
  2. Create your HTML document that you want to turn into a PDF (or image)
  3. Run your HTML document through the tool.
  4. For example, if I really like the treatment Google has done to their logo today and want to capture it forever as a PDF:
  5. wkhtmltopdf http://google.com google.pdf

再简单翻译一下使用方法:下载二进制文件或者从源文件构建;创建html文件;以google为例进行html到pdf的转换: wkhtmltopdf http://google.com google.pdf即可。

整体看,wkhtmltopdf使用起来很方便,pdf转换效果很理想,安装也简单。其下载地址为:https://wkhtmltopdf.org/downloads.html

定义html模板文件

测试报告的html模板,这里列出一些共性的东西:上线内容、缺陷统计、研发效率等,大家可以结合自身的业务需要进行添加。简单的实现,可以直接做字符串替换,麻烦点的,就可以用thymeleaf等模板文件了:

<html>
 <meta http-equiv="content-type" content="text/html;charset=utf-8">
 <head>
 <style type="text/css">
 body{
 font-size: 9pt;
 }
 table{border-collapse: collapse;width:800px;}
 tr{}
 td{border: 1px solid #0D3349;padding:5px;font-size:9pt;}
 .tr-label{
 background-color: #2D64B3;
 color:#ffffff;
 }
 a{
 text-decoration: none;
 color:#000000;
 }
 </style>
 <title>{{REPORT_TITLE}}</title>
 </head>
 <body>
 <h1>{{REPORT_TITLE}}</h1>
 <hr style="size:1px;"/>
 <p><h2>上线内容:</h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">类别</td>
 <td>内容</td>
 </tr>
 {{RELEASE_NOTE}}
 </table>
 <p><h2>缺陷分析 - 根据模块:</h2></h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">模块名称</td>
 <td>个数</td>
 </tr>
 {{ISSUE_COMPONENT}}
 </table>
 <p><h2>缺陷分析 - 根据根据状态:</h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">状态</td>
 <td>个数</td>
 </tr>
 {{ISSUE_STATUS}}
 </table>
 <p><h2>缺陷分析 - 根据优先级:</h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">优先级</td>
 <td>个数</td>
 </tr>
 {{ISSUE_PRIORITY}}
 </table>
 <p><h2>缺陷分析 - 根据经办人:</h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">经办人</td>
 <td>个数</td>
 </tr>
 {{ISSUE_ASSIGNEE}}
 </table>
 <p><h2>缺陷分析 - 根据报告人:</h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">报告人</td>
 <td>个数</td>
 </tr>
 {{ISSUE_REPORTER}}
 </table>
 <p><h2>缺陷开发测试效率</h2></p>
 <table>
 <tr class="tr-label">
 <td width="150px">类别</td>
 <td>耗时(小时)</td>
 </tr>
 <tr>
 <td width="150px">开发响应平均耗时</td>
 <td>{{DEV_REACT}}</td>
 </tr>
 <tr>
 <td width="150px">开发处理平均耗时</td>
 <td>{{DEV_PROCESS}}</td>
 </tr>
 <tr>
 <td width="150px">测试响应平均耗时</td>
 <td>{{TEST_REACT}}</td>
 </tr>
 <tr>
 <td width="150px">测试处理平均耗时</td>
 <td>{{TEST_PROCESS}}</td>
 </tr>
 </table>
 </body>
</html>

结果数据的获取

上文提到,我们才用Jira做项目管理,所以测试数据的获取,主要还是使用Jira-client这个三方jar,个别地方,比如缺陷时间这一块,采用直接query数据库的方式实现以提高效率,关键代码如下(注意里面夹杂了mardown格式的):

/**
 * 获取根据jql生成的issue
 * @param jql
 * @return
 * @throws Exception
 */
public List<Issue> getIssues(String jql) throws Exception{
 Iterator<Issue> iterator = jiraClient.searchIssues(jql).iterator();
 List<Issue> list = new ArrayList<>();
 while(iterator.hasNext()){
 list.add(iterator.next());
 }
 return list;
}
/**
 * 生成报告
 * @param title
 * @param jql
 * @return
 * @throws Exception
 */
public String[] generateReport(String title, String jql) throws Exception{
 String[] content = generateReport(getIssues(jql));
 for(int i = 0; i < content.length; i ++){
 content[i] = content[i].replace("{{REPORT_TITLE}}", title);
 }
 return content;
}
/**
 * 测试报告生成pdf
 * @param title
 * @param jql
 * @throws Exception
 */
public File generateReportPdf(String title, String jql) throws Exception{
 String html = generateReport(title, jql)[1];
 LocalDateTime localDate = LocalDateTime.now();
 String date = localDate.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
 String htmlFileName = jiraReportPath + "/" + date + ".html";
 IOUtils.write(html, new FileOutputStream(new File(htmlFileName)));
 String pdfFileName = jiraReportPath + "/" + date + ".pdf";
 String cmd = wkthmltopdfCmd + " " + htmlFileName + " " + pdfFileName;
 Process process = Runtime.getRuntime().exec(cmd);
 process.waitFor();
 return new File(pdfFileName);
}
private int timediffToMinutes(String timeDiff){
 String[] info = timeDiff.split(":");
 return Integer.valueOf(info[0]) * 60 + Integer.valueOf(info[1]);
}
/**
 * 获取issue的开发、测试响应和处理耗时
 * @param issueList
 * @return
 */
private String[] issueReactProcess(List<Issue> issueList){
 List<Integer> devReact = new ArrayList<>();
 List<Integer> devProcess = new ArrayList<>();
 List<Integer> testReact = new ArrayList<>();
 List<Integer> testProcess = new ArrayList<>();
 String[] result = new String[8];
 int maxSingleDevReact = 0;
 int maxSingleDevProcess = 0;
 int maxSingleTestReact = 0;
 int maxSingleTestProcess = 0;
 for(Issue issue : issueList){
 if((issue.getIssueType().getName().equalsIgnoreCase("BUG")
 || issue.getIssueType().getName().equalsIgnoreCase("缺陷")
 || issue.getIssueType().getName().equalsIgnoreCase("故障")) && issue.getStatus().getName().equalsIgnoreCase("测试通过")){//只统计测试通过的
 log.info(">>> 添加需要统计耗时的缺陷:{}", issue.getKey());
 try {
 String sql = "select timediff(changegroup.created,jiraissue.created) from changeitem,changegroup,jiraissue where \n" +
 "changeitem.field='status' and changeitem.groupid in (select id from changegroup where issueid=" + issue.getId() + ") and changeitem.groupid=changegroup.id \n" +
 "and changegroup.issueid=jiraissue.id and changeitem.newString='处理中';";
 int singleDevReact = timediffToMinutes(jiraJdbcTemplate.queryForObject(sql, String.class));
 devReact.add(singleDevReact);
 if(singleDevReact > maxSingleDevReact){
 maxSingleDevReact = singleDevReact;
 try {
 result[4] = "<a href='" + IssueUtils.webUrl(issue) + "' target='_blank'>" + issue.getSummary() + "</a>";
 } catch (Exception e) {
 e.printStackTrace();
 }
 }
 sql = "select timediff(changegroup.created,jiraissue.created) from changeitem,changegroup,jiraissue where \n" +
 "changeitem.field='status' and changeitem.groupid in (select id from changegroup where issueid=" + issue.getId() + ") and changeitem.groupid=changegroup.id \n" +
 "and changegroup.issueid=jiraissue.id and changeitem.newString='开发完成';";
 int singleDevProcess = timediffToMinutes(jiraJdbcTemplate.queryForObject(sql, String.class));
 devProcess.add(singleDevProcess - singleDevReact);
 if(singleDevProcess > maxSingleDevProcess){
 maxSingleDevProcess = singleDevProcess;
 try {
 result[5] = "<a href='" + IssueUtils.webUrl(issue) + "' target='_blank'>" + issue.getSummary() + "</a>";
 } catch (Exception e) {
 e.printStackTrace();
 }
 }
 sql = "select timediff(changegroup.created,jiraissue.created) from changeitem,changegroup,jiraissue where \n" +
 "changeitem.field='status' and changeitem.groupid in (select id from changegroup where issueid=" + issue.getId() + ") and changeitem.groupid=changegroup.id \n" +
 "and changegroup.issueid=jiraissue.id and changeitem.newString='测试进行中';";
 int singleTestReact = timediffToMinutes(jiraJdbcTemplate.queryForObject(sql, String.class));
 testReact.add(singleTestReact - singleDevProcess);
 if(singleTestReact > maxSingleTestReact){
 maxSingleTestReact = singleTestReact;
 try {
 result[6] = "<a href='" + IssueUtils.webUrl(issue) + "' target='_blank'>" + issue.getSummary() + "</a>";
 } catch (Exception e) {
 e.printStackTrace();
 }
 }
 sql = "select timediff(changegroup.created,jiraissue.created) from changeitem,changegroup,jiraissue where \n" +
 "changeitem.field='status' and changeitem.groupid in (select id from changegroup where issueid=" + issue.getId() + ") and changeitem.groupid=changegroup.id \n" +
 "and changegroup.issueid=jiraissue.id and changeitem.newString='测试通过';";
 int singleTestProcess = timediffToMinutes(jiraJdbcTemplate.queryForObject(sql, String.class));
 testProcess.add(singleTestProcess - singleTestReact);
 if(singleTestProcess > maxSingleTestProcess){
 maxSingleTestProcess = singleTestProcess;
 try {
 result[7] = "<a href='" + IssueUtils.webUrl(issue) + "' target='_blank'>" + issue.getSummary() + "</a>";
 } catch (Exception e) {
 e.printStackTrace();
 }
 }
 } catch (DataAccessException e) {
 continue;
 }
 }
 }
 result[0] = String.valueOf(generateIssueDuration(devReact));
 result[1] = String.valueOf(generateIssueDuration(devProcess));
 result[2] = String.valueOf(generateIssueDuration(testReact));
 result[3] = String.valueOf(generateIssueDuration(testProcess));
 return result;
}
private double generateIssueDuration(List<Integer> list){
 DescriptiveStatistics descriptiveStatistics = new DescriptiveStatistics();
 for(int d : list){
 descriptiveStatistics.addValue(d / 60f);//按小时统计
 }
 double r = 0;
 try{
 r = new BigDecimal(descriptiveStatistics.getMean()).setScale(BigDecimal.ROUND_HALF_UP, 2).doubleValue();
 }catch(Exception e){
 }
 return r;
}
/**
 * 生成报告
 * @param issueList
 * @throws Exception
 */
private String[] generateReport(List<Issue> issueList) throws Exception{
 String[] issueReactProcessResult = issueReactProcess(issueList);
 Map<String, String> ldapUserMap = new HashMap<>();
 for(LdapUserEntity ldapUserEntity : ldapUserService.allUsers()){
 ldapUserMap.put(ldapUserEntity.getUserName(), ldapUserEntity.getDisplayName());
 }
 ldapUserMap.put("TBD", "TBD");
 String content = IOUtils.toString(new ClassPathResource("jira/report.md").getInputStream(), "utf-8");
 String content2 = IOUtils.toString(new ClassPathResource("jira/jira_release_report.html").getInputStream(), "utf-8");
 content2 = content2.replace("{{DEV_REACT}}", issueReactProcessResult[0]);
 content2 = content2.replace("{{DEV_PROCESS}}", issueReactProcessResult[1]);
 content2 = content2.replace("{{TEST_REACT}}", issueReactProcessResult[2]);
 content2 = content2.replace("{{TEST_PROCESS}}", issueReactProcessResult[3]);
 content2 = content2.replace("{{MAX_DEV_REACT}}", issueReactProcessResult[4]);
 content2 = content2.replace("{{MAX_DEV_PROCESS}}", issueReactProcessResult[5]);
 content2 = content2.replace("{{MAX_TEST_REACT}}", issueReactProcessResult[6]);
 content2 = content2.replace("{{MAX_TEST_PROCESS}}", issueReactProcessResult[7]);
 Map<String, List<String>> releaseNote = new HashMap<>();
 Map<String, Integer> issuePriority = new HashMap<>();
 Map<String, Integer> issueAssignee = new HashMap<>();
 Map<String, Integer> issueReporter = new HashMap<>();
 Map<String, Integer> issueStatus = new HashMap<>();
 Map<String, Integer> component = new HashMap<>();
 Comparator<Map.Entry<String, Integer>> comparator = new Comparator<Map.Entry<String, Integer>>() {
 @Override
 public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
 if(o1.getKey().equalsIgnoreCase(o2.getKey())){
 return o2.getValue().compareTo(o1.getValue());
 }
 else{
 return o1.getKey().compareTo(o2.getKey());
 }
 }
 };
 for(Issue issue : issueList){
 //生成release note
 String type = issue.getIssueType().getName();
 if(!releaseNote.keySet().contains(type)){
 releaseNote.put(type, new ArrayList<String>());
 }
 releaseNote.get(type).add(issue.getKey() + ":" + issue.getSummary());
 if("bug".equalsIgnoreCase(type) || "故障".equalsIgnoreCase(type)) {
 //统计priority
 String priority = issue.getPriority().getName();
 if (!issuePriority.keySet().contains(priority)) {
 issuePriority.put(priority, 0);
 }
 issuePriority.put(priority, issuePriority.get(priority) + 1);
 //统计assignee
 String assignee = "TBD";
 try {
 assignee = issue.getAssignee().getName();
 } catch (Exception e) {
 log.error(e.getMessage(), e);
 }
 if (!issueAssignee.keySet().contains(assignee)) {
 issueAssignee.put(assignee, 0);
 }
 issueAssignee.put(assignee, issueAssignee.get(assignee) + 1);
 //统计reporter
 String reporter = "TBD";
 try {
 reporter = issue.getReporter().getName();
 } catch (Exception e) {
 log.error(e.getMessage(), e);
 }
 if (!issueReporter.keySet().contains(reporter)) {
 issueReporter.put(reporter, 0);
 }
 issueReporter.put(reporter, issueReporter.get(reporter) + 1);
 //统计issue status
 String status = issue.getStatus().getName();
 if (!issueStatus.keySet().contains(status)) {
 issueStatus.put(status, 0);
 }
 issueStatus.put(status, issueStatus.get(status) + 1);
 //统计component
 for (net.rcarz.jiraclient.Component c : issue.getComponents()) {
 String cc = c.getName();
 if (!component.keySet().contains(cc)) {
 component.put(cc, 0);
 }
 component.put(cc, component.get(cc) + 1);
 }
 }
 }
 //issuePriority排序
 List<Map.Entry<String, Integer>> list = new ArrayList<Map.Entry<String, Integer>>(issuePriority.entrySet());
 Collections.sort(list, comparator);
 StringBuilder sb = new StringBuilder();
 StringBuilder sb2 = new StringBuilder();
 sb.append("| 级别 | 个数 |\n");
 sb.append("|:----|----:|\n");
 for(Map.Entry<String, Integer> entry : list){
 sb.append("| " + entry.getKey() + " | " + entry.getValue() + " |\n");
 sb2.append("<tr><td>" + entry.getKey() + "</td><td>" + entry.getValue() + "</td></tr>");
 }
 content = content.replace("{{ISSUE_PRIORITY}}", sb.toString());
 content2= content2.replace("{{ISSUE_PRIORITY}}", sb2.toString());
 //issueassignee
 list = new ArrayList<Map.Entry<String, Integer>>(issueAssignee.entrySet());
 Collections.sort(list, comparator);
 sb = new StringBuilder();
 sb2 = new StringBuilder();
 sb.append("| 经办人 | 个数 |\n");
 sb.append("|:----|----:|\n");
 for(Map.Entry<String, Integer> entry : list){
 sb.append("| " + entry.getKey() + " | " + entry.getValue() + " |\n");
 sb2.append("<tr><td>" + ldapUserMap.get(entry.getKey()) + "</td><td>" + entry.getValue() + "</td></tr>");
 }
 content = content.replace("{{ISSUE_ASSIGNEE}}", sb.toString());
 content2 = content2.replace("{{ISSUE_ASSIGNEE}}", sb2.toString());
 //issue reporter
 list = new ArrayList<Map.Entry<String, Integer>>(issueReporter.entrySet());
 Collections.sort(list, comparator);
 sb = new StringBuilder();
 sb2 = new StringBuilder();
 sb.append("| 报告人 | 个数 |\n");
 sb.append("|:----|----:|\n");
 for(Map.Entry<String, Integer> entry : list){
 sb.append("| " + entry.getKey() + " | " + entry.getValue() + " |\n");
 sb2.append("<tr><td>" + ldapUserMap.get(entry.getKey()) + "</td><td>" + entry.getValue() + "</td></tr>");
 }
 content = content.replace("{{ISSUE_REPORTER}}", sb.toString());
 content2 = content2.replace("{{ISSUE_REPORTER}}", sb2.toString());
 //issuestatus
 list = new ArrayList<Map.Entry<String, Integer>>(issueStatus.entrySet());
 Collections.sort(list, comparator);
 sb = new StringBuilder();
 sb2 = new StringBuilder();
 sb.append("| 状态 | 个数 |\n");
 sb.append("|:----|----:|\n");
 for(Map.Entry<String, Integer> entry : list){
 sb.append("| " + entry.getKey() + " | " + entry.getValue() + " |\n");
 sb2.append("<tr><td>" + entry.getKey() + "</td><td>" + entry.getValue() + "</td></tr>");
 }
 content = content.replace("{{ISSUE_STATUS}}", sb.toString());
 content2 = content2.replace("{{ISSUE_STATUS}}", sb2.toString());
 //issue component
 list = new ArrayList<Map.Entry<String, Integer>>(component.entrySet());
 Collections.sort(list, comparator);
 sb = new StringBuilder();
 sb2 = new StringBuilder();
 sb.append("| 模块 | 个数 |\n");
 sb.append("|:----|----:|\n");
 for(Map.Entry<String, Integer> entry : list){
 sb.append("| " + entry.getKey() + " | " + entry.getValue() + " |\n");
 sb2.append("<tr><td>" + entry.getKey() + "</td><td>" + entry.getValue() + "</td></tr>");
 }
 content = content.replace("{{ISSUE_COMPONENT}}", sb.toString());
 content2 = content2.replace("{{ISSUE_COMPONENT}}", sb2.toString());
 //release note
 List<Map.Entry<String, List<String>>> list2 = new ArrayList<>(releaseNote.entrySet());
 Collections.sort(list2, new Comparator<Map.Entry<String, List<String>>>() {
 @Override
 public int compare(Map.Entry<String, List<String>> o1, Map.Entry<String, List<String>> o2) {
 return o1.getKey().compareTo(o2.getKey());
 }
 });
 sb = new StringBuilder();
 sb2 = new StringBuilder();
 Comparator<String> comparator2 = new Comparator<String>() {
 @Override
 public int compare(String o1, String o2) {
 return o1.substring(0, o1.indexOf(":")).compareTo(o2.substring(0, o2.indexOf(":")));
 }
 };
 for(Map.Entry<String, List<String>> entry : list2){
 List<String> valueList = entry.getValue();
 Collections.sort(valueList, comparator2);
 for(String v : valueList) {
 sb.append("* [ " + entry.getKey() + " ] " + v + "\n");
 sb2.append("<tr><td>" + entry.getKey() + "</td><td><a href='" + jiraIssueBrowseUrl + v.substring(0, v.indexOf(":")) + "' target='_blank'>" + v + "</a></td></tr>");
 }
 }
 content = content.replace("{{RELEASE_NOTE}}", sb.toString());
 content2 = content2.replace("{{RELEASE_NOTE}}", sb2.toString());
 return new String[]{content, content2};
}
private String jiraBugSummaryProcessUser(String user){
 String[] reporters = user.split(",");
 for(int i = 0; i < reporters.length; i ++){
 reporters[i] = "'" + reporters[i] + "'";
 }
 user = StringUtils.join(reporters, ",");
 return user;
}
/**
 *
 * @param type
 * @param user
 * @return
 */
public List<String> jiraBugSummaryLabel(String type, String user){
 user = jiraBugSummaryProcessUser(user);
 String sql = "";
 if("reporter".equalsIgnoreCase(type) || "depReporter".equalsIgnoreCase(type)){
 sql = "select distinct(date_format(a.created, '%Y%m')) as sdate from jiraissue a where a.reporter in(" + user + ")";
 }
 else if("assignee".equalsIgnoreCase(type) || "depAssignee".equalsIgnoreCase(type)){
 sql = "select distinct(date_format(a.created, '%Y%m')) as sdate from jiraissue a where a.assignee in(" + user + ")";
 }
 List<String> list = jiraJdbcTemplate.queryForList(sql, String.class);
 Collections.sort(list, new Comparator<String>() {
 @Override
 public int compare(String o1, String o2) {
 return o1.compareTo(o2);
 }
 });
 return list;
}
public List<JiraBugSummaryEntity> jiraBugSummary(String type, String user, String dep){
 user = jiraBugSummaryProcessUser(user);
 String sql = "";
 if("reporter".equalsIgnoreCase(type)){
 sql = "select count(1) as `count`, date_format(a.created, '%Y%m') as sdate, a.reporter as `user`";
 sql += " from jiraissue a where a.issuetype in ('10004', '10207') and a.reporter in(" + user + ") group by sdate,user order by sdate";
 }
 else if("assignee".equalsIgnoreCase(type)){
 sql = "select count(1) as `count`, date_format(a.created, '%Y%m') as sdate, a.assignee as `user`";
 sql += " from jiraissue a where a.issuetype in ('10004', '10207') and a.assignee in(" + user + ") group by sdate,user order by sdate";
 }
 else if("depReporter".equalsIgnoreCase(type)){
 sql = "select count(1) as `count`, date_format(a.created, '%Y%m') as sdate, '" + dep + "' as `user`";
 sql += " from jiraissue a where a.issuetype in ('10004', '10207') and a.reporter in(" + user + ") group by sdate order by sdate";
 }
 else if("depAssignee".equalsIgnoreCase(type)){
 sql = "select count(1) as `count`, date_format(a.created, '%Y%m') as sdate, '" + dep + "' as `user`";
 sql += " from jiraissue a where a.issuetype in ('10004', '10207') and a.assignee in(" + user + ") group by sdate order by sdate";
 }
 List<JiraBugSummaryEntity> list = jiraJdbcTemplate.query(sql, new BeanPropertyRowMapper<JiraBugSummaryEntity>(JiraBugSummaryEntity.class));
 return list;
}

都是一些常规用法,就不解释代码了。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表