我摸、我摸、我摸摸摸——提高代码可测试性

SidelightofLife 2009-03-21

      虽然有了EasyMock这样的摸客工具,但并不一定就表示你的代码好测,在mock对象创建完成后,你的代码得有能力让这些mock对象注入到你的对象中去,这样EasyMock才能有用武之地,也就是说,只有当代码基于IOC原则实现的,才能使EasyMock发挥真正的作用。

      满足以下条件的代码都是无法通过创建mock对象来测试的:

1.在代码内自己查找依赖。如在代码中直接new某个接口的实现类;

2.代码依赖于单例类的实例。由于单例类的构造方法是private的,所以无法创建mock对象。如果使用spring,那么单例的效果可以通过spring的配置来实现,这时就可以将类实现为普通类,而EasyMock就可以起作用了;

3.代码依赖与某些类的静态方法的执行结果。静态方法是无法mock的,所以不要在静态方法中实现业务逻辑,本身这样实现也是错误的,业务逻辑应该放到相应的领域对象中的功能,静态方法只应该用于实现某些简单逻辑的工具方法;

      在现实中,很多时候我们需要依赖老系统中的接口进行开发,某些接口被实现为单例类或者接口的实例只能通过某个静态方法获取,假如在代码中直接调用这些静态方法来获取实例,那么测试时我们就得强依赖与静态方法的实现,无法对依赖进行mock,如下面的代码:

public class AccountService {
	public void doSomething() {
		AccountDao.getInstance().insert();
	}
}

public class AccountDao {
	private static final AccountDao INSTANCE = new AccountDao();
	
	private AccountDao() {
	}
	
	public void insert() {
	}
	
	public static AccountDao getInstance() {
		return INSTANCE;
	}
}

      AccountDao是一个单例类,而AccountService直接通过AccountDao.getInstance()来获取依赖,这样的代码在UT时是很难测试的,因为无法对AccountDao进行mock。这里将介绍几种解决的方法。

第一种方法,先来看点代码:

public class AccountService {
	private AccountDao accountDao;

	public void setAccountDao(AccountDao accountDao) {
		this.accountDao = accountDao;
	}

	public AccountDao getAccountDao() {
		if (accountDao == null) {
			accountDao = AccountDao.getInstance();
		}
		return accountDao;
	}

	public void doSomething() {
		getAccountDao().insert();
	}
}
      这里为AccountService增加了accountDao属性,并为它添加geAccount、setAccount方法,代码中依赖accountDao的地方都通过getAccount()获取实例,同时在getAccount方法中弄了点小技量,先判断accountDao是否为null,为null就调用AccountDao.getInstance()来获取AccountDao的实例。虽然这里有get、set方法,但实际上并没有真正的使用IOC原则,因为我们还是在AccountService中去查找依赖了。而setAccount方法也只是为了UT而留下的一个小后门,因为这时我们可以在UT时将mock对象注入,但这儿set方法在系统运行过程中根本不会使用,就只是为了让我们的代码好测,感觉是有点别扭。
      第二种方法,假如你的系统中已经在使用Spring,那么可以通过Spring提供的方法替换(Method Replacement)来实现依赖注入。通过方法替换,你可以在不修改任何源代码的前提下,替换任何非final类的非静态方法。
Spring的方法替换是通过CGLIB在运行期动态创建类的子类来实现的,随后对方法的调用就会被重定向到另一个类上,这个类需要实现MethodReplacer接口:
import java.lang.reflect.Method;
import org.springframework.beans.factory.support.MethodReplacer;

public class AccountDaoReplacer implements MethodReplacer {
	public Object reimplement(Object arg0, Method arg1, Object[] arg2)
			throws Throwable {
		return AccountDao.getInstance();
	}
}


public class AccountService {
	private AccountDao accountDao;

	public void setAccountDao(AccountDao accountDao) {
		this.accountDao = accountDao;
	}

	public AccountDao getAccountDao() {
		return accountDao;
	}

	public void doSomething() {
		getAccountDao().insert();
	}
}
      我们实现了MethodReplacer的reimplement方法,方法的第一个参数是原有方法被调用的那个对象,第一个参数表示要覆盖的方法,第三个参数是方法调用时传入的参数,这个方法必须返回重新实现后的逻辑结果。再看看现在的AccountService,惟一的改动就是getAccount()方法的if分支被删除掉了。代码写完后,还需要在spring的配置文件中做如下配置才能达到我们预期的目标:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" 
    "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
	<bean id="accountServiceReplacer" class="AccountDaoReplacer"></bean>
	<bean id="accountService" class="AccountService">
		<replaced-method name="getAccountDao" replacer="accountServiceReplacer">
