日期:2014-05-20  浏览次数:20996 次

在多数据源(JTA)环境下使用Spring TestContext Framework

项目背景

  1. 从08年开始一直使用Spring2.5作为核心业务容器。
  2. 采用贫血型pojo+业务service的方式处理业务逻辑。
  3. 需要读写多个数据库实例,目前有两个Oracle实例和一个MySQL实例,在某些业务下,会在同一个事务中,向这三个数据库实例中写数据。为此,我们通过JNDI,结合Spring的JtaTransactionManager处理跨数据源的事务处理。
  4. 一应与业务无关的工作,如事务、缓存等通过AOP或声明式事务来处理。
  5. 权限使用Acegi(确实是Acegi,V1.0.6,现在叫Spring Security……当时很新潮)。

测试现状

当时没有做单元测试、自动化集成测试,但是有人工的验收测试,关键业务有压力测试。也许有人会侧目,但是这是公司与项目的现状……期间曾经有领导想推行单元测试,但是未推行成功,就其原因,我觉得有如下几条:

  1. 当时推行纯粹的单元测试,但是从业务层的角度来看,由于要写很多的mock,且无法对事务、数据库处理做校验,大家觉得如果只是做业务层的if else测试,效果并不好。
  2. 项目人手少,刚毕业的多,对单元测试等培训不够,且有上线时间点压力。

项目现状

随着公司业务的飞速发展,系统使用的场景越来越复杂,对以用功能的升级改造也越来越频繁,代码重构越来越多,所以测试人员的压力很大,由功能升级或重构导致的bug也多了起来。此时通过自动化的测试脚本来提高开发质量与系统稳定性就越来越重要了。

开始集成测试

?为什么选择Spring TestContext Framework与集成测试

  • 首先,项目本来就基于Spring2.5容器,这是个天然的因素。
  • 其次,由于系统功能绝大多数都是与数据库的交互,所以测试数据库操作正确与否,是一个主要的目的。
  • 最后,由项目背景可知,数据库的事务情况比较复杂,在测试时需要完善的事务环境支持。

所以根据以上几点,我们选择基于Spring TestContext Framework做集成测试。

Spring TestContext Framework集成测试环境的构建

?构建数据源与JtaTransactionManager

根据系统的事务特点,我们需要使用脱离应用服务器的JNDI数据源,而不能使用轻量级的数据源(如DBCP),因为JtaTransactionManager是不支持的。通过查阅Spring的文档,我们使用Spring的JotmFactoryBean结合ow2-jotm的分发版本中的StandardXADataSource来构建脱离应用服务器的JNDI数据源,配置如下:

<bean id="jotm" class="org.springframework.transaction.jta.JotmFactoryBean" />
<bean id="aInnerDataSource" class="org.enhydra.jdbc.standard.StandardXADataSource" destroy-method="shutdown">
  <property name="transactionManager" ref="jotm" />
  <property name="driverName" value="oracle.jdbc.driver.OracleDriver" />
  <property name="url" value="jdbc:oracle:thin:@192.168.1.1:15211:a" />
  <property name="user" value="xxx" />
  <property name="password" value="xxx" />
</bean>
 <bean id="aDataSource" class="org.enhydra.jdbc.pool.StandardXAPoolDataSource" destroy-method="shutdown">
   <property name="dataSource" ref="aInnerDataSource"/>
   <property name="user" value="xxx"/>
   <property name="password" value="xxx"/>
   <property name="maxSize" value="5"/>
 </bean> 
<bean id="bInnerDataSource" class="org.enhydra.jdbc.standard.StandardXADataSource" destroy-method="shutdown">
  <property name="transactionManager" ref="jotm" />
  <property name="driverName" value="oracle.jdbc.driver.OracleDriver" />
  <property name="url" value="jdbc:oracle:thin:@192.168.1.2:15212:b" />
  <property name="user" value="xxx" />
  <property name="password" value="xxx" />
</bean>
 <bean id="bDataSource" class="org.enhydra.jdbc.pool.StandardXAPoolDataSource" destroy-method="shutdown">
   <property name="dataSource" ref="bInnerDataSource"/>
   <property name="user" value="xxx"/>
   <property name="password" value="xxx"/>
   <property name="maxSize" value="5"/>
 </bean> 
