最近我们又在热火朝天的搞起来了单元测试。但谈到单元测试,总是不可避免的涉及到Mock,如何方便地Mock,是一个很大的难题。
之所以会是这样的原因,主要是因为Mock的时候希望尽量不侵入原有代码,而且可以任意对部分,包括私有方法进行Mock。
在比较工具之后,我选择了最近火热的testable-mock框架,但是在使用的过程发现如果我想进行流程测试,并不是一个测试类对应一个被测类,这样的方式去Mock,有没有办法去增强一下呢? 我们定义一个全局的类,将我们依赖RPC Mock进去,并且支持数据模板的配置?
抱着这个想法,我开始了自己的折腾(zuosi)路线。
Testable-Mock的原理
开始之前,我们简单了解一下字节码增强的原理。
在java 1.5开始, 开始有了一个魔法技能,就是在JVM加载某个CLASS允许修改之后再进行加载。这个神奇的功能就是java.lang.instrument , 核心的接口是ClassFileTransformer 。
有了这个之后,好多字节码操作框架都涌现出来,比如ASM,javassist框架,有的工具甚至让我们不用了解字节码的结构,
就可以很方便的去修改我们想要修改的类。
Testable-Mock正是基于这个机制进行的实现,操作字节码也是通过ASM框架。
TestableClassTransformer 作为class文件修改的入口,在程序启动的时候会加载agent的com.alibaba.testable.agent.PreMain#premain 加载这个transformer。
Testable-Mock的设计
明白了Testable-Mock的设计之后,让我们来看看它的设计吧。
在改进的过程中我最大的感觉是它的设计比较绕,一开始没有把握它的模型关系,导致走了很多弯路。
入口逻辑:
我们从com.alibaba.testable.agent.transformer.TestableClassTransformer#transform 开始。
if (mockClassParser.isMockClass(cn)) {
bytes = new MockClassHandler(className).getBytes(bytes);
BytecodeUtil.dumpByte(cn, GlobalConfig.getDumpPath(), bytes);
return bytes;
}
String mockClass = foundMockForSourceClass(className);
if (mockClass != null) {
List<MethodInfo> injectMethods = mockClassParser.getTestableMockMethods(mockClass);
bytes = new SourceClassHandler(injectMethods, mockClass).getBytes(bytes);
BytecodeUtil.dumpByte(cn, GlobalConfig.getDumpPath(), bytes);
return bytes;
}
-
它会对每一个Class会进行判断,是否是MockClass,那么使用MockClassHandler进行处理。 -
不是MockClass的话,那么就按SourceClass进行处理,寻找是否有对应的MockClass。 -
如果可以找到MockClass,那么就对SourceClass中的进行替换
入口逻辑相当的简单,也很容易明白。我们接着向下看。
MockClassHandler:
@Override
protected void transform(ClassNode cn) {
LogUtil.diagnose("Found mock class %s", cn.name);
if (!CLASS_OBJECT.equals(cn.superName)) {
MockAssociationUtil.recordSubMockContainer(ClassUtil.toDotSeparatedName(cn.superName),
ClassUtil.toDotSeparatedName(cn.name));
}
injectRefFieldAndGetInstanceMethod(cn);
int mockMethodCount = 0;
for (MethodNode mn : cn.methods) {
if (isMockMethod(mn)) {
mockMethodCount++;
mn.access = BytecodeUtil.toPublicAccess(mn.access);
unfoldTargetClass(mn);
injectInvokeRecorder(mn);
injectAssociationChecker(mn);
handleTestableUtil(mn);
}
}
LogUtil.diagnose(" Found %d mock methods", mockMethodCount);
}
逻辑处理过程:
-
首先创建一个Public Static 的singleton变量到这个类中,这样在其他类中来引用Mock方法的时候就不需要new了。 private void injectRefFieldAndGetInstanceMethod(ClassNode cn) {
String byteCodeMockClassName = ClassUtil.toByteCodeClassName(mockClassName);
MethodNode getInstanceMethod = new MethodNode(ACC_PUBLIC | ACC_STATIC, GET_TESTABLE_REF,
VOID_ARGS + byteCodeMockClassName, null, null);
...
}
-
找到这个MockClass的所有MethodNode,循环处理。根据注释我们也很容易明白 -
首先,会把这个方法设置成public的。 -
解析target class参数,下面的步骤是处理记录、association类型的,我们这里就不展开了。
这里的核心就是增加了一个静态的GET_TESTABLE_REF 方法和将target class放入到每一个mock method的第一个参数里。
foundMockForSourceClass:
private String foundMockForSourceClass(String className) {
String mockClass = lookForMockWithAnnotationAsSourceClass(className);
if (mockClass != null) {
return mockClass;
}
mockClass = foundMockForTestClass(ClassUtil.getTestClassName(className));
if (mockClass != null) {
return mockClass;
}
return foundMockForInnerSourceClass(className);
}
找MockClass的方式有三个:
-
按@MockWith 的方式去查找。如果SourceClass包含这个@MockWith 注解,那么就查看对应的class。 -
按TestClass去寻找。主要的逻辑是假如是测试类,按@MockWith 方式去寻找。如果没有找到,就按照className的方式去寻找,逻辑是将Test 关键字替换成Mock 看是否可以找到。 private String foundMockForTestClass(String className) {
ClassNode cn = adaptInnerClass(ClassUtil.getClassNode(className));
if (cn != null) {
String mockClass = lookForMockWithAnnotationAsTestClass(cn);
if (mockClass != null) {
return mockClass;
}
mockClass = lookForInnerMockClass(cn);
if (mockClass != null) {
return mockClass;
}
}
return lookForOuterMockClass(className);
}
-
在SourceClass内部中寻找。
SourceClassHandler:
我们重点关注一下com.alibaba.testable.agent.handler.SourceClassHandler#transformMethod 方法
do {
if (invokeOps.contains(instructions[i].getOpcode())) {
MethodInsnNode node = (MethodInsnNode)instructions[i];
if (CONSTRUCTOR.equals(node.name)) {
...
} else {
...
MethodInfo mockMethod = getMemberInjectMethodName(memberInjectMethods, node);
if (mockMethod != null) {
int rangeStart = getMemberMethodStart(instructions, i);
if (rangeStart >= 0) {
if (rangeStart < i) {
handleFrameStackChange(mn, mockMethod, rangeStart, i);
}
instructions = replaceMemberCallOps(mn, mockMethod,
instructions, node.owner, node.getOpcode(), rangeStart, i);
i = rangeStart;
} else {
LogUtil.warn("Potential missed mocking at %s:%s", mn.name, getLineNum(instructions, i));
}
}
}
}
i++;
} while (i < instructions.length);
这里有两个点我们要关注一下:
-
invokeOps.contains(instructions[i].getOpcode()) 的invokeOps,包括4种类型,只有这四种类型的我们才需要替换。从下面的代码,我们可以知道主要是对方法的调用进行替换。 private final Set<Integer> invokeOps = new HashSet<Integer>() {{
add(Opcodes.INVOKEVIRTUAL);
add(Opcodes.INVOKESPECIAL);
add(Opcodes.INVOKESTATIC);
add(Opcodes.INVOKEINTERFACE);
}};
-
getMemberInjectMethodName(memberInjectMethods, node); 根据node从MockClass的memberInjectMethods 中获取可以使用的MockMethod。
看到这里,它的整体设计我们已经了解了。接下来我们总结一下。
总结:
模型关系总结
一个测试类对应一个被测类,被测类会引用好几个引用类,我们的Mock类定义的targetClass和targetMethod应该指向引用类的class和method。这点我在开始的时候总是以为对应的是被测类。
流程总结:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BNB5LOk8-1632972595544)(https://raw.githubusercontent.com/Sutonline/md-img-bed/master/testable-mock%E5%8E%9F%E6%9C%89%E6%B5%8B%E8%AF%95%E6%B5%81%E7%A8%8B.jpg)]
明白了它的设计之后,那么我们来开始搞起来!
Testable-Mock改进
改进的方式很简单,主要有两个点。
- 解析我们定义的GlobalMockClass,存储MockClass和TargetClass、TargetMethod的关系
- 在寻找MockClass的时候做一些改动,如果包括我们全局的Global中的TargetClass就可以进行替换
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hFIJGvNj-1632972595547)(https://raw.githubusercontent.com/Sutonline/md-img-bed/master/Testable%E6%94%B9%E8%BF%9B%E6%B5%81%E7%A8%8B.png)]
类设计方面也比较简单:
- 继承原有的MockClassParser,存储全局的targetClass-method信息
- 继承原有的SourceClassHandler,从单个MockClass替换成Method方法集合
- 实现DataReader,可以基于文件配置读取方法入参返回结果
更具体的细节可以参见代码: 代码地址
总结
初次的字节码增强旅程并不是很顺利,只是迈出了一小步,不过感觉的确很神奇。对于ASM以及类加载的过程都没有系统的去理解,这个层次的领域知识还需要继续学习。
|