开发单元测试脚手架 - 第四篇 - JDT解析业务代码

5 篇文章 0 订阅
4 篇文章 0 订阅

前言

通过上文中IBM官方文档对JDT的介绍,咱们得开始着手使用JDT工具来处理业务代码,来生成单元测试了。

 

首先业务代码中我们关注的内容是外部依赖的方法调用,因为这些内容是我们需要mock掉的,而脚手架中最为棘手的部分也是围绕mock信息的解析、mock代码的生成而展开的。

 

本文将会对外部依赖的方法调用解析转换为mock信息这个过程进行详细的描述。

 

明确需求

    还是老规矩,近一段业务类的代码:

 

 

public class XXService {
  OtherService a;
  ...
  public void biz(){
    System.out.println(a.foo1(1L));
  }
}
 

    咱们可以看到,这里我们需要为biz()方法编写单元测试,那么对我们而言 a.foo(1L) 这个方法调用就是一个外部依赖,我们需要mock掉它。

    通常,我么会编写如下代码测试代码:

 

 

...
public class XXServiceTest extends JTester {
  @SpringBeanByName
  XXService xxService;

  @SpringBeanFor
  @Mocked
  OtherService a;
  ...

  @Test
  public void test_biz(){
    new Expectations(){
      {
        a.foo1(1L);
        result = 1L;
      }
    };
    xxService.biz();
  }
}
 

    这里需要明确几个规律:

1、单元测试类的名称是有规范的,一般是类名+Test结尾;

2、单元测试类的类型,我们可以通过代码中获取到,外部依赖的类型相同,所以这些成员变量的声明,可以通过代码分析中得到;

3、需要测试的业务方法是biz(),也可以通过分析得出;

4、一般测试方法,会分为mock,被测方法的调用语句,返回值的断言(上面的例子中没有返回值,所以没有断言部分) 三个部分。

 

    固定部分的实现方法,通过JDT的API不难得出,所以不做重点。

    关键的难点在于,我们需要分析出:需要找出生成mock代码的方法调用,并分析出这些方法调用的‘成员变量名、方法名、参数类型列表、返回值类型。

 

JDT - AST能给我们什么

    还是以上文中的XXService为例,它的AST树形结构,这里简单地表示如下:

 

 

XXService
    |
    |-a
    |
    \_biz()
       |
       \_System.out.println(a.foo1(1L))
           |
           |-System.out
           |
           |-println
           |
           \_a.foo1(1L)
                |
                |-a
                |
                |-foo1
                |
                \_ 1L
 

    我们能够发现这棵树中,需要mock的部分在于节点“a.foo1(1L)”。

    那么我们可以通过JDT的核心API——ASTVisitor来遍历树得到它,但是问题又来了,这个节点的父节点也是一个方法调用,但是却是我们并不关心的。所以它的父节点算是我们需求中的干扰信息。

    如果代码再复杂一些,这种干扰信息的节点会更加的多。

 

    所以就有了我们接下来的实现策略——

 

两次遍历AST树

第一次遍历,收集所有我们关注的方法调用的信息。

第二次遍历,只针对第一次遍历得到的方法调用进行mock信息的处理,并创建这些方法调用之间的关系树结构。

 

 

核心API

org.eclipse.jdt.core.dom.ASTVisitor

visit(MethodInvocation)
遍历方法调用信息
visit(SimpleName)
遍历名称简单的标识符

 

从XXService的AST树结构中,不难发现,如果遍历到一个方法调用信息的时候,接下来遍历到的标识符信息一定与这个方法调用是相关联的。

 

这一点可以通过MethodInvocation的accept0(ASTVisitor)方法代码(如下)中可以得到证明:

 

void accept0(ASTVisitor visitor) {
	boolean visitChildren = visitor.visit(this);
	if (visitChildren) {
		// visit children in normal left to right reading order
		acceptChild(visitor, getExpression());
		if (this.ast.apiLevel >= AST.JLS3) {
			acceptChildren(visitor, this.typeArguments);
		}
		acceptChild(visitor, getName());
		acceptChildren(visitor, this.arguments);
	}
	visitor.endVisit(this);
}
 

 

