unify usage of TestableMock, no more targetClass

This commit is contained in:
金戟 2020-10-25 20:40:44 +08:00
parent 9981aafa93
commit a0509a889f
13 changed files with 151 additions and 137 deletions

View File

@ -13,33 +13,33 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
@EnablePrivateAccess
class DemoServiceTest {
@TestableMock
@TestableMock(targetMethod = CONSTRUCTOR)
private BlackBox createBlackBox(String text) {
return new BlackBox("mock_" + text);
}
@TestableMock
private String innerFunc(String text) {
private String innerFunc(DemoService self, String text) {
return "mock_" + text;
}
@TestableMock(targetClass = String.class)
@TestableMock
private String trim(String self) {
return "trim_string";
}
@TestableMock(targetClass = String.class, targetMethod = "substring")
@TestableMock(targetMethod = "substring")
private String sub(String self, int i, int j) {
return "sub_string";
}
@TestableMock(targetClass = String.class)
@TestableMock
private boolean startsWith(String self, String s) {
return false;
}
@TestableMock
private String callFromDifferentMethod() {
private String callFromDifferentMethod(DemoService self) {
if (TEST_CASE.equals("should_able_to_get_test_case_name")) {
return "mock_special";
}

View File

@ -12,23 +12,23 @@ import java.util.concurrent.Executors
@EnablePrivateAccess
internal class DemoServiceTest {
@TestableMock
@TestableMock(targetMethod = CONSTRUCTOR)
private fun createBlackBox(text: String) = BlackBox("mock_$text")
@TestableMock
private fun innerFunc(text: String) = "mock_$text"
private fun innerFunc(self: DemoService, text: String) = "mock_$text"
@TestableMock(targetClass = BlackBox::class)
@TestableMock
private fun trim(self: BlackBox) = "trim_string"
@TestableMock(targetClass = BlackBox::class, targetMethod = "substring")
@TestableMock(targetMethod = "substring")
private fun sub(self: BlackBox, i: Int, j: Int) = "sub_string"
@TestableMock(targetClass = BlackBox::class)
@TestableMock
private fun startsWith(self: BlackBox, s: String) = false
@TestableMock
private fun callFromDifferentMethod(): String {
private fun callFromDifferentMethod(self: DemoService): String {
return if (TEST_CASE == "should_able_to_get_test_case_name") {
"mock_special"
} else {

View File

@ -6,7 +6,7 @@
- remove dependence on EnableTestable annotation in `testable-agent`
- rename annotations to reflect the actual use
- ## v0.1.0
## v0.1.0
- move generated agent jar to class folder
- support mock method of any object

View File

@ -57,30 +57,30 @@
**【1】覆写任意类的方法调用**
定义一个普通方法,使它与需覆写的方法名称和返回值类型完全一致,且比原方法的参数列表在首位多一个与该方法所属对象类型一致的参数。然后为这个方法加上`@TestableMock`注解,并设置`targetClass`属性值也为该方法所属对象的类型
在测试类里定义一个有`@TestableMock`注解的普通方法,使它与需覆写的方法名称、参数、返回值类型完全一致,然后在其参数列表首位再增加一个类型为该方法原本所属对象类型的参数
此时被测类中所有对该类指定方法的调用,将在单元测试运行时,自动被替换为对上述自定义Mock方法的调用。
此时被测类中所有对该需覆写方法的调用,将在单元测试运行时,将自动被替换为对上述自定义Mock方法的调用。
`@TestableMock`注解还有一个很少需要用到的`targetMethod`属性用于指定Mock的目标方法名称。使用此参数后被注释修饰的方法名称就可以随意命名了通常仅在遇到极其罕见的Mock方法签名重名情况时才需要使用
**注意**:当遇到有两个需覆写的方法重名时,可将需覆写的方法名写到`@TestableMock`注解的`targetMethod`参数里此时Mock方法自身就可以随意命名了
示例项目文件`DemoServiceTest.java`中的`should_able_to_test_common_method()`用例详细展示了这种用法。
示例项目文件`DemoServiceTest.java`中的`should_able_to_test_common_method()`用例详细展示了这种用法。
**【2】覆写任意类的new操作**
**【2】覆写被测类自身的成员方法**
同样还是定义一个普通方法,然后加上`@TestableMock`注解。方法名称随意只需让方法的返回值为要覆写new操作的目标类型且参数与指定类构造方法完全一致
有时候在对某些方法进行测试时希望将被测类自身的另外一些成员方法Mock掉
此时被测类中所有用`new`创建指定类的操作将被替换为对该自定义方法的调用。
详见示例项目文件`DemoServiceTest.java`中的`should_able_to_test_new_object()`用例。
**【3】覆写被测类自身的私有成员方法**
有时候被测类自身的某个成员方法访问了外部系统在进行单元测试的时候就需要将这个备查样自己的成员方法Mock掉。
在测试类中声明一个名称、参数和返回值类型都与要覆写的目标方法完全一致的普通方法,同样加上`@TestableMock`注解,不配置`targetClass`属性,即可实现对被测类私有成员方法的覆写。
操作方法与前一种情况相同Mock方法的第一个参数类型需与被测类相同即可实现对被测类自身不论是公有或私有成员方法的覆写。
详见示例项目文件`DemoServiceTest.java`中的`should_able_to_test_member_method()`用例。
**【3】覆写任意类的new操作**
在测试类里定义一个有`@TestableMock`注解的普通方法,将注解的`targetMethod`参数写为"<init>",然后使该方法与要被创建类型的构造函数参数、返回值类型完全一致,方法名称随意。
此时被测类中所有用`new`创建指定类的操作并使用了与Mock方法参数一致的构造函数将被替换为对该自定义方法的调用。
详见示例项目文件`DemoServiceTest.java`中的`should_able_to_test_new_object()`用例。
**【4】识别当前测试用例和调用来源**
在Mock方法中可以通过`TestableTool.TEST_CASE`和`TestableTool.SOURCE_METHOD`来识别**当前运行的测试用例名称**和**进入该Mock方法前的被测类方法名称**,从而区分处理不同的调用场景。

View File

@ -14,6 +14,7 @@
<project.compiler.level>1.6</project.compiler.level>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<asm.lib.version>8.0.1</asm.lib.version>
<testable.version>0.2.0-SNAPSHOT</testable.version>
<plugin.compiler.version>3.8.1</plugin.compiler.version>
<plugin.jar.version>3.2.0</plugin.jar.version>
<plugin.shade.version>3.2.4</plugin.shade.version>
@ -31,6 +32,12 @@
<version>5.6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba.testable</groupId>
<artifactId>testable-core</artifactId>
<version>${testable.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>

View File

@ -4,8 +4,6 @@ import com.alibaba.testable.agent.constant.ConstPool;
import com.alibaba.testable.agent.model.MethodInfo;
import com.alibaba.testable.agent.util.BytecodeUtil;
import com.alibaba.testable.agent.util.ClassUtil;
import com.alibaba.testable.agent.util.CollectionUtil;
import com.alibaba.testable.agent.util.StringUtil;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.*;
@ -35,22 +33,15 @@ public class SourceClassHandler extends BaseClassHandler {
*/
@Override
protected void transform(ClassNode cn) {
List<MethodInfo> methods = new ArrayList<MethodInfo>();
for (MethodNode m : cn.methods) {
// record all member method names
if (!ConstPool.CONSTRUCTOR.equals(m.name)) {
methods.add(new MethodInfo(cn.name, m.name, null, m.desc));
}
}
// member methods which has injection stub
Set<MethodInfo> memberInjectMethods = CollectionUtil.getCrossSet(methods, injectMethods);
Set<MethodInfo> memberInjectMethods = new HashSet<MethodInfo>();
Set<MethodInfo> newOperatorInjectMethods = new HashSet<MethodInfo>();
for (MethodInfo mi : injectMethods) {
if (!mi.getClazz().equals(cn.name)) {
if (mi.getName().equals(ConstPool.CONSTRUCTOR)) {
newOperatorInjectMethods.add(mi);
} else {
memberInjectMethods.add(mi);
}
}
// new operations which has injection stub
Set<MethodInfo> newOperatorInjectMethods = CollectionUtil.getMinusSet(injectMethods, memberInjectMethods);
for (MethodNode m : cn.methods) {
transformMethod(cn, m, memberInjectMethods, newOperatorInjectMethods);
}
@ -64,25 +55,19 @@ public class SourceClassHandler extends BaseClassHandler {
do {
if (invokeOps.contains(instructions[i].getOpcode())) {
MethodInsnNode node = (MethodInsnNode)instructions[i];
int index = memberInjectMethodList.indexOf(new MethodInfo(node.owner, node.name, null, node.desc));
if (index >= 0) {
String memberInjectMethodName = getMemberInjectMethodName(memberInjectMethodList, node);
if (memberInjectMethodName != null) {
// it's a member method and an inject method for it exist
int rangeStart = getMemberMethodStart(instructions, i);
if (rangeStart >= 0) {
if (cn.name.equals(node.owner)) {
// member method of current class
instructions = replaceMemberCallOps(cn, mn, instructions, rangeStart, i);
} else {
// member method of other class
String method = memberInjectMethodList.get(index).getSubstitutionMethod();
instructions = replaceCommonCallOps(cn, mn, instructions, node.owner, method, rangeStart, i);
}
instructions = replaceMemberCallOps(cn, mn, instructions, node.owner, memberInjectMethodName, rangeStart, i);
i = rangeStart;
}
} else if (ConstPool.CONSTRUCTOR.equals(node.name)) {
// it's a new operation
String newOperatorInjectMethodName = getNewOperatorInjectMethodName(newOperatorInjectMethods, node);
if (newOperatorInjectMethodName.length() > 0) {
if (newOperatorInjectMethodName != null) {
// and an inject method for it exist
int rangeStart = getConstructorStart(instructions, node.owner, i);
if (rangeStart >= 0) {
instructions = replaceNewOps(cn, mn, newOperatorInjectMethodName, instructions, rangeStart, i);
@ -95,13 +80,22 @@ public class SourceClassHandler extends BaseClassHandler {
} while (i < instructions.length);
}
private String getMemberInjectMethodName(List<MethodInfo> memberInjectMethodList, MethodInsnNode node) {
for (MethodInfo m : memberInjectMethodList) {
if (m.getClazz().equals(node.owner) && m.getName().equals(node.name) && m.getDesc().equals(node.desc)) {
return m.getMockName();
}
}
return null;
}
private String getNewOperatorInjectMethodName(Set<MethodInfo> newOperatorInjectMethods, MethodInsnNode node) {
for (MethodInfo m : newOperatorInjectMethods) {
if (m.getDesc().equals(getConstructorInjectDesc(node))) {
return m.getName();
return m.getMockName();
}
}
return "";
return null;
}
private String getConstructorInjectDesc(MethodInsnNode constructorNode) {
@ -160,19 +154,6 @@ public class SourceClassHandler extends BaseClassHandler {
}
private AbstractInsnNode[] replaceMemberCallOps(ClassNode cn, MethodNode mn, AbstractInsnNode[] instructions,
int start, int end) {
MethodInsnNode method = (MethodInsnNode)instructions[end];
String testClassName = ClassUtil.getTestClassName(cn.name);
mn.instructions.insertBefore(instructions[start], new FieldInsnNode(GETSTATIC, testClassName,
ConstPool.TESTABLE_INJECT_REF, ClassUtil.toByteCodeClassName(testClassName)));
mn.instructions.insertBefore(instructions[end], new MethodInsnNode(INVOKEVIRTUAL, testClassName,
method.name, method.desc, false));
mn.instructions.remove(instructions[start]);
mn.instructions.remove(instructions[end]);
return mn.instructions.toArray();
}
private AbstractInsnNode[] replaceCommonCallOps(ClassNode cn, MethodNode mn, AbstractInsnNode[] instructions,
String ownerClass, String substitutionMethod, int start, int end) {
mn.maxStack++;
MethodInsnNode method = (MethodInsnNode)instructions[end];

View File

@ -6,27 +6,26 @@ package com.alibaba.testable.agent.model;
public class MethodInfo {
/**
* name of the class this method belongs to
* name of the class this method belongs to (in slash-separate format)
*/
private final String clazz;
/**
* name of the method
* name of the source method
*/
private final String name;
/**
* name of the substitution method
* Note: this field do NOT join the `equals()` or `hashCode()` calculation
* name of the mock method
*/
private final String substitutionMethod;
private final String mockName;
/**
* parameter and return value of the method
* parameter and return value of the source method
*/
private final String desc;
public MethodInfo(String clazz, String name, String substitutionMethod, String desc) {
public MethodInfo(String clazz, String name, String mockName, String desc) {
this.clazz = clazz;
this.name = name;
this.substitutionMethod = substitutionMethod;
this.mockName = mockName;
this.desc = desc;
}
@ -38,8 +37,8 @@ public class MethodInfo {
return name;
}
public String getSubstitutionMethod() {
return substitutionMethod;
public String getMockName() {
return mockName;
}
public String getDesc() {
@ -55,6 +54,7 @@ public class MethodInfo {
if (!clazz.equals(that.clazz)) { return false; }
if (!name.equals(that.name)) { return false; }
if (!mockName.equals(that.mockName)) { return false; }
return desc.equals(that.desc);
}
@ -62,6 +62,7 @@ public class MethodInfo {
public int hashCode() {
int result = clazz.hashCode();
result = 31 * result + name.hashCode();
result = 31 * result + mockName.hashCode();
result = 31 * result + desc.hashCode();
return result;
}

View File

@ -8,7 +8,6 @@ import com.alibaba.testable.agent.model.MethodInfo;
import com.alibaba.testable.agent.tool.ComparableWeakRef;
import com.alibaba.testable.agent.util.ClassUtil;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AnnotationNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.MethodNode;
@ -22,7 +21,6 @@ import java.util.List;
import java.util.Set;
import static com.alibaba.testable.agent.util.ClassUtil.toDotSeparateFullClassName;
import static com.alibaba.testable.agent.util.ClassUtil.toSlashSeparatedName;
/**
* @author flin
@ -30,7 +28,6 @@ import static com.alibaba.testable.agent.util.ClassUtil.toSlashSeparatedName;
public class TestableClassTransformer implements ClassFileTransformer {
private final Set<ComparableWeakRef<String>> loadedClassNames = ComparableWeakRef.getWeekHashSet();
private static final String TARGET_CLASS = "targetClass";
private static final String TARGET_METHOD = "targetMethod";
@Override
@ -85,47 +82,44 @@ public class TestableClassTransformer implements ClassFileTransformer {
}
private void checkMethodAnnotation(ClassNode cn, List<MethodInfo> methodInfos, MethodNode mn) {
if (mn.visibleAnnotations == null) {
ImmutablePair<String, String> methodDescPair = extractFirstParameter(mn.desc);
if (methodDescPair == null || mn.visibleAnnotations == null) {
return;
}
for (AnnotationNode an : mn.visibleAnnotations) {
if (toDotSeparateFullClassName(an.desc).equals(ConstPool.TESTABLE_MOCK)) {
String sourceClassName = ClassUtil.getSourceClassName(cn.name);
String targetClass = getAnnotationParameter(an, TARGET_CLASS, sourceClassName);
String targetClass = ClassUtil.toSlashSeparateFullClassName(methodDescPair.left);
String targetMethod = getAnnotationParameter(an, TARGET_METHOD, mn.name);
if (sourceClassName.equals(targetClass)) {
// member method of the source class
methodInfos.add(new MethodInfo(
toSlashSeparatedName(targetClass), targetMethod, null, mn.desc));
if (targetMethod.equals(ConstPool.CONSTRUCTOR)) {
String sourceClassName = ClassUtil.getSourceClassName(cn.name);
methodInfos.add(new MethodInfo(sourceClassName, targetMethod, mn.name, mn.desc));
} else {
// member method of a common class
ImmutablePair<String, String> methodDescPair = extractFirstParameter(mn.desc);
if (methodDescPair != null && methodDescPair.left.equals(ClassUtil.toByteCodeClassName(targetClass))) {
methodInfos.add(new MethodInfo(
toSlashSeparatedName(targetClass), targetMethod, mn.name, methodDescPair.right));
}
methodInfos.add(new MethodInfo(targetClass, targetMethod, mn.name, methodDescPair.right));
}
break;
}
}
}
/**
* Split desc to "first parameter" and "desc of rest parameters"
* @param desc method desc
*/
private ImmutablePair<String, String> extractFirstParameter(String desc) {
// assume first parameter is a class
int pos = desc.indexOf(";");
return pos < 0 ? null : ImmutablePair.of(desc.substring(1, pos + 1), "(" + desc.substring(pos + 1));
}
private String getAnnotationParameter(AnnotationNode an, String key, String defaultValue) {
/**
* Read value of annotation parameter
*/
private <T> T getAnnotationParameter(AnnotationNode an, String key, T defaultValue) {
if (an.values != null) {
int i = an.values.indexOf(key);
if (i % 2 == 0) {
Object value = an.values.get(i + 1);
if (value instanceof Type) {
// fit for `targetClass` parameter
return ClassUtil.toSlashSeparateFullClassName(value.toString());
for (int i = 0; i < an.values.size(); i += 2) {
if (an.values.get(i).equals(key)) {
return (T)(an.values.get(i + 1));
}
return value.toString();
}
}
return defaultValue;

View File

@ -33,34 +33,4 @@ public class CollectionUtil {
return list;
}
/**
* Get cross set of two collections
* @param collectionLeft the first collection
* @param collectionRight the second collection
*/
public static <T> Set<T> getCrossSet(Collection<T> collectionLeft, Collection<T> collectionRight) {
Set<T> crossSet = new HashSet<T>();
for (T i : collectionLeft) {
if (collectionRight.contains(i)) {
crossSet.add(i);
}
}
return crossSet;
}
/**
* Get minus set of two collections
* @param collectionRaw original collection
* @param collectionMinus items to remove
*/
public static <T> Set<T> getMinusSet(Collection<T> collectionRaw, Collection<T> collectionMinus) {
Set<T> crossSet = new HashSet<T>();
for (T i : collectionRaw) {
if (!collectionMinus.contains(i)) {
crossSet.add(i);
}
}
return crossSet;
}
}

View File

@ -0,0 +1,38 @@
package com.alibaba.testable.agent.handler;
import com.alibaba.testable.agent.model.MethodInfo;
import com.alibaba.testable.core.accessor.PrivateAccessor;
import org.junit.jupiter.api.Test;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.VarInsnNode;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.*;
import static org.objectweb.asm.Opcodes.*;
class SourceClassHandlerTest {
private final SourceClassHandler handler = new SourceClassHandler(new ArrayList<MethodInfo>());
@Test
void should_get_member_method_start() {
AbstractInsnNode[] instructions = new AbstractInsnNode[]{
new VarInsnNode(ALOAD, 2),
new VarInsnNode(ALOAD, 0),
new VarInsnNode(ALOAD, 2),
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/String", "trim", "()Ljava/lang/String;", false),
new InsnNode(ICONST_1),
new InsnNode(ICONST_2),
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/String", "substring", "(II)Ljava/lang/String;", false),
new MethodInsnNode(INVOKESPECIAL, "com/alibaba/testable/demo/DemoServiceTest", "blackBox", "(Ljava/lang/String;)Lcom/alibaba/testable/demo/BlackBox;", false),
new MethodInsnNode(INVOKEVIRTUAL, "com/alibaba/testable/demo/BlackBox", "callMe", "()Ljava/lang/String;", false),
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/String", "startsWith", "(Ljava/lang/String;)Z", false)
};
assertEquals(2, PrivateAccessor.invoke(handler, "getMemberMethodStart", instructions, 3));
assertEquals(2, PrivateAccessor.invoke(handler, "getMemberMethodStart", instructions, 6));
assertEquals(0, PrivateAccessor.invoke(handler, "getMemberMethodStart", instructions, 9));
}
}

View File

@ -0,0 +1,23 @@
package com.alibaba.testable.agent.transformer;
import com.alibaba.testable.core.accessor.PrivateAccessor;
import org.junit.jupiter.api.Test;
import org.objectweb.asm.tree.AnnotationNode;
import static com.alibaba.testable.agent.util.CollectionUtil.listOf;
import static org.junit.jupiter.api.Assertions.*;
class TestableClassTransformerTest {
private TestableClassTransformer transformer = new TestableClassTransformer();
@Test
void should_get_annotation_parameter() {
AnnotationNode an = new AnnotationNode("");
an.values = listOf((Object)"testKey", "testValue", "demoKey", "demoValue");
assertEquals("testValue", PrivateAccessor.invoke(transformer, "getAnnotationParameter", an, "testKey", "none"));
assertEquals("demoValue", PrivateAccessor.invoke(transformer, "getAnnotationParameter", an, "demoKey", "none"));
assertEquals("none", PrivateAccessor.invoke(transformer, "getAnnotationParameter", an, "testValue", "none"));
}
}

View File

@ -12,11 +12,6 @@ import java.lang.annotation.*;
@Documented
public @interface TestableMock {
/**
* mock method of specified class instead of the class under test
*/
Class targetClass() default Object.class;
/**
* mock specified method instead of method with same name
*/

View File

@ -7,6 +7,11 @@ import com.alibaba.testable.core.util.TestableUtil;
*/
public class TestableTool {
/**
* Name of the constructor method
*/
public static final String CONSTRUCTOR = "<init>";
/**
* Name of current test case method
*/