</replaced-method>
	</bean>
</beans>
      这里声明了AccountService和AccountDaoReplacer的实例,在AccountService的声明中,我们通过replace-method指明了需要替换的方法,方法名由name属性指定,replacer指向MethodReplacer的bean名称,如果被替换的方法有多个重载方法,那么需要在replace-method中通过arg-type指定参数:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" 
    "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
	<bean id="accountServiceReplacer" class="AccountDaoReplacer"></bean>
	<bean id="accountService" class="AccountService">
		<replaced-method name="getAccountDao" replacer="accountServiceReplacer">
			<arg-type>String</arg-type>
		</replaced-method>
	</bean>	
</beans>
由于用了CGLIB,性能方面要比第一中方法要稍差些,不过只是这种简单的get方法的替换,这个差异并不明显,调用AccountService的getAccount方法100000次,花了151毫秒,而第一个方法则几乎是0毫秒。另外在这种解决方法中,setAccount方法也纯粹就是为了UT而留下的后门。

      第三种解决方法,是通过Spring的FactoryBean接口来实现。Spring的FactoryBean本来就是用来解决这种无法通过new创建bean的情况的,可以将它当做其它bean的工厂。FactoryBean可以象普通bean一样在Spring中配置,但当Spring使用FactoryBean来查找依赖时,并不返回FactoryBean本身,而是调用Factory.getObject()方法,并以其返回值做为查找的结果。

import org.springframework.beans.factory.FactoryBean;

public class AccountDaoFactoryBean implements FactoryBean {

	public Object getObject() throws Exception {
		return AccountDao.getInstance();
	}

	public Class getObjectType() {
		return AccountDao.class;
	}

	public boolean isSingleton() {
		return true;
	}
}

public class AccountService {
	private AccountDao accountDao;

	public void setAccountDao(AccountDao accountDao) {
		this.accountDao = accountDao;
	}

	public AccountDao getAccountDao() {
		return accountDao;
	}

	public void doSomething() {
		getAccountDao().insert();
	}
}
FactoryBean接口需要实现三个方法:getObject()方法获取FactoryBean要创建的对象,这个才真正是其它bean要依赖的对象;getObjectType()方法返回FactoryBean要创建的对象的class;isSingleton()告诉Spring,FactoryBean创建的对象是否为单例的,这里需要和FactoryBean在Spring中配置<bean>标签时指定的singleton属性区别开来,后者用于告诉Spring该FactoryBean本身是否为单例的,而不是FactoryBean创建的Bean是否为单例。

      代码写完后,在Spring中配置下就可以了,和普通bean的配置没有什么区别:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" 
    "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
	<bean id="accountDaoFactoryBean" class="AccountDaoFactoryBean"></bean>
	<bean id="factoryAccountService" class="AccountService">
		<property name="accountDao" ref="accountDaoFactoryBean"></property>
	</bean>
</beans>
 这里的AccountService实现与第二种方法中的实现完全一样,但在这种方法中,setAccount方法就不是个花瓶了,因为它在系统运行时会被用来注入AccountDao的实例。
      当一个方法中的代码量过多,往往意味着类或方法的设计有问题,职责不明确,在一个类或方法中做了太的事,抛除这个方面,单单从UT的角度来说,这意味着对这个方法的测试工作量会很大,为什么呢?首先方法的代码量多,往往意味着方法中的依赖很多,分支很多,代码的复杂度很高,那么光是为覆盖各种分支而准备的测试数据的代码量就很大,每多写一行代码,往往可能就会多一个问题,象这样子就会经常出现代码测试不通过,然后定位到最后,却是因为测试代码有问题而导致的现象。
      既然一个方法中的代码量不能过多,OK,我重构下,采用将某些相似的逻辑提到一个私有方法中,然后由待测试方法调用抽取出来的私有方法的策略,这样子初初看起来是使待测试方法中的代码量变少了,而且代码可读性提高了不少,但其作用也仅到此而已,实际上呢是换汤不换药,对待测试方法构造的测试代码量一点也没有减少,方法还是那么的难测。
      解决的方法还是应该从根源找起,假如方法的代码很多,很可能就是因为类的职责不明确导致的,在一个类中干了多类该干的事,这时应该对类重新设计,明确职责,将功能进行分解。

相关推荐

82550495 / 0评论 2020-03-01