第一次遍历

    知道AST树中方法调用、简单标识符两种节点之间的遍历关系之后,我们可以分两步完成收集外部依赖方法调用的目的。

第一步 将所有方法调用信息压入待处理的栈中

    代码如下:

 

public boolean visit(MethodInvocation node) {
	methodInvocationMayNeedMock.add(node);
	return true;
}

    methodInvocationMayNeedMock是一个普通的列表,凡是遍历到的方法调用节点都会被加入进来。

 

第二步 根据标识符来判断方法调用是否为外部依赖调用

    代码如下:

 

public boolean visit(SimpleName node) {
	IBinding binding = node.resolveBinding();
	if (binding instanceof IVariableBinding) {
		IVariableBinding varBinding = (IVariableBinding) binding;
		if (isMemberInUnderTestClass(varBinding)) {
			// 成员变量的父节点必须是方法调用
			if (!(node.getParent() instanceof MethodInvocation)) {
				return true;
			}

			// 成员变量必须是父节点方法调用的expression(即不能是参数)
			MethodInvocation parentInvocation = (MethodInvocation) node.getParent();
			if (node != parentInvocation.getExpression()) {
				return true;
			}

			collectMemberInvocation(node);
		}
	}
	if (binding instanceof IMethodBinding) {
		IMethodBinding methodBinding = (IMethodBinding) binding;
		if (isMethodOfUnderTestClass(methodBinding)) {
			collectMemberInvocation(node);
		}
	}
	return true;
}
 

    这里需要说明的是:

标识符种类非常的多(可以查看SimpleName的子类得知这一点),这里我们需要关注的是变量名(通过IVariableBinding匹配得出)、方法名(通过IMethodBinding匹配得出);

 

对变量名的处理

    遇到变量名时,我们会去判断它是不是被测试业务类的一个成员变量,如果是,则会收集与它相关的方法调用信息。

    来看看方法 isMemberInUnderTestClass(IVariableBinding) 的实现:

 

private boolean isMemberInUnderTestClass(IVariableBinding vb) {
	String underTestClass = getUnderTestClassFullyQualifiedName();
	if (vb.getDeclaringClass() == null) {
		return false;
	}

	boolean isNotStatic = ((vb.getModifiers() & Modifier.STATIC) != Modifier.STATIC);

	String varBindingClass = vb.getDeclaringClass().getQualifiedName();
	return vb.isField() && isNotStatic && underTestClass != null && varBindingClass != null
		   && underTestClass.equals(varBindingClass);
}

 

1、是否为被测业务类的成员变量

    通过【声明变量信息的类的完整描述信息】与【被测试业务类的完整描述信息】比对之后,再判断它是否为一个成员变量,并且不是静态属性(由于日志类对象、BeanCopier,也通常被声明为类的静态属性,所以需要排除静态属性)。

 

2、是否为方法调用的组成部分

    如果一个变量的方法被调用,则它的上级父节点一定是一个方法调用。所以直接判断它的parent是否为一个MethodInvocation类的实例就ok了。

 

3、是否为父级方法调用的expression部分。

    以一段代码为例:

xxService.doSth(memberId);

    假定xxService, memberId都是类A的成员变量,当对类A进行分析的时候,上面语句的AST树结构为:

xxService.doSth(memberId);
  |
  |-xxService
  |
  |-doSth
  |
  \_memberId
 

    在这里,成员变量memberId的父节点也是一个MethodInvocation类的实例,但是它只是参数的组成部分,而并不是它的方法被调用到了。这样收集到的信息就会出现错误(这也是数日前脚手架的一个bug)。

    所以需要增加一个判断——成员变量必须是父节点方法调用的expression的唯一组成部分(之所以是【唯一组成部分】,原因在于像类似于System.out这样的变量,脚手架也是不提供mock支持的。)。


