1 个多月前在社区开源了自己的一个 JVM 测试工具,然后看大家可能比较关注与其相关的测试覆盖率功能,所以我决定花点时间把这块功能单独拿出来详细分享给大家。
先把之前的帖子和工具链接贴一下,可以先看链接,也可以直奔下文
之前的帖子:JVM 的测试工具
开源工具:remote-debug-agent,wiki 中也有对测试覆盖率的详细说明。
为什么不用 Jacoco
其实是对上面 3、4 两点做一些说明。不是说 Jacoco 不能用于集成测试,只是每个工具有每个工具的特点和局限性。从我自身的实际经验来看,集成测试有一个问题是我比较关心的,那就是 一般集成环境只有 1 个,多个需求会同时放上去测,可能每个测试人员也就负责 1、2 个需求,我如何知道具体某一个需求的覆盖率? 仅靠原生的 Jacoco 似乎难以解决这个问题,因为它默认只为整个系统的代码生成一个覆盖率报告。
单元测试和 Jacoco 的思路是一个质量保障的理想方向。一个系统一次 build 确实应该把所有的功能测试一遍。但理想很丰满现实却很骨感,也许真的有利害的团队能做到这点,但就我自身的实际情况来看,我们离那种境界还差得很远很远。
我们大部分时间是对一个已上线系统进行优化/补丁开发,然后系统整体很庞大,功能很多,别说集成阶段的手工测试了,就算是用上自动化测试,也不可能把所有功能都跑一遍。为某个需求写的单测案例,也顶多在该需求开发的时间点附近可以跑起来,远达不到长期反复测试使用的需要。
近年来的系统微服务化确实可以在一定程度上缓解单一系统臃肿问题,但服务数量的增加,也会让服务关联变得复杂,有时在单测环节想把以前的案例都跑一遍,却发现写 mock 都要写断手。
此外集成环境还要面对稳定性问题,比如频繁的发布重启。我的需求发布后,我才测了 30%,系统就因发布重启了,一重启 Jacoco 就生成了一份覆盖率数据,那么我测试整个需求所覆盖的代码就会存在于多个 exec 文件中,如何整合整个需求测试时覆盖了哪些代码也是一个难题。
OK,说了那么多,这里先坦言一下,我的工具也不能解决上述所有问题,但 1、至少解决了部分问题;2、给剩下的问题也提供了解决它们的 “接口”。这也是标题所写 半个解决方案 的原因。
最终的解决方案是完全可行的,因为我目前正在自己的实际工作中,依靠本方案为团队提供单一需求级的测试覆盖率报告。
关于我的工具本身的介绍请看前面的帖子,或者看 Github 的 wiki,帖子里是简介,wiki 上我写得很详细。这里我只介绍与测试覆盖率相关的内容。
下载或编译工具后可以得到一组 jar 包,remote-debug-agent.jar 就是工具本体,其它是关联 jar 包。首先将所有 jar 包放到服务器的任意目录中,然后工具是作为 javaagent 运行的,所以要修改被测应用的启动命令(如果不想修改启动脚本也可以使用热部署的方式,详见 wiki),需要将-javaagent 参数添加到 JAVA_OPTS 中。
举个例子,假如你的被测系统中的代码(你想要关注覆盖率的代码)都在 com.foo.bar 这个包底下(或其子包底下),那么就添加-javaagent:${你的目录}/remote-debug-agent.jar=includes=com.foo.bar,apiport=8098
到 JAVA_OPTS 中,之后就可以正常启动应用了。
应用启动后 agent 就默认处于工作状态了,本工具有 2 种工作模式:线程隔离模式和非线程隔离模式。
这是工具默认的工作模式,它会将 JVM 内的线程与请求调用者的身份进行绑定,之后再记录覆盖率。举个例子,A 测试员测试需求甲,B 测试员测试需求乙,他们同时在一个集成环境上做手工测试。A 点了一个按钮,运行了后端的代码 com.foo.bar.Class1.method1(),B 点了另外一个按钮,运行了代码 com.foo.bar.Class2.method2(),那么 agent 的记录就是 A 覆盖了 method1 的某几行,B 覆盖了 method2 的某几行。一般情况下也就可以认为测试需求甲和需求乙时分别覆盖了哪些代码。
这里要说明两个问题
- 对于工具来说,A 和 B 的身份是什么?本工具的设计理念是尽量减少对测试人员的影响,因此测试人员只需要正常的执行测试即可。他们发送到服务器后端的请求是未经过任何装饰的,所以工具默认使用客户端 IP 作为身份标识,在我的工作环境中,大部分系统的集成环境都部署在公司内网环境中,而测试人员的办公电脑 IP 都是固定的,这样 IP 地址就是人的身份标识。当然也有一些系统可能部署在互联网环境,或者有的人的办公 IP 不是固定的,我提供的解决思路是从请求中提取其它可标识身份的信息,比如 HTTP header 中的一些信息。因为这就是非常定制化的操作了,所以对于这种情况工具并未直接支持,只是提供了很方便的定制化开发接口,允许使用者可以在一定程度上改变 agent 从 HTTP 请求中提取身份标识的行为。
- 身份标识与线程的绑定是怎么做的?这跟开发使用的具体架构和技术有关,但不管什么技术,我们都可以把一个请求从发起到回送响应的整个过程看成是一段旅途,从 A 点旅行到 B 点可能有多条路线,但只要确定了旅行方针(web 架构技术),就总能确定一些必经之路。比如使用 Servlet 架构的应用,那么请求肯定会走 HttpServlet.service() 方法(或其子类重写的同名方法),那么探针就是在该方法的进入和退出时,进行身份标识与线程的绑定和解绑。工具默认支持了一些架构,对于 Java 用得较多的 Servlet 架构也提供了一些定制化支持的接口。当然如果这些不能满足你的要求,也可以留言给我,有空我可以扩展工具支持的架构。
这种工作模式适用于我目前所处工作环境的大多数情况,当然也可能不可避免的漏掉一些代码,比如位于 servlet“前端” 的 filter 中的代码,因为在执行 filter 中的代码时,线程还未与任何身份标识绑定,agent 就不记录这里的覆盖率。如果你统计时不想漏掉这些代码,也可以使用非线程隔离模式。
在-javaagent 的参数中增加dependIP=false
即可让 agent 工作在该模式。该模式下 agent 就会无视线程绑定这个条件,完全记录 includes 指定范围内所有代码的运行。缺点就是无法区分某一行代码是谁执行的。对于我来说这在某些情况下依然适用,比如某个时间段,某个系统只有 1 个人测试的时候。
以上说的是 agent 如何将覆盖率数据保存在其内存中,下面说明一下我们如何得到这些数据(文件)。
与 Jacoco 类似,工具在启动后会给 jvm 虚拟机注册一个关闭钩子,当 jvm 正常关闭时,会将覆盖率数据写入 1 个元文件中。该文件默认位于 agent jar 包所在目录,可通过 outputdir 参数指定输出目录,例如outputdir=data
则会在 agent jar 包所在目录中新建 data 目录,将元文件存放在其中。如果指定的值以 / 开头,工具会将其视为绝对路径。
注意 jvm 关闭钩子仅在虚拟机正常关闭时起效,因此不要使用 kill -9(linux 系统中)或点击窗口关闭按钮(windows 系统中)来停止应用,应使用 kill/kill -15(linux 系统中)或 Ctrl+C(windows 系统中)来关闭 jvm。
另外也可以发送http://ip:8098/manage/dump
请求来主动落盘覆盖率数据。发送http://ip:8098/manage/clean
则可以清空已记录的覆盖率数据(释放内存)
工具使用了 3 个 Map 对象来记录覆盖率数据
Map<String, Set<String>> methodMap
Key 为调用者身份,Value 为该调用者执行到的方法签名。当启用dependIP=false
参数时,该表只有一个元素,Key 值为 999.999.999.999Map<String, ArrayList<Integer>> methodsLine
,Key 为方法签名,Value 为该方法的行号表。Map<String, boolean[]> methodCover
Key 为 IP&Method,即调用者身份和 Method 签名用 & 符号连接组成的字符串,Value 为该方法每行被执行的情况。java
public double getNumber() {
double r = Math.random();
System.out.println(r);
if(largeThanHalf(r)) {
System.out.println("It's a big number");
}
return r;
}
该方法编译后会形成 5 个代码行,那么它对应的 methodCover 的 value 将是一个 boolean[10] 的数组。每 2 个元素表示 1 个代码行的行首与行尾是否被执行到了,假设该数组的值为[true, true, true, true, true, true, false, false, true, true]
,则表示第 1、2、3、5 行被执行到了,而 if 分支内的第 4 行未被执行到。如果此方法的 methodsLine 表记录为[10, 11, 12, 13, 14]
,则实际覆盖的代码行就是 [10, 11, 12, 14]探针通过类似如下的程序,将覆盖率数据写入元文件,因此用对应的 java.io.ObjectInputStream 按顺序读取,即可得到 3 个 Map 的数据。
ObjectOutputStream bos = new ObjectOutputStream(new FileOutputStream(f));
bos.writeObject(methodMap);
bos.writeObject(methodCover);
bos.writeObject(methodsLine);
以上便是我的工具提供的半个解决方案,下面该谈谈另外一半了。覆盖率这个东西,说白了就是一个分式,上文所述便是该式的分子,即我的测试操作覆盖了哪些代码。我们只需要把 “分母”(我们关心哪些范围的代码)以及 “除法”(生成覆盖率报告的算法)再实现了,就能得到最终的覆盖率了。而我的实际经验告诉我,在前文的限定条件下,分母和算法是非常定制化的东西,几乎没有可能给出通用的解决方案,因此后面我会以自己的思路和经验为核心,将实现这另一半解决方案的办法告诉大家。
如果你们做的是全量测试覆盖,那是不用太关心覆盖率的分母的,但如果是像我一样需要的是某个需求的测试覆盖率的话,就可以通过一些手段来精确化测试覆盖率报告的范围。
一个比较常见的做法是,根据需求的改动来划定分母,这在需求改动的代码边界比较清晰时适用。这块程序的实现,与具体的测试计划管理、代码库管理、配置发布管理有关,你的目标效果应该是:给出任意时间点,都可以定位到集成环境中的某行代码,对应代码仓库中的哪个源文件的哪一行上。为此你需要:
你可能已经注意到了,我的做法简单来说就是改哪测哪,很明显这不是一个充分的测试策略,但我们可以将这种策略下的覆盖率数据视为一个兜底条款。实际工作中,你肯定会给覆盖率设定指标,那么标准可以相对设高一些。当然我没法给出具体的推荐值,我个人认为 60%~90% 区间内的任何值都是合理的,这跟很多因素有关,除了测试质量外,可能还有业务重视度、开发代码规范(保护性代码、自动生成代码等)等多个因素影响。具体数值还请大家自己摸索。
分母和分子都有了之后,就可以将两者进行比对了。先举个例子:某需求修改了某 1 个文件的 11,12,15 行,从覆盖率元数据中分析出,该文件对应的类执行了 10,11,12,13 行,那么最终报告里,你可以将 11,12 行标绿色,15 行标红色,其余不染色(不关心),整个需求覆盖率为 2/3。
这一步是纯粹的算法问题,你的目标应该是:以代码的行号为核心,看分子的行号记录中,有没有分母的行号。为此你可能需要:
这一块理论上来说我可以像分子部分一样,直接给出解决方案(程序代码或工具),但因为 1、这部分与我开源工具的本来的功能是完全解耦的;2、有一些算法说实话我也不是很确定正确性,比如循环语句的代码行号如何处理(你可以用 javap 命令反编译一个有循环语句的 class 研究一下行号表看看)。所以最终还是决定仅提供思路。
最后就是像 Jacoco 一样,生成可视化的染色代码报告就行了。
这里我自己仿照 Jacoco 的风格,用 freemarker 写了一个模板。
<html>
<head>
<META http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>测试覆盖率报告</title>
<style>
body {
margin: 5px 15px 5px 15px;
padding: 0;
border: 0;
}
h3 {
text-align: center;
}
span.cover{
background-color: #ccffcc;
}
span.part{
background-color: #ffff80;
}
span.uncover{
background-color: #ffaaaa;
}
#footer {
margin: 0;
padding: 0;
width: 100%;
height: 20px;
}
div#optPanel{
border: 1px solid #ccc;
border-radius: 16px;
padding: 4px 8px 4px 8px;
float: right;
cursor:pointer;
}
div#optPanel:hover{
border: 1px solid #ccc;
border-radius: 16px;
padding: 4px 8px 4px 8px;
float: right;
cursor:pointer;
background-color:#87CEFA;
}
div#code{
margin-left:25px;
margin-right:25px;
border: 1px #bbb solid;
height:600px;
overflow:auto;">
}
</style>
<script type="text/javascript">
var locations = [];
function next(){
var div = document.getElementById('code');
if(locations.length==0){
analyseCode();
}
var i;
for(i=0;i<locations.length;i++){
if((locations[i]-5)*20>div.scrollTop+600){
break;
}
}
if(i==locations.length){
i=0;
}
var jumpStep = (locations[i]-5)*20;
div.scrollTop = jumpStep < 0 ? 0 : jumpStep;
}
function analyseCode(){
var div = document.getElementById('code');
var codeNode = div.getElementsByTagName('code')[0];
var strArray = codeNode.innerHTML.split('\n');
var i, sindex;
for(i=0;i<strArray.length;i++){
if((sindex=strArray[i].search('<span class='))>0){
var lineNumber = strArray[i].substring(0, sindex-2);
locations.push(lineNumber);
i++;
for(;i<strArray.length;i++){
if(strArray[i].search('<span class=')==-1){
break;
}
}
}
}
}
</script>
</head>
<body>
<h3>文件: ${javaFile}, 版本: ${revision}</h3>
<h3>覆盖率:${coverRate}%</h3>
<br/>
<h4> <a style="display:${display}" href="../CoverageReport.html?req=${reqNo}">返回</a></h4>
<div id="optPanel" onclick="next()" href="javascript:void(0)"><span>下一处</span></div>
<h4>Coverage</h4>
<div id="code">
<code style="white-space:pre;line-height:10px;font-size:13px;">
${code}
</code>
</div>
<h4> <a style="display:${display}" href="../CoverageReport.html?req=${reqNo}">返回</a></h4>
<div id="footer">
</div>
</body>
</html>
code 变量就是个字符串,内容就是一个 java 文件的源代码,只是需要给已覆盖的行套上标签<span class="cover">
,部分覆盖的行套上标签<span class="part">
,未覆盖的行套上标签<span class="uncover">
即可。关于 freemarker 的具体用法,还请自行搜索。
效果展示
一个需求肯定有多个文件嘛,返回链接就是回到需求的整体覆盖率页面。我就简单的用个表格,把需求改的每一个文件罗列在一个页面中,这里就不贴了。下一处按钮可滚动 code 区域到下一段 “分母” 处,方便在很长的文件中查看报告。另外我在 code 变量中生成了行号。
本文介绍了关于 JVM 语言的 web 应用,集成测试阶段,单一需求的覆盖率统计方法。对分子部分给出了解决方案,分母与除法部分给出了思路和经验、
一个完整流程应该是:
从本文到实际的效果落地,确实还有非常多的路要走,如果你做好了投入的准备,那么不妨试一试,最终的效果一定成为提高团队测试质量的助力。
当然最后还有一个最直接的实现方法,那就是雇佣我帮您来完成整体的解决方案 空间中有联系方式,欢迎邮件联系(优先接受远程工作机会)。