您的位置 首页 java

实战|Java 测试覆盖率 Jacoco插桩的不同形式总结和踩坑记录(下)

本文为霍格沃兹测试学院优秀学员关于 Jacoco 的小结和踩坑记录。测试开发进阶学习,文末加群。

六、注意事项汇总

  1. 修改 Java _OPTS 参数时,如果位置不对,可能造成代理无法启动。
  2. java -jar 启动时,- java agent 参数,不能错误,否则可能造成代理不生效。
  3. Export MAVEN_OPTS 参数时,后续的所有 mvn 命令,都会带上此参数,因此相当于每次执行 mvn 命令,都会尝试启动代理,因此可能会出现 address bind already in use 之类的异常抛出。因此,我们只有在 mvn tomcat7:run 启动服务器时才需要启动代理,其他如 mvn 的编译、install 命令都不需要,所以在启动之后,把 MAVEN_OPTS 参数置空,或者重启一个 terminal 来执行命令。
  4. 同一个 ip 地址上,部署多套服务器需要收集覆盖率时,端口自己规划好,不可重复。
  5. 测试执行信息的收集 (在应用的测试服务器)。
  6. 测试执行信息的获取、以及生成覆盖率报告(可在测试服务器上、也可在统一的服务器上)。
  7. 5 的收集在测试服务器上,6 的操作可以在测试服务器是,也可以是统一的服务器(我们选择后者)。
  8. 关闭应用服务时,务必不要强杀,请使用 kill -15 杀进程 (当然有时候,会出现 kill -15 杀不掉进程的时候,用 kIll -9 也无妨,这一点并不是很确定),否则,很有可能会造成覆盖率数据来不及保存而丢失。

七、说给想做平台的你

按照原来的流程,如果想做增量的覆盖率,那么有如下的步骤需要涉及,我们需要做的事情:

  1. 部署测试服务器(加入 Jacoco 的代理,按照上面的方式进行即可)。
  2. 需要知道上述部署时的版本代码,需要知道待比较的基线版本代码,并下载两个代码到某个路径下,并编译最新的代码 (至于需不需要编译,看你的需求,也可以用测试服务器上的,这样最准确。现编译的话,可能会编译机跟测试机的不同,造成生成的 class 文件不一致,这会导致覆盖率数据不准确)。
  3. Dump 覆盖率执行数据。
  4. 根据 dump 出来的执行数据 exec 文件,以及刚才对最新代码的编译出来的字节码 class 文件和 src 中的源代码进行报告生成。
  5. 导出覆盖率数据报告(一般是在 Linux 中执行,查看时需要到自己的 Windows 或者 Mac 上查看)。

以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。

那么上述的步骤,其实可以都进行自动化配置。

  1. 部署

如果有 devops 平台的话,可以集成进去,端口要规划好。

  1. 基线代码、和最新代码

可以用 jgit 和 svnkit 这两个工具进行代码下载和克隆。

  1. dump.

用 API 去 dump,可以屏蔽不同启动方式,只需要有 TCP 的 serverip 和端口即可。

  1. report

用 Jacoco 的 API 做。

那唯一的差别,就是对项目层级的判定,比如多模块、比如可能项目的目录并不规范 (有的 maven 项目并没有把所有的代码放到 src/main/java 下),这些需要自己对公司项目进行适配。我司就是因为项目结构差别太大,所以适配的过程花了一番功夫。

  1. 导出报告

提供下载,或者给出服务器存放的链接,都行,这个看个人实现就行了。

八、一些坑

  1. Ant 构建

build.xml 中,有特定的 compile 阶段,这个自己去找。请务必保证,有:

 debug="true"  

这个配置,不然 Jacoco 是无法注入的,有的时候 ant 项目生成的数据为 0,就可以去排查下这里。

比如我司配置了两个,一个 compileDebug, 一个 compile,在 compileDebug 阶段打开了 debug 的开关:

实战|Java 测试覆盖率 Jacoco插桩的不同形式总结和踩坑记录(下)

  1. 关于负载均衡

有时候可能一个服务会有负载均衡出现,那么可以配置不同端口,如果在不同服务器上,那么 IP 和端口都可以不同。

这时候,在 dump 数据的时候,只需要循环几个 ip:port(至于你想怎么传,那就是代码层面事情了)去 dump,保存到同一个文件中就行了。

  1. 做平台时-项目代码无法独立编译

这个看怎么解决了,如果非要自己编译,那就让开发适配到可以独立编译。

我这里是提供了 sftp 下载的方式,你告诉我你的代码在哪个服务器的那个路径,提供给我用户名密码,我用 Java 的方式去 sftp 下载到平台部署的机器上。这样可以解决现编译的不匹配问题,也可以解决无法独立编译的问题。

但是有几个遗留问题,你如何判定是不是要重新下载,你也会担心 sftp 下载下来的 class 和 java 代码跟测试机上的是否不一样。这个要看个人取舍,理论上 TCP 进行下载还是安全的。

  1. 如果注入 Jacoco 的配置之后,端口确实没有起来或者 dump 的时候,TCPserver 连接不上

可能原因有几种。

  • TCP 端口确实没起来,这个在部署测试服务器的文档里有说明,部署后需要查看下是否真的起来。
  • TCP 端口确实起来了,netstat 查看的时候也是显示正确。

这里还有两种可能。

  • 确保 javaagent 参数中的 address 写的是真实 ip 地址,而不是 127.0.0.1 或者 localhost。
  • 防火墙。防火墙开启的时候,阻碍了外部 ip 连接的进入,请关闭防火墙,或者配置防火墙策略。

举个栗子。

8:30 的时候,执行了测试,生成了一次报告。此时 8.30 之前的数据,肯定是存在的。

9:00 的时候,重新部署了,之前没有再次捞取执行信息,那重启之后,8.30-9.00 之间的执行记录可能很大概率丢失。所以,务必小心。

  1. 怎么确保报告准确,且尽量减少丢失?

及时保存,及时收集,可以采用定时任务的方式。

  1. 应用的突然重启和服务器的断电状况怎么处理?

天灾,没招。如果真的确实需要,可以在程序中加入定时收集,但是频率不一定好控制,而且当不再执行的时候,平白重复保存完全一模一样的执行信息,个人觉得意义不大,会对服务器磁盘造成巨大压力。具体解决方案还要看个人取舍。

  1. 造成覆盖率报告数据不准确的原因有哪些?

最最最最底层的原因 —— 部署时的 class 文件和生成报告的时候,用的 class 文件不一致。有以下几种情况:

  • 测试服务器(就是你的应用所在的那个环境)中的 class 文件和我管理平台上编译环境不一致,导致产生的 class 文件跟部署时的 class 文件有差异。这个可以通过不手动编译,而是从测试服务器部署位置的目录来拷贝传输,来解决,但现阶段,没做。
  • 测试服务器版本变更了,但是管理平台上的代码没变更(或者说新代码拉取下来了,但是没有重新编译。),导致 class 文件不一致。
  • 管理平台上的新版本代码的版本号没有填写,默认每次拉取最新代码,这会导致生成报告的时候, 源码 变了,class 文件没变,覆盖率插桩收集的时候,用的还是老代码。所以,要想准确。需要保证,测试服务器部署时的代码版本和管理平台上写的版本号完全一致。

九、补充一些 API 相关的代码

覆盖率数据的获取

 import org.Jacoco.core.tools.ExecDumpClient;
import org.Jacoco.core.tools.ExecFileLoader;
...