<bean id="transactionManager" class="org.springframework.transaction.jta.JtaTransactionManager">
  <property name="userTransaction" ref="jotm" />
  <property name="allowCustomIsolationLevels" value="true" />
</bean> 

?以上的配置,可以参考Spring JotmFactoryBean的doc,里面有比较详尽的说明,这里有两点说明:

  1. doc中只使用了一个数据源,因为其只是一个示例,而我们在测试环境中需要多个数据源。
  2. 事务配置使用AOP做横切,在集成测试环境中,其配置可以与生产环境相同,这样也保证了测试的真实性与有效性。
  3. 在测试环境中需要StandardXAPoolDataSource的配置,否则当使用AbstractTransactionalJUnit4SpringContextTests中的SimpleJdbcTemplate执行修改数据的sql时,在JTA事务环境下,事务会失效,数据会直接提交到数据库中。

测试基类的选择与扩展

Spring TestContext Framework支持JUnit3.8、JUnit4和TestNG,这里我们以JUnit4为例。首先让我们来看一下Spring基于JUnit4为我们提供的基类:AbstractTransactionalJUnit4SpringContextTests、AbstractJUnit4SpringContextTests。通过查阅文档与代码,我们知道,AbstractTransactionalJUnit4SpringContextTests为其子类提供了事务能力以及简单的数据初始化接口,而其本身就是AbstractJUnit4SpringContextTests的子类。在大多数应用场景中,直接继承AbstractTransactionalJUnit4SpringContextTests,从而获得事务能力是一个不错的选择。

Transaction 与 Flush

由于项目中使用了Hibernate,默认的FlushModel是"manual"或"Never",所以对于Inert语句的Flush会在事务提交的时候执行。由于测试代码中拥有@Transactional以及事务的传播策略,所以当Service中的事务增强方法执行结束之后,事务并未像在生产环境中那样提交,则Hibernate也没有flush。导致我们想在一个测试单元中验证事务提交后的结果变的不再可行。

基于此,我们有必要设置Hibernate,希望它立即提交,或者我们手动提交,以保证Session的Flush。为了不修改我们的代码以及尽可能的模拟代码在生产环境中的执行,我们通过AOP做一个后置处理,即在每次业务Service执行完,将SessionFluash:

public class SessionFlushingAfterAdvice implements AfterReturningAdvice { 
private List<SessionFactory> sessionFactories;

@Override
public void afterReturning(Object returnValue, Method method, Object[] args, Object target) throws Throwable {
for (SessionFactory sessionFactory : sessionFactories) { sessionFactory.getCurrentSession().flush(); }

}

public List<SessionFactory> getSessionFactories() { return sessionFactories; }

public void setSessionFactories(List<SessionFactory> sessionFactories) { this.sessionFactories = sessionFactories; }

}

?

这样我们在Insert之后,可以去校验对象的状态,并且在测试完成后,事务会自动回滚。

?

谨慎使用AbstractTransactionalJUnit4SpringContextTests做Insert操作

在初始化测试的数据环境时,我们经常会通过AbstractTransactionalJUnit4SpringContextTests的simpleJdbcTemplate执行一些写操作,以满足测试环境。如果通过simpleJdbcTemplate Insert了一条数据,之后在测试脚本中又对该数据做Update,造成数据库的死锁。

所以如果在初始化数据时需要Insert操作,请不要在事务环境下处理。

Acegi与测试环境

?在Acegi中,我们会用SecurityContextHolder从当前用户的上下文环境中获得用户信息,虽然Acegi提供了TestAuthenticationToken,但是模拟的比较简单,例如无法通过WebAuthenticationDetails获取用户当前客户的IP。而mock静态变量本身较为复杂,业务代码中有大量的静态方法也不利于测试,于是我们可以通过一个单例Service将其包装一下:

public interface UserSecurityContextHolder {
	/**
	 * 
	 * <li>获取当前登录用户</li>
	 * 
	 */
	public User getCurrentUser();