对方法名的处理

    这里处理方法名,是为了解决被测试的业务方法调用到另外的业务方法的问题。

    举例而言:

 

public class XXService {
  public void biz1() {
    biz2();
  }

  public void biz2() {
    ...
  }
}
 

    面对这样一个业务类的biz1方法生成代码时,它当中调用的biz2()方法,也会被作为一个方法调用遍历到,但是这个方法调用并没有变量名信息,所以无法通过遍历到变量名来收集它的信息。那么就只能通过方法名来收集了。

 

收集方法调用

    方法 collectMemberInvocation 的代码如下:

 

/**
 * 收集成员变量相关的调用
 * 
 * @param node 成员变量的节点 或者 成员方法的节点
 */
private void collectMemberInvocation(SimpleName node) {
	if (methodInvocationMayNeedMock.isEmpty()) {
		return;
	}

	MethodInvocation invocation = methodInvocationMayNeedMock.get(methodInvocationMayNeedMock.size() - 1);

	Stack<MethodInvocation> miStack = new Stack<MethodInvocation>();

	// 首先收集成员变量节点或者成员方法节点相关的方法调用信息,假定名为 MI
	// 接着,假定某个方法调用的参数中包含MI,或者就是MI的直接父节点(这种情况就是级联调用),那么该方法调用也需要被收集,成为MI的父级别调用
	// 而后一直上溯所有的方法调用信息,看哪些方法调用与MI的父级别调用具有上面这样的关系,也被依次收集
	if (invocation == node.getParent()) {
		miStack.push(methodInvocationMayNeedMock.remove(methodInvocationMayNeedMock.size() - 1));

		if (!methodInvocationMayNeedMock.isEmpty()) {
			MethodInvocation parentInvocation = methodInvocationMayNeedMock.get(methodInvocationMayNeedMock.size() - 1);
			while (!parentInvocation.arguments().contains(invocation)
				   && invocation.getParent() == parentInvocation) {
				miStack.push(parentInvocation);

				methodInvocationMayNeedMock.remove(methodInvocationMayNeedMock.size() - 1);

				if (methodInvocationMayNeedMock.isEmpty()) {
					break;
				}

				invocation = parentInvocation;
				parentInvocation = methodInvocationMayNeedMock.get(methodInvocationMayNeedMock.size() - 1);
			}
		}
	}

	if (miStack.isEmpty()) {
		return;
	}

	while (!miStack.isEmpty()) {
		tmpCollectedMethodInvocations.add(miStack.pop());
	}
}
 

    前文中提到 methodInvocationMayNeedMock 这个列表中会把所有遍历到的方法调用信息收集加入进来。

    所以当我们发现了一个与外部依赖有关联的标识符时,我们就会收集它的父级方法调用以及其在AST树结构向上能够上溯找到的所有方法调用,放入到列表 tmpCollectedMethodInvocations 中。

    而在每个方法完成遍历的时候,我们会将 tmpCollectedMethodInvocations 中的调用信息合并到另外一个列表 collectedMethodInvocations 中统一管理。

 

小插曲

    之所以上溯收集所有的方法调用,是为了收集到级联调用的信息。

 

a.foo1().foo2();

 

    比如上面这样的一个级联调用语句,在AST中关系树结构如下:

 

a.foo1().foo2()
  |
  |-a.foo1()
  |    |
  |    |-a
  |    |
  |    \_foo1()
  |
  \_foo2()
 

    发现了吧,遍历到变量名 a 时,我们就会收集到级联调用的几个方法调用信息。(但是为什么不是遍历到foo2()这个方法名节点,因为笔者感觉这样做会让逻辑变复杂。)

 

第二次遍历

    这次遍历,需要将第一次遍历收集到的跟外部依赖有关的方法调用组织成一个树形关系结构,以便于后续生成代码之用。

    代码如下:

 

