Android 增量代码测试覆盖率工具实践(一)

发表于:2022-3-11 09:11

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

 作者:coRe    来源:稀土掘金

  当业务快速发展,新业务不断出现,开发同学粗心的情况下,难免会出现少测漏测的情况,如何保证新增代码有足够的测试覆盖率?当一段正常的代码,开发却修改了,测试人员没有测试其功能,如果保证能够发现?
  所以代码覆盖测试是有必要的,代码覆盖只能保证这行代码执行了,不能保证其是否正确。寻找相关工具,发现最接近的是jacoco。jacoco 接入也比较简单,在安卓上用的offline 模式,不过jacoco 默认是全部插入探针代码,所以需要对其改造,只对增量代码插入探针。
  大致流程
  需求开发流程:项目管理是git,master 分支是线上分支,开发人员在开发某个需求时,会从master 拉取新分支开发,测试完成,封板上线后,会把分支合到master 上。确保master 永远是线上代码。
  测试流程:开发人员在开发时,是开发包,开发完成会打测试包给测试人员,测试人员反馈问题,开发修改代码,再次打包,这一过程可能会重复多次。测试通过,告知开发打正式包。最终上线正式包。我们对开发包称为debug 包,测试包称为beta包,正式包称为release 包。buildType中对应三种打包方式。
  buildTypes {
          debug {...
          }
          release {...
          }
          beta {...
          }
      }

  其中debug 包是开发人员直接安装运行,beta与release是通过 jenkins 连打包机打出来供测试人员下载。
  所以我们在插入探针代码时,只需要对beta 包插入,然后测试人员下载,手动测试,本地生成数据,上传数据给服务器。供后续生成报告时使用,使用开关如下:
  jacocoCoverageConfig {
      jacocoEnable isBeta()
      ....
  }
  def isBeta() {
      def taskNames = gradle.startParameter.taskNames
      for (tn in taskNames) {
          if (tn == "assembleBeta" || tn == "ttpPackageBeta") {
              return true
          }
      }
      return false
  }

  在打release 包时,调用生成报告的任务。查看本次增量代码的覆盖率报告,输出报告到apk目录,供开发人员查看,由开发人员判断这个覆盖率是否合理。当然你也可以在低于百分之多少时抛出异常,中断打包。
  框架的整体流程如下:
  首先分为三块:
  1、编译时:这里说的是开关为打开的情况,编译时主要是获取两个分支的差异方法集合,然后调用jacoco提供的方法,对差异方法代码插入探针。
  2、App 运行时:测试人员在运行带有探针的包,会把探针运行数据.ec 保存在本地,下次再打开app时上传上次数据。
  3、生成报告:打正式包时,下载此项目版本所有的覆盖数据,和编译时一样,获取两分支差异方法集合。调用jacoco方法,生成最终的差异方法报告。
  下面分别对各个流程中一些技术难点说明。
  一、编译时
  编译时是通过gradle TransForm实现的,TransForm可以对字节码进行修改。主要过程分为三大步,class git 管理、获取差异方法、对diff方法插入探针。
  大致代码如下:
  JacocoTransform.groovy
  @Override
      void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
          ……
          if (!dirInputs.isEmpty() || !jarInputs.isEmpty()) {
              if (jacocoExtension.jacocoEnable) {
                  //copy class到 app/classes
                  copy(transformInvocation, dirInputs, jarInputs, jacocoExtension.includes)
                  //提交classes 到git
                  gitPush(jacocoExtension.gitPushShell, "jacoco auto commit")
                  //获取差异方法集
                  BranchDiffTask branchDiffTask = project.tasks.findByName('generateReport')
                  branchDiffTask.pullDiffClasses()
              }
              //对diff方法插入探针
              inject(transformInvocation, dirInputs, jarInputs, jacocoExtension.includes)
          }
      }

  1.1 class git 管理
  首先项目中是有java 和kotlin 源码。如果解析源码文件,需要对两种语言适配。 而无论是java 还是kotlin ,编译完都是 .class,解析class 可以通过 ASM 。jacoco 也需要ASM,所以我们需要保存源码对应的 class 文件,当然也不能全部保存,只保存自己的包名的,例如前辍  com.ttpc 。一些第三方的源码,我们认为它是稳定的,没问题的,也就没必要对其进行覆盖测试,把编译后的class copy到项目的app 目录下,与src 同级,例:
  这些 class 也是需要通过git 管理的。然后自动执行git add、commit、push 命令,提交到git 服务器。因为通过 git 可以获得两个服务器分支差异的文件名。
  1.2、获取两个分支差异方法集
  其中编译时与生成报告时都需要获取 "两个分支差异方法集"。其中一个分支就是当前开发分支,一个是master 分支(可配置)。差异方法定义无论是新增方法,还是修改了方法,那怕修改一行代码,都算是差异方法,那个整个方法都要覆盖到。
  以dev_3 为开发当前分支,master  为稳定分支举例。当前分支通过 git name-rev --name-only HEAD 获取 。
  1.2.1、获取差异文件名集
  通过 git 可以获得两个分支差异的文件名。
   git diff origin/dev_3 origin/master --name-only
  输出如下:
  通过 \n 分隔,得到差异文件名集合。通过后辍过滤非 .class 与非包名文件。
  ok,现在得到两分支差异class文件名,但是我们需要精确到差异方法。
  1.2.2、copy 两分支差异文件
  接下来,切换到master 分支,把所有class copy 到一个临时目录。
  再切回 当前dev_3分支,把所有class copy 到临时目录。(临时目录和项目同级,为了不影响项目)
  删除那些不在  差异文件名集合  的文件,得到差异文件集。
  切换分支+copy 如下:注意是强制切换,会导致工作区丢失。
  #!/bin/sh
  gitBran=$1 # 要切换的分支
  workDir=$2 #当前目录
  outDir=$3 # copy 输出目录
  git checkout -b $gitBran origin/$gitBran
  git checkout -f $gitBran
  git pull
  cp -r "${workDir}/app/classes" $outDir


  1.2.3、生成差异方法集
  对两个分支目录的所有class,使用ASM读取class,访问方法,收集方法信息,关键代码如下:
  public class DiffClassVisitor extends ClassVisitor {
  ……
  @Override
      public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
          MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
          final MethodInfo methodInfo = new MethodInfo();
          methodInfo.className = className;
          methodInfo.methodName = name;
          methodInfo.desc = desc;
          methodInfo.signature = signature;
          methodInfo.exceptions = exceptions;
          mv = new MethodVisitor(Opcodes.ASM5, mv) {
              StringBuilder builder = new StringBuilder();
              //访问方法一个参数
              @Override
              public void visitParameter(String name, int access) {
                  builder.append(name);
                  builder.append(access);
                  super.visitParameter(name, access);
              }
              
              //访问方法一个注解
              @Override
              public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
                  builder.append(desc);
                  builder.append(visible);
                  return super.visitAnnotation(desc, visible);
              }
              //访问ldc指令,也就是访问常量池索引
              //与方法体有关,需要参与md5
              @Override
              public void visitLdcInsn(Object cst) {
                  //资源id 每次编译都会变,所以不参与 0x7f010008
                  if (!(cst instanceof Integer) || !isResourceId((Integer)cst)) {
                      builder.append(cst.toString());
                  }
                  super.visitLdcInsn(cst);
              }
              ……
              //方法访问结束
              @Override
              public void visitEnd() {
                  String md5 = Util.MD5(builder.toString());
                  methodInfo.md5 = md5;
                  DiffAnalyzer.getInstance().addMethodInfo(methodInfo, type);
                  super.visitEnd();
              }
         }

  其中MethodVisitor 有很多 visitXxx方法,都是方法的基本信息与一些指令。然后对其md5 ,得到方法的md5签名。通过classsName,methodName,desc来定位同一方法,然后比较其md5是否一致。一致则代表未修改过代码。这里要注意的是visitLdcInsn 访问常量池指令,因为每次编译时,资源id都会不一致,所以要过滤掉资源id。
  当所有的class 访问结束,通过两个分支方法集,得到差异方法集。
  public void diff() {
          if (!currentList.isEmpty() && !branchList.isEmpty()) {
              for (MethodInfo cMethodInfo : currentList) {
                  boolean findInBranch = false;
                  for (MethodInfo bMethodInfo : branchList) {
                      if (cMethodInfo.className.equals(bMethodInfo.className)
                              && cMethodInfo.methodName.equals(bMethodInfo.methodName)
                              && cMethodInfo.desc.equals(bMethodInfo.desc)) {
                          if (!cMethodInfo.md5.equals(bMethodInfo.md5)) {
                              diffList.add(cMethodInfo);
                          }
                          findInBranch = true;
                          break;
                      }
                  }
                  if (!findInBranch) {
                      diffList.add(cMethodInfo);
                  }
                  diffClass.add(cMethodInfo.className);
              }
          }
      } 

  1.2.4、插入探针代码
  调用jacoco 的instrument ,把插入探针后的字节码写入文件。
   ClassInjector.class
      @Override
      void processClass(File fileIn, File fileOut) throws IOException {
          if (shouldIncludeClass(fileIn)) {
              InputStream is = null;
              OutputStream os = null;
              try {
                  is = new BufferedInputStream(new FileInputStream(fileIn));
                  os = new BufferedOutputStream(new FileOutputStream(fileOut));
                  // For instrumentation and runtime we need a IRuntime instance
                  // to collect execution data:
                  // The Instrumenter creates a modified version of our test target class
                  // that contains additional probes for execution data recording:
                  final Instrumenter instr = new Instrumenter(new OfflineInstrumentationAccessGenerator());
                  final byte[] instrumented = instr.instrument(is, fileIn.getName());
                  os.write(instrumented);
              } finally {
                  closeQuietly(os);
                  closeQuietly(is);
              }
          } else {
              FileUtils.copyFile(fileIn, fileOut);
          }
      }

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

关注51Testing

联系我们

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

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

沪ICP备05003035号

沪公网安备 31010102002173号