	/**
	 * 
	 * <li>获取用户的登录IP</li>
	 * 
	 */
	public String getLoginIP();
}

public class DefaultUserSecurityContextHolder implements UserSecurityContextHolder {

	@Override
	public User getCurrentUser() {
		User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
		if (null == user)
			throw new AuthenticationServiceException("authentication failure,no user in the context!");
		return user;
	}

	@Override
	public String getLoginIP() {
		WebAuthenticationDetails details = (WebAuthenticationDetails) SecurityContextHolder	.getContext()
																							.getAuthentication()
																							.getDetails();
		return details.getRemoteAddress();
	}
}

public class UserSecurityContextHolderForTest implements UserSecurityContextHolder {

	@Autowired
	private HibernateSessionWrapper wrapper;

	@Override
	public User getCurrentUser() {
		return wrapper.get(User.class, 1l);
	}

	@Override
	public String getLoginIP() {
		return "127.0.0.1";
	}
}

?在开发业务代码中,通过Spring注入这个service获取当前登陆用户的信息。在测试环境中,只要配置一个用于测试的实现即可。

单元测试(集成测试)带来了什么

对于测试,我们才刚刚上路,单元测试并非项目成败的关键,但是拥有之后却会提高代码的质量,进而提高项目成功的几率(废话)。而测试会促使我们去编写更健壮的代码,良好的测试脚本,让我们有重构的勇气,增强程序员的信心,这种影响无疑是无形和重要的。

?

?

1 楼 leifeng2 2010-08-11  
我想一个应用程序最好应该以一个数据源为准,如果需要的数据来源于多个数据库,可以考虑通过建立如数据库链路的方式,实现库于库之间的互访。
2 楼 yin_bp 2010-08-11  
感兴趣看看bbossgroups项目中所带的多数据源事务实用方法,简单实用。

poolman.xml文件【这个文件放到classes下即可】中配置了bspf和query,mq三个数据源,用来测试两种情况的多数据源事务:
a.两个不同数据源bspf和query,真正的多数据源事务
b.两个不同数据源bspf和mq,但是mq引用了bspf的多数据源事务(其实是一个数据源bspf的事务)

datasource配置文件:

<?xml version="1.0" encoding="gb2312"?>