public void dumpExecDataToFile(String  File Path) {
        logger.debug(" 开始 dump 覆盖率信息:{}, 到:{}文件中 ", this.JacocoAgentTCPServer,
                filePath);
        ExecDumpClient dumpClient = new ExecDumpClient();
        dumpClient.setDump(true);
        ExecFileLoader execFileLoader = null;
        try {
            execFileLoader = dumpClient.dump(
                    this.JacocoAgentTCPServer.getJacocoAgentIp(),
                    this.JacocoAgentTCPServer.getJacocoAgentPort());
                         // 这个后面的 true,代表如果这个文件已经存在,且以前已经保存过数据,那么是可以追加的,也相当于覆盖率数据文件的合并
                        // 如果设置为 false,则会重置该文件 , 这在多节点负载均衡的时候尤其有用,可以把多个节点的数据组合合并之后再进行统计
             execFileLoader.save(new File(filePath), true);
        } catch (IOException e2) {
            logger.error(" 获取 dump 信息失败:{}", e2.getMessage());
            throw new BusinessValidationException("TCP 服务连接失败 , 请查看 TCP 配置 ");
        }
    }  

另外可以根据自己的需要,看下是否把以前的覆盖率数据做备份 (我们现在是做了备份、且做了定时 dump,防止覆盖率数据突然丢失),需要的时候从备份数据里拿,再从 TCPserver 中 dump,然后做合并,这个过程可能统计全量的时候尤其需要。

CodeCoverageDTO.java

该文件主要封装覆盖率数据生成报告的时候需要的一些属性,如数据文件、src 源码、class 文件、报告存放文件等等。

 import java.io.File;

/**
 * @author : Administrator
 * @since : 2019 年 3 月 6 日 下午 7:53:02
 * @see :
 */
public class CodeCoverageFilesAndFoldersDTO {
     private  File project dir ;

    /**
     * 覆盖率的 exec 文件地址
     */
    private File executionDataFile;

    /**
     * 目录下必须包含源码编译过的 class 文件 , 用来统计覆盖率。所以这里用 server 打出的 jar 包地址即可
     */
    private File classesDirectory;

    /**
     * 源码的 /src/main/java, 只有写了源码地址覆盖率报告才能打开到代码层。使用 jar 只有数据结果
     */
    private File sourceDirectory;
    private File reportDirectory;
    private File incrementReportDirectory;

    public File getProjectDir() {
        return projectDir;
    }

    // 省略了 getter 和 setter
}  

ReportGenerator.java

这里生成报告的时候,其实默认应该已经有源码、exec 文件、class 文件了,至于 class 文件什么时候编译出来的或者怎么出来的,那应该在生成报告的前置步骤已经做好了。

 private  static  void createReportWithMultiProjects(File reportDir,
            List<CodeCoverageFilesAndFoldersDTO> codeCoverageFilesAndFoldersDTOs)
            throws IOException {
        logger.debug(" 开始在:{}下生成覆盖率报告 ", reportDir);
        File coverageFolderFile = reportDir;
        if (coverageFolderFile. exists ()) {
            FileUtil.forceDeleteDirectory(coverageFolderFile);
        }

        HTMLFormatter htmlFormatter = new HTMLFormatter();
        IReportVisitor iReportVisitor = null;

        boolean everCreatedReport = false;

        for (CodeCoverageFilesAndFoldersDTO codeCoverageFilesAndFoldersDTO : codeCoverageFilesAndFoldersDTOs) {
            // class 文件为空或者不存在
            boolean classDirNotExists = (null == codeCoverageFilesAndFoldersDTO
                    .getClassesDirectory())
                    || (!(codeCoverageFilesAndFoldersDTO.getClassesDirectory()
                            .exists()));

            // class 文件目录不存在
            boolean needNotToCreateReport = classDirNotExists;
            if (needNotToCreateReport) {
                logger.debug(" 目录:{}没有 class 文件,不生成报告 ",
                        codeCoverageFilesAndFoldersDTO.getProjectDir()
                                .getAbsolutePath());
                continue;
            }

            // 修改标志位
            everCreatedReport = true;
            logger.debug(" 正在为:{}生成报告 ", codeCoverageFilesAndFoldersDTO
                    .getProjectDir().getAbsolutePath());
            IBundleCoverage bundleCoverage = analyzeStructureWithOutChangeMethods(
                    codeCoverageFilesAndFoldersDTO);
            ExecFileLoader execFileLoader = getExecFileLoader(
                    codeCoverageFilesAndFoldersDTO);
            iReportVisitor = htmlFormatter
                    .createVisitor(new FileMultiReportOutput(
                            new File(coverageFolderFile.getAbsolutePath(),
                                    codeCoverageFilesAndFoldersDTO
                                            .getProjectDir().getName())));

            if (null != execFileLoader) {
                iReportVisitor.visitInfo(
                        execFileLoader.getSessionInfoStore().getInfos(),
                        execFileLoader.getExecutionDataStore().getContents());
            }

                        // 这个地方之所以没有用一个固定的文件夹来指定,是因为我们的项目有的不标准,如果你们的项目是标准的,比如都在 src/main/java 下,那就可以直接用一个固定值
                         // 我们这里为了防止 src/java src/java/plugin src/plugin 这种层级的源码出现,才做了适配
            ISourceFileLocator iSourceFileLocator = getSourceFileLocatorsUnderThis(
                    codeCoverageFilesAndFoldersDTO.getSourceDirectory());
            iReportVisitor.visitBundle(bundleCoverage, iSourceFileLocator);
            iReportVisitor.visitEnd();
        }

        if (!everCreatedReport) {
            throw new BusinessValidationException(" 从未生成报告,检查下工程是否未编译或者是否都是空工程 ");
        }
    }

