Java代码覆盖率统计的原理
转自 http://linmingren.me/blog/2014/02/java%E4%BB%A3%E7%A0%81%E8%A6%86%E7%9B%96%E7%8E%87%E7%BB%9F%E8%AE%A1%E7%9A%84%E5%8E%9F%E7%90%86/
Java中有一堆统计代码覆盖率的库,我用过的就有JaCoCo和Cobertura。看起来很高端,不过原理很简单,今天没事自己写了几个类来验证一下。
假设有一个想要被测试的类是这样(实际的类当然不可能这么简单,不过拿来理解原理足够了)
package test; public class UserMgr { public int getRole(String username) { if (username.equals("admin")) { return 1; } if (username.equals("system")) { return 2; } return -1; } }
如果想要统计getRole函数哪些语句被覆盖到了,最直观的方法就是给这个类加一个列表来保存哪些语句被执行了,然后在每条语句前都往这些列表添加上当前的行号,写出来是这样
package test; import java.util.ArrayList; import java.util.List; public class UserMgr { public static List<Integer> lineCovered = new ArrayList<Integer>();//保存了执行过的行号 public int getRole(String username) { lineCovered.add(当前行号); if (username.equals("admin")) { lineCovered.add(当前行号); return 1; } lineCovered.add(当前行号); if (username.equals("system")) { lineCovered.add(当前行号); return 2; } lineCovered.add(当前行号); return -1; } }
接下来要做的就是在不修改UserMgr源码的前提下,直接把UserMgr的class文件的内容换成带有lineCovered成员的那个版本。妈呀,这也太高端了吧?别担心,有了asm和它的Bytecode Outline插件的支持,做这个就是copy&paste的技术含量。
先给UserMgr类增加lineCovered这个静态变量。粗略扫描了一下asm的官方指南asm-guide, 要对class文件作修改,标准的做法是自己实现对应的ClassVisitor和MethodVisitor,然后根据需要加入对应的语句,然后把转换后的class内容保存另外一个class文件中,这个过程就是所谓的instrument.对应的代码如下(记得先在当前工程目录加/instrument/test这两层目录,当然你也可以把修改后的class文件存在别的地方,随你便)
package instrument; import java.io.FileOutputStream; import java.io.IOException; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; public class InstrumentMain { public static void main(String[] args) throws IOException { ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);//用了COMPUTE_MAXS就不需要去处理visitMax了 CodeCoverageClassVisitor mv = new CodeCoverageClassVisitor(cw); ClassReader cr = new ClassReader("test.UserMgr"); cr.accept(mv, 0); FileOutputStream fs = new FileOutputStream("./instrument/test/UserMgr.class"); fs.write(cw.toByteArray()); fs.close(); } }
CodeCoverageClassVisitor的代码是这样,关键的地方就是在visitEnd这里要加什么东西,现在轮到Bytecode Outline发神威了。
package instrument; import static org.objectweb.asm.Opcodes.*; import org.objectweb.asm.ClassVisitor; public class CodeCoverageClassVisitor extends ClassVisitor { public CodeCoverageClassVisitor(ClassVisitor cv) { super(ASM4,cv); } @Override public void visitEnd() { //在这里给目标类加上lineCovered静态变量 super.visitEnd(); } }
在Eclipse里通过http://andrei.gmxhome.de/eclipse/安装Bytecode Outline插件(不要安装官方版本,否么要么装不上,要么用不了)。安装后把上面的UserMgr的第二个版本在Eclipse里写一遍,然后通过Window -> Show View -> Other -> Java -> Bytecode查看对应的asm代码(点那个Show ASMified Code按钮),可以看到public static ListlineCovered = new ArrayList();这句代码对应了两部分的asm代码,一部分是声明,一部分是初始化。
直接把Bytecode Outline插件里对应的代码复制到visitEnd即可。现在的完整代码是这样:
package instrument; import static org.objectweb.asm.Opcodes.*; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.FieldVisitor; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; public class CodeCoverageClassVisitor extends ClassVisitor { public CodeCoverageClassVisitor(ClassVisitor cv) { super(ASM4,cv); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv; mv = cv.visitMethod(access, name, desc, signature, exceptions); if (mv != null) { mv = new CodeCoverageMethodVisitor(mv); } return mv; } @Override public void visitEnd() { FieldVisitor fv = cv.visitField(ACC_PUBLIC + ACC_STATIC, "lineCovered", "Ljava/util/List;", "Ljava/util/List<Ljava/lang/Integer;>;", null); fv.visitEnd(); cv.visitEnd(); MethodVisitor mv = cv.visitMethod(ACC_STATIC, "<clinit>", "()V", null, null); mv.visitCode(); Label l0 = new Label(); mv.visitLabel(l0); mv.visitLineNumber(7, l0); mv.visitTypeInsn(NEW, "java/util/ArrayList"); mv.visitInsn(DUP); mv.visitMethodInsn(INVOKESPECIAL, "java/util/ArrayList", "<init>", "()V"); mv.visitFieldInsn(PUTSTATIC, "test/UserMgr", "lineCovered", "Ljava/util/List;"); mv.visitInsn(RETURN); mv.visitMaxs(2, 0); //super.visitEnd(); } }
最后就是写一个MethodVisitor来给每行代码加上对应的lineCovered.add(当前行号)。初始版本是这样:
package instrument; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; import static org.objectweb.asm.Opcodes.*; public class CodeCoverageMethodVisitor extends MethodVisitor { public CodeCoverageMethodVisitor(MethodVisitor mv) { super(ASM4,mv); } @Override public void visitLineNumber(int line, Label arg1) { //在每行语句前加lineCovered.add() super.visitLineNumber(line, arg1); } }
CodeCoverageMethodVisitor 的完整代码如下:
package instrument; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; import static org.objectweb.asm.Opcodes.*; public class CodeCoverageMethodVisitor extends MethodVisitor { public CodeCoverageMethodVisitor(MethodVisitor mv) { super(ASM4,mv); } @Override public void visitLineNumber(int line, Label arg1) { mv.visitFieldInsn(GETSTATIC, "test/UserMgr", "lineCovered","Ljava/util/List;"); mv.visitIntInsn(SIPUSH, line); mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer", "valueOf","(I)Ljava/lang/Integer;"); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "add","(Ljava/lang/Object;)Z"); mv.visitInsn(POP); super.visitLineNumber(line, arg1); } }
现在脏活干完了,重新运行下InstrumentMain来生成对应的修改后的class文件。最后写个main函数来测试一下:
package test; public class RunTest { public static void main(String[] args) { UserMgr e = new UserMgr(); e.lineCovered.clear();//清除旧的行号数据, e.getRole("admin"); System.out.println("getRole on admin covers the following lines:"); for (Integer line: UserMgr.lineCovered) { System.out.println("line: " + line); } e.lineCovered.clear(); e.getRole("system"); System.out.println("getRole on system covers the following lines:"); for (Integer line: UserMgr.lineCovered) { System.out.println("line: " + line); } } }
输出的结果是(在jdk7上可能会出现java.lang.VerifyError: Expecting a stackmap frame这样的错误,这时给测试程序加上-XX:-UseSplitVerifier参数即可):
getRole on admin covers the following lines: line: 5 line: 6 getRole on system covers the following lines: line: 5 line: 9 line: 10