<poolman>
	
	<datasource>
		<dbname>bspf</dbname>
		<loadmetadata>true</loadmetadata>
		<jndiName>jdbc/mysql-ds</jndiName>
		<driver>oracle.jdbc.driver.OracleDriver</driver>

		<url>jdbc:oracle:thin:@//172.16.17.219:1521/orcl</url>

		<username>testmq</username>
		<password>testmq</password>

		<txIsolationLevel>READ_COMMITTED</txIsolationLevel>

		<nativeResults>true</nativeResults>

		<poolPreparedStatements>false</poolPreparedStatements>

		<initialConnections>2</initialConnections>

		<minimumSize>0</minimumSize>
		<maximumSize>10</maximumSize>
		<!--
			控制connection达到maximumSize是否允许再创建新的connection true:允许,缺省值 false:不允许
		-->
		<maximumSoft>false</maximumSoft>

		<!--
			是否检测超时链接(事务超时链接) true-检测,如果检测到有事务超时的链接,系统将强制回收(释放)该链接 false-不检测,默认值
		-->
		<removeAbandoned>true</removeAbandoned>
		<!--
		链接使用超时时间(事务超时时间)
		单位:秒
	-->
		<userTimeout>50</userTimeout>
		<!--
			系统强制回收链接时,是否输出后台日志 true-输出,默认值 false-不输出
		-->
		<logAbandoned>true</logAbandoned>

		<!-- 
    	数据库会话是否是readonly,缺省为false
     -->
		<readOnly>false</readOnly>

		<!--
			对应属性:timeBetweenEvictionRunsMillis the amount of time (in
			milliseconds) to sleep between examining idle objects for eviction
		-->
		<skimmerFrequency>10</skimmerFrequency>
		<!--
			对应于minEvictableIdleTimeMillis 属性: minEvictableIdleTimeMillis the
			minimum number of milliseconds an object can sit idle in the pool
			before it is eligable for evcition 单位:秒 空闲链接回收时间,空闲时间超过指定的值时,将被回收
		-->
		<connectionTimeout>60</connectionTimeout>
		<!--
			numTestsPerEvictionRun the number of idle objects to examine per run
			within the idle object eviction thread (if any) 每次回收的链接个数
		-->
		<shrinkBy>5</shrinkBy>
		<!--
			/** * 检测空闲链接处理时,是否对空闲链接进行有效性检查控制开关 * true-检查,都检查到有无效链接时,直接销毁无效链接 *
			false-不检查,缺省值 */
		-->
		<testWhileidle>true</testWhileidle>


		<!--
			定义数据库主键生成机制 缺省的采用系统自带的主键生成机制, 外步程序可以覆盖系统主键生成机制 由值来决定
			auto:自动,一般在生产环境下采用该种模式,
			解决了单个应用并发访问数据库添加记录产生冲突的问题,效率高,如果生产环境下有多个应用并发访问同一数据库时,必须采用composite模式
			composite:结合自动和实时从数据库中获取最大的主键值两种方式来处理,开发环境下建议采用该种模式,
			解决了多个应用同时访问数据库添加记录时产生冲突的问题,效率相对较低,
			如果生产环境下有多个应用并发访问同一数据库时,必须采用composite模式
		-->
		<keygenerate>composite</keygenerate>

	
   		<!-- 请求链接时等待时间,单位:秒
    客服端程序请求链接等待时间超过指定值时,后台包等待超时异常
     -->
		<maxWait>60</maxWait>

		<!-- 
    	链接有效性检查sql语句
     -->
		<validationQuery>select 1 from dual</validationQuery>

		<autoprimarykey>false</autoprimarykey>
		<showsql>false</showsql>

	</datasource>


	<datasource>

		<dbname>query</dbname>
		<loadmetadata>true</loadmetadata>
		<jndiName>jdbc/oracle-query</jndiName>
		<driver>oracle.jdbc.driver.OracleDriver</driver>

		<url>jdbc:oracle:thin:@//172.16.17.219:1521/orcl</url>

		<username>query</username>
		<password>query</password>

		<txIsolationLevel>READ_COMMITTED</txIsolationLevel>

		<nativeResults>true</nativeResults>

		<poolPreparedStatements>false</poolPreparedStatements>

		<initialConnections>2</initialConnections>

		<minimumSize>0</minimumSize>
		<maximumSize>10</maximumSize>
		<!--
			控制connection达到maximumSize是否允许再创建新的connection true:允许,缺省值 false:不允许
		-->
		<maximumSoft>false</maximumSoft>

		<!--
			是否检测超时链接(事务超时链接) true-检测,如果检测到有事务超时的链接,系统将强制回收(释放)该链接 false-不检测,默认值
		-->
		<removeAbandoned>true</removeAbandoned>
		<!--
		链接使用超时时间(事务超时时间)
		单位:秒
	-->
		<userTimeout>50</userTimeout>
		<!--
			系统强制回收链接时,是否输出后台日志 true-输出,默认值 false-不输出
		-->
		<logAbandoned>true</logAbandoned>

		<!-- 
    	数据库会话是否是readonly,缺省为false
     -->
		<readOnly>false</readOnly>

		<!--
			对应属性:timeBetweenEvictionRunsMillis the amount of time (in
			milliseconds) to sleep between examining idle objects for eviction
		-->
		<skimmerFrequency>10</skimmerFrequency>
		<!--
			对应于minEvictableIdleTimeMillis 属性: minEvictableIdleTimeMillis the
			minimum number of milliseconds an object can sit idle in the pool
			before it is eligable for evcition 单位:秒 空闲链接回收时间,空闲时间超过指定的值时,将被回收
		-->
		<connectionTimeout>60</connectionTimeout>
		<!--
			numTestsPerEvictionRun the number of idle objects to examine per run
			within the idle object eviction thread (if any) 每次回收的链接个数
		-->
		<shrinkBy>5</shrinkBy>
		<!--
			/** * 检测空闲链接处理时,是否对空闲链接进行有效性检查控制开关 * true-检查,都检查到有无效链接时,直接销毁无效链接 *
			false-不检查,缺省值 */
		-->
		<testWhileidle>true</testWhileidle>


		<!--
			定义数据库主键生成机制 缺省的采用系统自带的主键生成机制, 外步程序可以覆盖系统主键生成机制 由值来决定
			auto:自动,一般在生产环境下采用该种模式,
			解决了单个应用并发访问数据库添加记录产生冲突的问题,效率高,如果生产环境下有多个应用并发访问同一数据库时,必须采用composite模式
			composite:结合自动和实时从数据库中获取最大的主键值两种方式来处理,开发环境下建议采用该种模式,
			解决了多个应用同时访问数据库添加记录时产生冲突的问题,效率相对较低,
			如果生产环境下有多个应用并发访问同一数据库时,必须采用composite模式
		-->
		<keygenerate>composite</keygenerate>

		<!-- 请求链接时等待时间,单位:秒
    客服端程序请求链接等待时间超过指定值时,后台包等待超时异常
     -->
		<maxWait>60</maxWait>

		<!-- 
    	链接有效性检查sql语句
     -->
		<validationQuery>select 1 from dual</validationQuery>

		<autoprimarykey>false</autoprimarykey>
		<showsql>false</showsql>

	</datasource>
         <datasource external="true">
		<dbname>mq</dbname>
		<externaljndiName>jdbc/mysql-ds</externaljndiName>
		<showsql>false</showsql>
	</datasource>