public boolean visit(MethodInvocation node) {
	// 如果该方法调用节点本身不是需要mock的,则不进行它的分析,
	if (!collectedMethodInvocations.contains(node)) {
		// 但是它当中可能包含需要mock的代码,所以需要返回true,让它的子节点还能继续被分析
		return true;
	}

	// 新代码——收集调用信息,生成树 begin
	MethodInvocationNode methodInvocationNode = new MethodInvocationNode(new MethodInvocationNodeData(node));
	if (analysisMethodInvocations.isEmpty()) { // 之前没有任何方法调用被分析,表明当前方法调用的父节点是方法定义
		TreeNode methodDeclarationNode = testClassTree.children.get(testClassTree.children.size() - 1);
		methodDeclarationNode.addChild(methodInvocationNode);
		methodInvocationNode.parent = methodDeclarationNode;
	} else {
		TreeNode parentMethodInvocationNode = testClassTree.findPreOrderingButFromLastChild(new MethodInvocationMatcher(
																														analysisMethodInvocations.peek()));
		parentMethodInvocationNode.addChild(methodInvocationNode);
		methodInvocationNode.parent = parentMethodInvocationNode;
	}
	// 新代码——收集调用信息,生成树 end
	analysisMethodInvocations.push(node);

	return true;
}

 

1、无视无用的方法调用

    前文中提到 collectedMethodInvocations 这个伟大的方法调用列表中管理了一个被测业务类中所有与外部依赖有关的方法调用信息,所以遍历到一个方法调用时,如果发现不在该列表中,就直接略过不进行任何处理。

 

2、将方法调用加入到生成树的方法声明节点之下

    如果当前正在分析的方法调用信息不存在,则表明这个方法调用的直接上级一定是被测业务方法的方法声明节点。

    举例而言:

    假定有业务类 XXService代码如下:

 

public class XXService {
  public void biz(){
    a.foo();
  }
}

    对应的AST树如下:

 

XXService
  |
  \_biz()
      |
      \_a.foo()
          |
          |-a
          |
          \_foo()
 

    当遍历到a.foo()这个方法调用时,就会发现它的上级节点是被测业务方法的声明节点。可以将它直接加入到生成树的方法声明节点之下。

    并将它压入到 【方法调用分析栈】 analysisMethodInvocations 中,等待visit(SimpleName)方法中进一步分析它的变量名、参数类型列表和返回值类型。

 

3、将方法调用加入到生成树的其他方法调用节点之下

    如果发现方法调用不是【方法调用分析栈】的第一个成员,则说明它应该作为其他方法调用的子节点(这里有两种情况,一是它是正在分析的方法调用的级联调用、二是它是正在分析的方法调用的参数)。

 

a. 遍历到正在分析的方法调用的级联调用

    举例而言:

    假定有另外一个业务类 XXService代码如下:

 

public class XXService {
  public void biz(){
    a.foo1().foo2();
  }
}

    对应的AST树如下:

 

XXService
  |
  \_biz()
      |
      \_a.foo1().foo2()
          |
          |-a.foo1()
          |   |
          |   |-a
          |   |
          |   \_foo1()
          |
          \_foo2()

 

    当遍历到方法调用 a.foo1() 时,我们会发现【方法调用分析栈】中已经有了其他元素,则去使用 testClassTree的 findPreOrderingButFromLastChild 方法,来前序遍历生成树中的节点查找【方法调用栈】顶部元素对应生成树中的节点对象。

    但是在遍历某个节点的子节点时,是从右向左遍历(因为当前分析的方法调用极有可能是处于右边的树节点的子节点——这里是为什么呢?)。

    由于之前我们收集方法调用信息的逻辑来推断,一定能够找到这样的一个父节点,所以我们将当前这个分析中的节点加入到【方法调用分析栈】顶部元素对应的树节点的子节点列表当中。

 

b. 遍历到正在分析的方法调用的参数

    举例而言:

    假定有业务类 OOService代码如下:

 

public class OOService {
  public void biz(){
    a.foo(b.doSth());
  }
}

    对应的AST树如下:

 