private static ISourceFileLocator getSourceFileLocatorsUnderThis(
            File topLevelSourceFileFolder) {
        MultiSourceFileLocator iSourceFileLocator = new MultiSourceFileLocator(
                4);

                 // 这里是获取当前给出的目录以及其下面的子目录中所包含的所有 java 文件
                  // 实现方式其实就是递归遍历文件夹,并过滤出来 java 文件,写法比较简单就不贴了,自行实现即可
        List<File> sourceFileFolders = getSourceFileFoldersUnderThis(
                topLevelSourceFileFolder);

        for (File eachSourceFileFolder : sourceFileFolders) {
            iSourceFileLocator
                    .add(new DirectorySourceFileLocator(eachSourceFileFolder,
                            GlobalDefination.CHAR_SET_DEFAULT, 4));
        }
        return iSourceFileLocator;
    }  

如果确实需要有些实现的源码,可以联系我或者从 github 上获取。

代码示例 GitHub 地址:

AngryTesterJacoco 的代码

-org.Jacoco.core.diff.DiffAST.java

这是代码比对源码,

 public static List<MethodInfo> diffDir(final String ntag,
            final String otag) {// src1 是整个工程中有变更的文件 ,src2 是历史版本全量文件 , 都是相对路径 , 例如在当前工作空间下生成 tag1 和 tag2
        final String pwd = new File(System.getProperty("user.dir"))
                .getAbsolutePath();// 同级目录
        final String parent = new File(System.getProperty("user.dir")).getParent();
        final String tag1Path = pwd;
        final String tag2Path = parent + SEPARATOR + otag;
        final List<File> files1 = getFileList(tag1Path);
        for (final File f : files1) {
            // 非普通类不处理
            if (!ASTGeneratror.isTypeDeclaration(f.getAbsolutePath())) {
                continue;
            }
                        // 实现方法在这里,主要是做了路径的替换
            final File f2 = new File(
                    tag2Path + f.getAbsolutePath().replace(tag1Path, ""));
            diffFile(f. toString (), f2.toString());
        }
        return methodInfos;
    }