</poolman>


再看看多数据源事务实用方法:
package com.frameworkset.common;

import javax.transaction.RollbackException;

import org.junit.Test;

import com.frameworkset.common.poolman.DBUtil;
import com.frameworkset.orm.transaction.TransactionManager;

public class TestMutiDBTX {
	public static void testMutiDBTX()
	{
		TransactionManager tm =  new TransactionManager();
		try
		{
			tm.begin();
			DBUtil db = new DBUtil();
			db.executeDelete("bspf","delete from table1 where id=1");
			db.executeUpdate("query","update table1 set value='test' where id=1");
			tm.commit();
			DBUtil.debugStatus();
		}
		catch(Exception e)
		{
			try {
				tm.rollback();
			} catch (RollbackException e1) {
				
				e1.printStackTrace();
			}
		}

	}
	
	@Test
	public void testMutiDBButSampleDatabaseTX()
	{
		TransactionManager tm =  new TransactionManager();
		try
		{
			tm.begin();
			DBUtil db = new DBUtil();
			db.executeDelete("bspf","delete from table1 where id=1");
			db.executeUpdate("mq","update table1 set value='test' where id=1");
			tm.commit();
			DBUtil.debugStatus();
		}
		catch(Exception e)
		{
			try {
				tm.rollback();
			} catch (RollbackException e1) {
				
				e1.printStackTrace();
			}
		}
		DBUtil.debugStatus();

	}

}


bbossgroups-2.0-RC1下载地址:
http://sourceforge.net/projects/bboss/files/bbossgroups-2.0-RC1/bbossgroups-2.0-RC1.zip/download

3 楼 hellohank 2010-08-11  
对于业务层来说,并不赞成使用单元测试,而应该使用集成测试。如果做单元测试,从工作量上来说,有得不偿失的感觉~
顶楼主啊~
4 楼 former 2010-08-11  
<div class="quote_title">leifeng2 写道</div>
<div class="quote_div">我想一个应用程序最好应该以一个数据源为准,如果需要的数据来源于多个数据库,可以考虑通过建立如数据库链路的方式,实现库于库之间的互访。</div>
<p>?</p>
<p>?不知道你说的数据库链路的方式是如何处理的,可以贴出一些例子和代码看看吗?在项目中也有业务是通过Oralce桥接远程MySQL处理的业务,但却是为了某些业务上的目的而实现的。</p>
<p>基于JTA的事务处理,有其自身的适用场景,如其可以保证在多库之间事务的原子性,兼容已有的数据库等。不知道你说的数据库链路方式对于不同数据库产品、不同物理位置分布是否可以很好的支持?对于Java代码中使用Hibernate的Schema设置是否可以很好的支持?DBA的管理难度如何?</p>