OOService
  |
  \_biz()
      |
      \_a.foo()
          |
          |- foo()
          |
          \_b.doSth()
               |
               |-b
               |
               \_doSth()
          

 

    当遍历到方法调用 b.doSth() 时,会执行上述处理【级联调用】一样的逻辑,将当前这个分析中的节点加入到【方法调用分析栈】顶部元素对应的树节点的子节点列表当中。

 

综上得到生成树结构

    合并上述两种情况的代码,得到如下业务类:

 

public class XXService {
  public void biz(){
    a.foo1().foo2();
    a.foo(b.doSth());
  }
}
 

    我们可以有如下生成树:

 

XXService
  |
  \_biz()
     |
     |-a.foo1().foo2()
     |   |
     |   \_a.foo1()
     |
     \_a.foo(b.doSth())
         |
         \_b.doSth()

 

后续处理

内联代码

    上文生成的树结构还不足以应付我们实际代码中的一种情况,比如业务类的业务方法调用了它自身的其他方法。

    举例而言:

 

public class XXService {
  public void biz1(){
    a.foo1();
    biz2()
  }
  
  public void biz2(){
    a.foo2();
  }
}
 

    它的生成树如下:

 

XXService
  |
  |-biz1()
  |  |
  |  |-a.foo1()
  |  |
  |  \_biz2()
  |
  \_biz2()
     |
     \_a.foo2()
 

    但是我们期望最终mock代码中,会将biz2()中的外部依赖的mock代码也一同生成在biz1()的测试方法当中。

    这样就需要对生成树进行一次遍历,发现【被测试方法】下的方法调用中如果包含另外一些【被测试方法】的话,就将另外一些【被测试方法】下级的方法调用节点的引用拷贝到它的下面。

    经过内联后的生成树如下:

 

XXService
  |
  |-biz1()
  |  |
  |  |-a.foo1()
  |  |
  |  \_biz2()
  |     |
  |     \_a.foo2()
  |
  \_biz2()
     |
     \_a.foo2()

 

小插曲

    如果biz1()方法中调用了biz1()自身呢?如果biz1() 调用了biz2(),biz2()又调用了biz1()呢?甚至于出现更深层次的循环调用呢?

    应对这些问题,这里的内联操作做了一个简单的处理,无限层次地向下内联自身方法下的方法调用节点,但是如果发现某个方法节点就是当前正在被联入的树的根节点(节点内容为方法定义)的根节点,就停止内联操作。

    比如出现这样的调用关系: biz1() -> biz2() -> biz1(),在执行biz1()的内联操作时,发现某个方法调用节点就是biz1(),就会停止操作,从而避免无限循环操作。

 

去除无mock信息的自身调用

    对生成树中,有可能出现某些自身方法调用,这些自身方法中并不包含任何的外部依赖调用,那么这些节点应该去除掉。

    举例而言:

    比如业务代码如下:

 

public class XXService {
  public void biz1(){
    a.foo1();
    biz2()
  }
  
  public void biz2(){
    System.out.println("hello world");
  }
}
 

    它的生成树执行完内联之后的树结构如下:

 

XXService
  |
  |-biz1()
  |  |
  |  |-a.foo1()
  |  |
  |  \_biz2()
  |
  \_biz2()
 

    我们应该将路径为 XXService -> biz1() -> biz2() 上的节点“biz2()”给删除掉,因为它并不包含任何需要mock掉的外部依赖的方法调用。

 

后记

至此一棵我们为后续mock代码生成的方法调用关系树就生成好了。

 

本文中解决了——自身方法调用的内联问题、可能出现的循环调用问题、不包含需要mock的自身方法调用节点。

 

因为项目开始在即,所以可能生成代码的博文得推迟发布了(估计得在下个周末才能写成)。

 

另外脚手架代码的开源事宜,正在与相关同事沟通当中,如果有兴趣的朋友,也可以在我的博文中留下你的批评、建议,或许笔者在文中的方法还有诸多问题,也期待得到更多人的理念,去将工具优化、完善。

== 本节完 ==

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值