/**
     * @param baseDir 与当前项目空间同级的历史版本代码路径
     * @return
     */
    public static List<MethodInfo> diffBaseDir(final String baseDir) {
        final String pwd = new File(System.getProperty("user.dir"))
                .getAbsolutePath();// 同级目录
        final String parent = new File(System.getProperty("user.dir")).getParent();
        final String tag1Path = pwd;
        final String tag2Path = parent + SEPARATOR + baseDir;
        final List<File> files1 = getFileList(tag1Path);
        for (final File f : files1) {
            // 非普通类不处理
            if (!ASTGeneratror.isTypeDeclaration(f.getAbsolutePath())) {
                continue;
            }
            final File f2 = new File(
                    tag2Path + f.getAbsolutePath().replace(tag1Path, ""));
            diffFile(f.toString(), f2.toString());
        }
        return methodInfos;
    }

/**
     * 对比文件
     * 
     * @param nfile
     * @param ofile
     * @return
     */
    public static List<MethodInfo> diffFile(final String nfile,
            final String ofile) {
        final MethodDeclaration[] methods1 = ASTGeneratror.getMethods(nfile);
        if (!new File(ofile).exists()) {
            for (final MethodDeclaration method : methods1) {
                final MethodInfo methodInfo = methodToMethodInfo(nfile, method);
                methodInfos.add(methodInfo);
            }
        } else {
            final MethodDeclaration[] methods2 = ASTGeneratror
                    .getMethods(ofile);
            final Map<String, MethodDeclaration> methodsMap = new HashMap<String, MethodDeclaration>();
            for (int i = 0; i < methods2.length; i++) {
                methodsMap.put(
                        methods2[i].getName().toString()
                                + methods2[i].parameters().toString(),
                        methods2[i]);
            }
            for (final MethodDeclaration method : methods1) {
                // 如果方法名是新增的 , 则直接将方法加入 List
                if (!isMethodExist(method, methodsMap)) {
                    final MethodInfo methodInfo = methodToMethodInfo(nfile,
                            method);
                    methodInfos.add(methodInfo);
                } else {
                    // 如果两个版本都有这个方法 , 则根据 MD5 判断方法是否一致
                    if (!isMethodTheSame(method,
                            methodsMap.get(method.getName().toString()
                                    + method.parameters().toString()))) {
                        final MethodInfo methodInfo = methodToMethodInfo(nfile,
                                method);
                        methodInfos.add(methodInfo);
                    }
                }
            }
        }
        return methodInfos;
    }

public static String MD5Encode(String s) {
        String MD5String = "";
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            BASE64Encoder base64en = new BASE64Encoder();
            MD5String = base64en.encode(md5.digest(s.getBytes("utf-8")));
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return MD5String;
    }


/**
     * 判斷方法是否一致
     * 
     * @param method1
     * @param method2
     * @return
     */
    public static boolean isMethodTheSame(final MethodDeclaration method1,
            final MethodDeclaration method2) {
        if (MD5Encode(method1.toString())
                .equals(MD5Encode(method2.toString()))) {
            return true;
        }
        return false;
    }  

上面最后一个方法就是拿方法的详细信息来做 md5 的比对,所以这也就有了评论区的那个方法误判变更的来由。不过这属于历史遗留问题,并不能算大事,想办法规避即可。

十、总结

以上,本文是对上一篇文章 Java 端覆盖率探索 的一个细化,文中总结的内容,得益于站在巨人的肩膀上,参考了以下资料和课程。这里推荐大家学习,也期待一起探讨。

References

[1] 有赞测试|浅谈代码覆盖率: 腾讯TMQ|JAVA 代码覆盖率工具 Jacoco-踩坑篇: 腾讯TMQ|JAVA 代码覆盖率工具 Jacoco-实践篇: 针对手工测试的代码变更覆盖率实现之路:

(文章来源于霍格沃兹测试学院)

文章来源:智云一二三科技

文章标题:实战|Java 测试覆盖率 Jacoco插桩的不同形式总结和踩坑记录(下)

文章地址:https://www.zhihuclub.com/189544.shtml

关于作者: 智云科技

热门文章

网站地图