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

发表于:2021-11-05 09:27

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:佚名    来源:知乎

  六、注意事项汇总
  1、修改 JAVA_OPTS 参数时,如果位置不对,可能造成代理无法启动。
  2、java -jar 启动时,-javaagent 参数,不能错误,否则可能造成代理不生效。
  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 上查看)。
  以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。
  那么上述的步骤,其实可以都进行自动化配置。
  部署
  如果有 devops 平台的话,可以集成进去,端口要规划好。
  基线代码、和最新代码
  可以用 jgit 和 svnkit 这两个工具进行代码下载和克隆。
  dump.
  用 API 去 dump,可以屏蔽不同启动方式,只需要有 TCP 的 serverip 和端口即可。
  report
  用 Jacoco 的 API 做。
  那唯一的差别,就是对项目层级的判定,比如多模块、比如可能项目的目录并不规范 (有的 maven 项目并没有把所有的代码放到 src/main/java 下),这些需要自己对公司项目进行适配。
  我司就是因为项目结构差别太大,所以适配的过程花了一番功夫。
  导出报告
  提供下载,或者给出服务器存放的链接,都行,这个看个人实现就行了。
  八、一些坑
  Ant 构建
  build.xml 中,有特定的 compile 阶段,这个自己去找。请务必保证,有:
  debug="true"
  这个配置,不然 Jacoco 是无法注入的,有的时候 ant 项目生成的数据为 0,就可以去排查下这里。
  比如我司配置了两个,一个 compileDebug, 一个 compile,在 compileDebug 阶段打开了 debug 的开关:
  关于负载均衡
  有时候可能一个服务会有负载均衡出现,那么可以配置不同端口,如果在不同服务器上,那么 IP 和端口都可以不同。
  这时候,在 dump 数据的时候,只需要循环几个 ip:port(至于你想怎么传,那就是代码层面事情了)去 dump,保存到同一个文件中就行了。
  做平台时-项目代码无法独立编译
  这个看怎么解决了,如果非要自己编译,那就让开发适配到可以独立编译。
  我这里是提供了 sftp 下载的方式,你告诉我你的代码在哪个服务器的那个路径,提供给我用户名密码,我用 Java 的方式去 sftp 下载到平台部署的机器上。这样可以解决现编译的不匹配问题,也可以解决无法独立编译的问题。
  但是有几个遗留问题,你如何判定是不是要重新下载,你也会担心 sftp 下载下来的 class 和 java 代码跟测试机上的是否不一样。这个要看个人取舍,理论上 TCP 进行下载还是安全的。
  如果注入 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 之间的执行记录可能很大概率丢失。所以,务必小心。
  怎么确保报告准确,且尽量减少丢失?
  及时保存,及时收集,可以采用定时任务的方式。
  应用的突然重启和服务器的断电状况怎么处理?
  天灾,没招。如果真的确实需要,可以在程序中加入定时收集,但是频率不一定好控制,而且当不再执行的时候,平白重复保存完全一模一样的执行信息,个人觉得意义不大,会对服务器磁盘造成巨大压力。具体解决方案还要看个人取舍。
  造成覆盖率报告数据不准确的原因有哪些?
  最最最最底层的原因 —— 部署时的 class 文件和生成报告的时候,用的 class 文件不一致。有以下几种情况:
  · 测试服务器(就是你的应用所在的那个环境)中的 class 文件和我管理平台上编译环境不一致,导致产生的 class 文件跟部署时的 class 文件有差异。这个可以通过不手动编译,而是从测试服务器部署位置的目录来拷贝传输,来解决,但现阶段,没做。
  · 测试服务器版本变更了,但是管理平台上的代码没变更(或者说新代码拉取下来了,但是没有重新编译。),导致 class 文件不一致。
  · 管理平台上的新版本代码的版本号没有填写,默认每次拉取最新代码,这会导致生成报告的时候,源码变了,class 文件没变,覆盖率插桩收集的时候,用的还是老代码。所以,要想准确。需要保证,测试服务器部署时的代码版本和管理平台上写的版本号完全一致。
  九、补充一些 API 相关的代码
  覆盖率数据的获取
  import org.Jacoco.core.tools.ExecDumpClient;
  import org.Jacoco.core.tools.ExecFileLoader;
  ...
  public void dumpExecDataToFile(String filePath) {
          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 projectDir;
      /**
       * 覆盖率的 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 地址:
  https://github.com/yelanting/ManagerPlatformAdministrator.git
  备注:
  这里关于 Jacoco 的一部分代码直接引用了 AngryTester 项目的代码,https://testerhome.com/AngryTester
  如果涉及到侵权请联系我,目前并未作商用;关于 server 部分的,则大部分是我自己练习的代码,可以随意拿去用,这个小工具只是为了给测试内部使用,其实并不具备完整项目的实力,所以代码和性能不一定很好,但我尽量按照阿里的规范来编写的代码,使其规范。
  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 的比对,所以这也就有了评论区的那个方法误判变更的来由。
  不过这属于历史遗留问题,并不能算大事,想办法规避即可。

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号