51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

查看: 937|回复: 0
打印 上一主题 下一主题

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

[复制链接]
  • TA的每日心情
    无聊
    3 小时前
  • 签到天数: 939 天

    连续签到: 1 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2022-3-15 09:57:21 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
     当业务快速发展,新业务不断出现,开发同学粗心的情况下,难免会出现少测漏测的情况,如何保证新增代码有足够的[url=]测试[/url]覆盖率?当一段正常的代码,开发却修改了,测试人员没有测试其功能,如果保证能够发现?
      所以代码覆盖测试是有必要的,代码覆盖只能保证这行代码执行了,不能保证其是否正确。寻找相关工具,发现最接近的是jacoco。jacoco 接入也比较简单,在安卓上用的offline 模式,不过jacoco 默认是全部插入探针代码,所以需要对其改造,只对增量代码插入探针。
      大致流程
      需求开发流程:项目管理是git,master 分支是线上分支,开发人员在开发某个需求时,会从master 拉取新分支开发,测试完成,封板上线后,会把分支合到master 上。确保master 永远是线上代码。
      测试流程:开发人员在开发时,是开发包,开发完成会打测试包给测试人员,测试人员反馈问题,开发修改代码,再次打包,这一过程可能会重复多次。测试通过,告知开发打正式包。最终上线正式包。我们对开发包称为debug 包,测试包称为beta包,正式包称为release 包。buildType中对应三种打包方式。
    1.  buildTypes {
    2.           debug {...
    3.           }
    4.           release {...
    5.           }
    6.           beta {...
    7.           }
    8.       }
    复制代码
    其中debug 包是开发人员直接安装运行,beta与release是通过 jenkins 连打包机打出来供测试人员下载。
      所以我们在插入探针代码时,只需要对beta 包插入,然后测试人员下载,手动测试,本地生成数据,上传数据给服务器。供后续生成报告时使用,使用开关如下:
    1.  jacocoCoverageConfig {
    2.       jacocoEnable isBeta()
    3.       ....
    4.   }
    5.   def isBeta() {
    6.       def taskNames = gradle.startParameter.taskNames
    7.       for (tn in taskNames) {
    8.           if (tn == "assembleBeta" || tn == "ttpPackageBeta") {
    9.               return true
    10.           }
    11.       }
    12.       return false
    13.   }
    复制代码
    在打release 包时,调用生成报告的任务。查看本次增量代码的覆盖率报告,输出报告到apk目录,供开发人员查看,由开发人员判断这个覆盖率是否合理。当然你也可以在低于百分之多少时抛出异常,中断打包。
      框架的整体流程如下:

     首先分为三块:
      1、编译时:这里说的是开关为打开的情况,编译时主要是获取两个分支的差异方法集合,然后调用jacoco提供的方法,对差异方法代码插入探针。
      2、App 运行时:测试人员在运行带有探针的包,会把探针运行数据.ec 保存在本地,下次再打开app时上传上次数据。
      3、生成报告:打正式包时,下载此项目版本所有的覆盖数据,和编译时一样,获取两分支差异方法集合。调用jacoco方法,生成最终的差异方法报告。
      下面分别对各个流程中一些技术难点说明。
      一、编译时
      编译时是通过gradle TransForm实现的,TransForm可以对字节码进行修改。主要过程分为三大步,class git 管理、获取差异方法、对diff方法插入探针。
      大致代码如下:
    1. JacocoTransform.groovy
    2.   @Override
    3.       void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    4.           ……
    5.           if (!dirInputs.isEmpty() || !jarInputs.isEmpty()) {
    6.               if (jacocoExtension.jacocoEnable) {
    7.                   //copy class到 app/classes
    8.                   copy(transformInvocation, dirInputs, jarInputs, jacocoExtension.includes)
    9.                   //提交classes 到git
    10.                   gitPush(jacocoExtension.gitPushShell, "jacoco auto commit")
    11.                   //获取差异方法集
    12.                   BranchDiffTask branchDiffTask = project.tasks.findByName('generateReport')
    13.                   branchDiffTask.pullDiffClasses()
    14.               }
    15.               //对diff方法插入探针
    16.               inject(transformInvocation, dirInputs, jarInputs, jacocoExtension.includes)
    17.           }
    18.       }
    复制代码
     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 如下:注意是强制切换,会导致工作区丢失。
    1. #!/bin/sh
    2.   gitBran=$1 # 要切换的分支
    3.   workDir=$2 #当前目录
    4.   outDir=$3 # copy 输出目录
    5.   git checkout -b $gitBran origin/$gitBran
    6.   git checkout -f $gitBran
    7.   git pull
    8.   cp -r "${workDir}/app/classes" $outDir
    复制代码

    1.2.3、生成差异方法集
      对两个分支目录的所有class,使用ASM读取class,访问方法,收集方法信息,关键代码如下:
    1.  public class DiffClassVisitor extends ClassVisitor {
    2.   ……
    3.   @Override
    4.       public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
    5.           MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
    6.           final MethodInfo methodInfo = new MethodInfo();
    7.           methodInfo.className = className;
    8.           methodInfo.methodName = name;
    9.           methodInfo.desc = desc;
    10.           methodInfo.signature = signature;
    11.           methodInfo.exceptions = exceptions;
    12.           mv = new MethodVisitor(Opcodes.ASM5, mv) {
    13.               StringBuilder builder = new StringBuilder();
    14.               //访问方法一个参数
    15.               @Override
    16.               public void visitParameter(String name, int access) {
    17.                   builder.append(name);
    18.                   builder.append(access);
    19.                   super.visitParameter(name, access);
    20.               }
    21.               
    22.               //访问方法一个注解
    23.               @Override
    24.               public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
    25.                   builder.append(desc);
    26.                   builder.append(visible);
    27.                   return super.visitAnnotation(desc, visible);
    28.               }
    29.               //访问ldc指令,也就是访问常量池索引
    30.               //与方法体有关,需要参与md5
    31.               @Override
    32.               public void visitLdcInsn(Object cst) {
    33.                   //资源id 每次编译都会变,所以不参与 0x7f010008
    34.                   if (!(cst instanceof Integer) || !isResourceId((Integer)cst)) {
    35.                       builder.append(cst.toString());
    36.                   }
    37.                   super.visitLdcInsn(cst);
    38.               }
    39.               ……
    40.               //方法访问结束
    41.               @Override
    42.               public void visitEnd() {
    43.                   String md5 = Util.MD5(builder.toString());
    44.                   methodInfo.md5 = md5;
    45.                   DiffAnalyzer.getInstance().addMethodInfo(methodInfo, type);
    46.                   super.visitEnd();
    47.               }
    48.          }
    复制代码
     其中MethodVisitor 有很多 visitXxx方法,都是方法的基本信息与一些指令。然后对其md5 ,得到方法的md5签名。通过classsName,methodName,desc来定位同一方法,然后比较其md5是否一致。一致则代表未修改过代码。这里要注意的是visitLdcInsn 访问常量池指令,因为每次编译时,资源id都会不一致,所以要过滤掉资源id。
      当所有的class 访问结束,通过两个分支方法集,得到差异方法集。
    1. public void diff() {
    2.           if (!currentList.isEmpty() && !branchList.isEmpty()) {
    3.               for (MethodInfo cMethodInfo : currentList) {
    4.                   boolean findInBranch = false;
    5.                   for (MethodInfo bMethodInfo : branchList) {
    6.                       if (cMethodInfo.className.equals(bMethodInfo.className)
    7.                               && cMethodInfo.methodName.equals(bMethodInfo.methodName)
    8.                               && cMethodInfo.desc.equals(bMethodInfo.desc)) {
    9.                           if (!cMethodInfo.md5.equals(bMethodInfo.md5)) {
    10.                               diffList.add(cMethodInfo);
    11.                           }
    12.                           findInBranch = true;
    13.                           break;
    14.                       }
    15.                   }
    16.                   if (!findInBranch) {
    17.                       diffList.add(cMethodInfo);
    18.                   }
    19.                   diffClass.add(cMethodInfo.className);
    20.               }
    21.           }
    22.       }
    复制代码
     1.2.4、插入探针代码
      调用jacoco 的instrument ,把插入探针后的字节码写入文件。
    1. ClassInjector.class
    2.       @Override
    3.       void processClass(File fileIn, File fileOut) throws IOException {
    4.           if (shouldIncludeClass(fileIn)) {
    5.               InputStream is = null;
    6.               OutputStream os = null;
    7.               try {
    8.                   is = new BufferedInputStream(new FileInputStream(fileIn));
    9.                   os = new BufferedOutputStream(new FileOutputStream(fileOut));
    10.                   // For instrumentation and runtime we need a IRuntime instance
    11.                   // to collect execution data:
    12.                   // The Instrumenter creates a modified version of our test target class
    13.                   // that contains additional probes for execution data recording:
    14.                   final Instrumenter instr = new Instrumenter(new OfflineInstrumentationAccessGenerator());
    15.                   final byte[] instrumented = instr.instrument(is, fileIn.getName());
    16.                   os.write(instrumented);
    17.               } finally {
    18.                   closeQuietly(os);
    19.                   closeQuietly(is);
    20.               }
    21.           } else {
    22.               FileUtils.copyFile(fileIn, fileOut);
    23.           }
    24.       }
    复制代码












    本帖子中包含更多资源

    您需要 登录 才可以下载或查看,没有帐号?(注-册)加入51Testing

    x
    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-4-28 12:38 , Processed in 0.064782 second(s), 24 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表