Spring环境下的 junit 集成测试3


Spring环境下的集成测试
在单元测试时,我们尽量在屏蔽模块间相互干扰的情况下,重点关注模块内部逻辑的正确性。而集成测试则是在将模块整合在一起后进行的测试,它的目的在于发现一些模块间整合的问题。有些功能很难通过模拟对象进行模拟,相反它们往往只能在真实模块整合后,才能真正运行起来,如事务管理就是其中比较典型的例子。

按照Spring的推荐,在单元测试时,你不应该依赖于Spring容器。换言之,你不应该在单元测试时启动ApplicatonContext并从中获取 Bean,相反你应该通过模拟对象完成单元测试。

而集成测试才是事先装配好模块和模块之间的关联类,如将DAO层真实的UserDao和LoginLogDao装配到UserServiceImpl再进行测试。具体装配工作是在Spring配置文件中完成的,因此集成测试需要启动Spring容器。

一、    准备

假设我们需要实现用户的登录和日志管理。我们将准备一个UserService接口类,和它的实现类:UserServiceImpl。并围绕UserServiceImpl进行测试。

测试对象

UserService接口类:
package com.wang.spring;

public interface UserService {
    boolean hasMatchUser(String userName, String password);
    User findUserByUserName(String userName);
    void loginSuccess(User user);
    void registerUser(User user);
}

UserServiceImpl实现类
package com.wang.spring;

public class UserServiceImpl implements UserService {
    // 需要两个DAO类支持数据库操作。
    private UserDao userDao;
    private LoginLogDao loginLogDao;

    public void setLoginLogDao(LoginLogDao loginLogDao) {
        this.loginLogDao = loginLogDao;
    }

    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }

    public boolean hasMatchUser(String userName, String password) {
        int matchCount =userDao.getMatchCount(userName, password);
        return matchCount > 0;
    }

    public User findUserByUserName(String userName) {
        return userDao.findUserByUserName(userName);
    }

    public void loginSuccess(User user) {
        // User用来暂存用户信息
        user.setCredits( 5 + user.getCredits());

        // loginLog用来暂存登入信息。
        LoginLog loginLog = new LoginLog();
        loginLog.setUserId(user.getUserId());
        loginLog.setIp(user.getLastIp());
        loginLog.setLoginDate(user.getLastIp());
       
        // 数据库操作
        userDao.updateLoginInfo(user);
        loginLogDao.insertLoginLog(loginLog);
    }

    public void registerUser(User user) {
        userDao.registerUser(user);
    }
}

支持类

UserServiceImpl中用到的四个支持类,其中两个是UserDao和LoginLogDao,分别用来支持数据库操作。

UserDao.java
package com.wang.spring;

public class UserDao {

    public int getMatchCount(String userName, String password) {
        return 1;
    }

    public User findUserByUserName(String userName) {
        User user = new User();
        user.setUserName(userName);
        user.setUserId(1);
        return user;
    }

    public void updateLoginInfo(User user) {
        // TODO Auto-generated method stub
    }

    public void registerUser(User user) {
        // TODO Auto-generated method stub
    }
}

LoginLogDao.java
package com.wang.spring;

public class LoginLogDao {
    public void insertLoginLog(LoginLog loginLog) {
        // TODO Auto-generated method stub
    }
}

另外两个分别是以上两个DAO类的支持类,用来暂存数据:

User.java
package com.wang.spring;

import java.util.Date;

public class User {
    private int userId = 0;
    private String userName = null;
    private String password = null;
    private String lastIp = null;
    private int credits = 0;
    private Date lastVisitDate = null;

    public int getCredits() {
        return credits;
    }

    public void setCredits(int credits) {
        this.credits = credits;
    }

    public Object getUserId() {
        return userName;
    }

    public Object getLastIp() {
        return lastIp;
    }

    public void setUserId(int userId) {
        this.userId = userId;
    }

    public void setLastIp(String lastIp) {
        this.lastIp = lastIp;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public void setLastVisit(Date lastVisitDate) {
        this.lastVisitDate = lastVisitDate;
    }

    public void setPassword(String password) {
        this.password = password;       
    }
}

LoginLog.java
package com.wang.spring;

public class LoginLog {
    public void setUserId(Object userId) {
        // TODO Auto-generated method stub
    }
    public void setIp(Object lastIp) {
        // TODO Auto-generated method stub
    }
    public void setLoginDate(Object lastIp) {
        // TODO Auto-generated method stub
    }
}

配置文件

以下是Spring配置文件,在这个案例中,我们只演示测试过程,而并没有真的做测试,所以只需要一个数据源就可以,不需要为此建立任何数据表。

ApplicationContext.xml




   
        <:property name="locations">
           
                classpath:database.properties                   
           
       
   

   
            class="org.springframework.jdbc.datasource.DriverManagerDataSource">
       
       
       
       
   

   
        <:property name="dataSource">
           
       
   

   
        <:property name="transactionManager">
           
       
        <:property name="transactionAttributes">
           
                PROPAGATION_REQUIRED
           
       
   

   
        <:property name="proxyInterfaces">
           
                com.wang.spring.UserService
           
       
        <:property name="interceptorNames">
           
               
               
           
       
   

   
   
       
       
   


   
   


database.properties
database.connection.url=jdbc:oracle:thin:@servername:test
database.connection.username=username
database.connection.password=password

二、传统测试(JUnit)的缺点

现在假设我们使用传统的方式直接通过扩展TestCase创建测试用例,则所有带test前缀的测试方法都会被毫无例外地执行。

代码清单 UserServiceJUnitTestCase:UserService集成测试用例

package com.wang.spring.test;

import java.util.Date;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.FileSystemXmlApplicationContext;
import com.wang.spring.User;
import com.wang.spring.UserService;
import junit.framework.TestCase;

public class UserServiceJUnitTestCase extends TestCase{
    //①Spring容器引用
    public ApplicationContext ctx = null;
    //②Spring配置文件
    private static String[] CONFIG_FILES = {
        "ApplicationContext.xml",
    };

    //③启动Spring容器
    protected void setUp() throws Exception {
        ctx = new FileSystemXmlApplicationContext(CONFIG_FILES);
    }
   
    //④测试方法一:查找匹配账户
    public void testHasMatchUser() {
        //④-1从容器中获取Bean
        UserService userService
            = (UserService) ctx.getBean("userService");
        boolean b1 = userService.hasMatchUser("admin", "123456");
        boolean b2 = userService.hasMatchUser("admin", "1111");
        assertTrue(b1);
        assertTrue(!b2);
    }
   
    //⑤测试方法二:登录用户
    public void testAddLoginLog() {
        //⑤-1从容器中获取Bean
        UserService userService
            = (UserService) ctx.getBean("userService");
        User user = userService.findUserByUserName("admin");
        user.setUserId(1);
        user.setUserId("admin");
        user.setLastIp("123.123.123.123");
        user.setLastVisit(new Date());
        userService.loginSuccess(user);
    }
}

1)    导致多次Spring容器初始化问题:根据JUnit测试方法的调用流程,每执行一个测试方法都会创建一个TestUserService实例并调用setUp()方法。由于我们在setUp()方法中初始化Spring 容器,这意味着TestUserService有多少个测试方法,Spring容器就会被重复初始化多少次。(是吗?待考证……)虽然初始化Spring容器的速度并不会太慢,但由于可能会在Sprnig容器初始化时执行加载Hibernate映射文件等耗时的操作,如果每执行一个测试方法都必须重复初始化Spring容器,则对测试性能的影响是不容忽视的。

2)    需要使用硬编码方式手工获取Bean:在④-1和⑤-1处,我们通过ctx.getBean()方法从Spring容器中获取需要测试的目标Bean,并且还要进行强制类型转换的造型操作。这种乏味的操作迷漫在测试用例的代码中,让人觉得繁琐不堪

3)    数据库现场容易遭受破坏:⑤处 的测试方法会对数据库记录进行插入操作,虽然是针对开发数据库进行操作,但如果数据操作的影响是持久的,可能会影响到后面的测试行为。举个例子,你在测试 方法中插入一条ID为1的User记录,第一次运行不会有问题,第二次运行时,就会因为主键冲突而导致测试用例失败。所以应该既能够完成功能逻辑检查,又能够在测试完成后恢复现场,不会留下“后遗症”。

4)    没有对数据操作正确性进行检查:⑤处我们向登录日志表插入了一条成功登录日志,可是我们却没有对t_login_log表中是否确实添加了一条记录进行检查。原来我们的方式是打开数据库,肉眼观察是否插入了相应的记录,但这严重违背了自动测试的原则。试想,你在测试包括成千上万个数据操作行为的程序时,如何用肉眼进行检查?

三、Spring提供的测试支持类

ConditionalTestCase
如果你直接通过扩展TestCase创建测试用例,则所有带test前缀的测试方法都会被毫无例外地执行。而ConditionalTestCase可以让你在某些情况下,有选择地关闭掉一些测试方法。要关闭某个测试方法行,仅需实现ConditionalTestCase的 isDisabledInThisEnvironment(String testMethodName)方法就可以了,ConditionalTestCase在运行每一个测试方法前会根据 isDisabledInThisEnvironment()方法判断是简单放弃目标方法的运行,还是按正常方式执行之。该方法默认情况下对所有的测试方法都返回false,也即执行所有的测试方法。

UserServiceSpringConditionalTestCase.java
package com.wang.spring.test;

import org.springframework.test.ConditionalTestCase;

public class UserServiceSpringConditionalTestCase
        extends ConditionalTestCase {
    // ①被忽略不执行的测试方法
    private static String[] IGNORED_METHODS
        = {"testMethod1","testMethod3"};

    // ②所有在 IGNORED_METHODS数组中 的方法都忽略执行。
    @Override
    protected boolean isDisabledInThisEnvironment(String testMethodName) {
        for (String method : IGNORED_METHODS) {
            if (method.equals(testMethodName)) {
                return true;
            }
        }
        return false;
    }
   
    // ③不执行
    public void testMethod1(){
        System.out.println("method1");
    }
   
    // ④执行
    public void testMethod2(){
        System.out.println("method2");
    }
   
    // ⑤不执行
    public void testMethod3(){
        System.out.println("method3");
    }
}

AbstractSpringContextTests
AbstractSpringContextTests 扩展于ConditionalTestCase,它维护了一个static类型的缓存器(HashMap),它使用键保存Spring ApplicationContext实例,这意味着Spring ApplicationContext是JVM级的,不同测试用例、不同测试方法都可以共享这个实例。也就是说,在运行多个测试用例和测试方法时,Spring容器仅需要实例化一次就可以了,极大地提高了基于Spring容器测试程序的运行效率。Spring通过这个测试帮助类解决了前面我们所指出的第1)个问题。

AbstractSingleSpringContextTests
AbstractSingleSpringContextTests继承于AbstractSpringContextTests,它通过一些方法让你方便地指定Spring配置文件所在位置:

String[] getConfigLocations():该方法允许你在指定Spring配置文件时使用资源类型前缀,这些资源类型前缀包括:classpath:、 file:。以类似于“com/baobaotao/beans.xml”形式指定的资源被当成类路径资源处理;

String[] getConfigPaths():以“/”开头的地址被当成类路径处理,如“/com/baobaotao/beans.xml”,而未以“/”开头的地址被当成相对于测试类所在包的文件路径,如“beans.xml”表示配置文件在测试类所在类包的目录下;

String getConfigPath():和String[] getConfigPaths()类似,在仅需指定一个配置文件中使用。

以上三个方法,它们的优先级和我们介绍的先后顺序对应,也就是说,当你在子类中覆盖了getConfigLocations()方法后,其它两个方法就没有意义了。AbstractSingleSpringContextTests将 Spring容器引用添加到static缓存中,并通过getApplicationContext()获取ApplicationContext 的引用。

一般情况下,所有的测试类和测试方法都可以共享这个Spring容器直到测试完结,不过在某些极端情况下,测试方法可能会对Spring容器进行改动(比如通过程序改变Bean的配置定义),如果这种改变对于其它测试方法来说是有干扰的,这就相当于“弄脏”了作为测试现场的Spring容器,因此在下一个测试方法执行前必须“抹除”这个改变。你可以简单地在会“弄脏”Spring容器的测试方法中添加setDirty()方法向 AbstractSingleSpringContextTests报告这一行为,这样在下一个测试方法执行前,AbstractSingleSpringContextTests就会重新加载Spring容器以修补被“弄脏”的部分。

AbstractDependencyInjectionSpringContextTests
AbstractDependencyInjectionSpringContextTests所新添的主要功能是其子类的属性能被Spring容器中的 Bean自动装配,你无需手工通过ApplicationContext.getBean()从容器中获取目标Bean自行装配。它很好回答了前面我们所指出第2)问题。

SpringDependencyInjectionContextTest.java
package com.wang.spring.test;

import org.springframework.test.AbstractDependencyInjectionSpringContextTests;

import com.wang.spring.User;
import com.wang.spring.UserService;

public class SpringDependencyInjectionContextTest
        extends AbstractDependencyInjectionSpringContextTests {
    protected UserService userService;
   
    // ①该属性设置方法会被自动调动
    public void setUserService(UserService userService) {
        this.userService = userService;
    }
   
    // ②指定Spring配置文件
    @Override
    protected String[] getConfigLocations() {
        return new String[]{"classpath:ApplicationContext.xml"};
    }

    // ③测试方法
    public void testHasMatchUser(){
        boolean match = userService.hasMatchUser("admin","123456");
        assertEquals(true, match);
    }
   
    public void testRegisterUser(){
        User user = new User();
        user.setUserId(2);
        user.setUserName("john");
        user.setPassword("123456");
        userService.registerUser(user);
    }
}
在②处,我们指定了Spring配置文件所在的位置,AbstractDependencyInjectionSpringContextTests将使用这些配置文件初始化好Spring容器,并将它们保存于static的缓存中。然后马上着手根据类型匹配机制(byType机制,参考下面的说明),自动将Spring容器中匹配测试类属性的Bean通过Setter ①注入到测试类中(UserService)。

多匹配冲突
如果Spring容器中拥有多个匹配UserService类型的Bean,会抛出UnsatisfiedDependencyException异常。如通过事务代理工厂为UserServiceImpl创建的代理Bean,我们通常会定义两个userService:(见前面配置文件ApplicationContext.xml)

一个被代理的目标类:
 
     
     
 
和一个代理类:
   
        <:property name="proxyInterfaces">
           
                com.wang.spring.UserService
           
       
        <:property name="interceptorNames">
           
               
               
           
       
   
两个Bean都按类型匹配于UserService,在对DependencyInjectionCtxTest的userService属性进行自动装配将会引发问题。

解决问题的办法有如下几种:
1)    将目标类写成代理类的内部类。
2)    使用基于注解驱动的事务管理配置机制,这样就无需在配置文件中定义两个UserService的Bean了。
3)    改变DependencyInjectionCtxTest的自动装配机制:Spring默认使用byType类型的自动装配机制,但它允许你通过 setAutowireMode()的方法改变默认自动装配的机制,比如可以调用setAutowireMode(AUTOWIRE_BY_NAME) 方法启用按名称匹配的自动装配机制。AbstractDependencyInjectionSpringContextTests定义了三个代表自动装配机制类型的常量,分别说明如下:
a)    AUTOWIRE_BY_TYPE:按类型匹配的方式进行自动装配,这个默认的机制。
b)    AUTOWIRE_BY_NAME:按名字匹配的方式进行自动装配。
c)    AUTOWIRE_NO:不使用自动装配机制,这意味着你需要手工调用getBean()进行装配。
在SpringDependencyInjectionContextTest类中加入构造函数:
    public SpringDependencyInjectionContextTest()
    {
        setAutowireMode(AUTOWIRE_BY_NAME);
    }

在不提供Setter方法的情况下自动注入
如果你觉得众多的Setter方法影响了视觉感观,但又希望享受测试类属性自动装配的好处,Spring也不会让你失望的。你需要做的是以下两步的工作
1)    将需要自动装配的属性变量声明为protected
2)    在测试类构造函数中调用setPopulateProtectedVariables(true)方法。

修改部分SpringDependencyInjectionContextTest.java代码如下:
package com.wang.spring.test;

import org.springframework.test.AbstractDependencyInjectionSpringContextTests;

import com.wang.spring.UserService;

public class SpringDependencyInjectionContextTest
        extends AbstractDependencyInjectionSpringContextTests {
    // ①将属性声明为protected
    protected UserService userService;
   
    // ②将Setter方法移除掉
    /*
    public void setUserService(UserService userService) {
        this.userService = userService;
    }
    */
   
    // ③启用直接对属性变量进行注释的机制
    public SpringDependencyInjectionContextTest(){
        /*
        Spring容器中没有匹配该属性的Bean,
        AbstractDependencyInjectionSpringContextTests 会抛出
        UnsatisfiedDependencyException异常。如果希望在采用自动装配的
        情况下,忽略属性未得到装配的情况,那么可以在测试类构造函数中调用
        setDependencyCheck(false)方法
        */
        setDependencyCheck(false);
        setPopulateProtectedVariables(true);
    }
    ……
}

提示:属性如果声明为public,虽然你也调用了setPopulateProtectedVariables(true)方法,属性变量依然不会被自动注入。所以这种机制仅限于protected的属性变量。

AbstractTransactionalSpringContextTests

方便地恢复测试数据库现场
我们现在已经可以通过AbstractDependencyInjectionSpringContextTests的属性自动装配机制方便地建立起测试固件,省却手工调用getBean()自行准备测试固件的烦恼。当我们对UserService的hasMatchUser()和 findUserByUserName()方法进行测试时,不会有任何问题,因为这两个方法仅对数据库执行读操作。但UserService以下两个接口方法会对数据库执行更改操作:
    void loginSuccess(User user);
    void registerUser(User user);
每完成一次操作,就有可能在数据库中建立一条新纪录,这导致在第二次执行的时候有可能产生数据已存在或主键冲突等错误。为了防止这种问题,测试用例必须在保证不对数据库状态产生持久化变化的情况下,对目标类的数据操作逻辑正确性进行检测。只要我们让测试方法不提交事务,在测试完后自动回滚事务,就可以了。

AbstractTransactionalSpringContextTests专为解决以上问题而生,也就是说前面我们所提及的第3)个问题在此得到了回答。只要继承该类创建测试用例,在默认情况下,测试方法中所包含的事务性数据操作都会在测试方法返回前被回滚。由于事务回滚操作发生在测试方法返回前的点上,所以你可以象往常一样在测试方法体中对数据操作的正确性进行校验。

SpringTransactionalContextTest.java
package com.wang.spring.test;

import org.springframework.test.AbstractTransactionalSpringContextTests;

import com.wang.spring.User;
import com.wang.spring.UserService;

public class SpringTransactionalContextTest
        extends AbstractTransactionalSpringContextTests {
    private UserService userService;

    public SpringTransactionalContextTest()
    {
        setAutowireMode(AUTOWIRE_BY_NAME);
    }

    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @Override
    protected String[] getConfigLocations() {
        return new String[]{"ApplicationContext.xml"};
    }
   
    // ①测试方法中的数据操作将在方法返回前被回滚,不会对数据库产生永久性数据操作,
    // 第二次运行该测试方法时,依旧可以成功运行。
    public void testRegisterUser(){
        User user = new User();
        user.setUserId(2);
        user.setUserName("john");
        user.setPassword("123456");
        userService.registerUser(user);

        //  ②对数据操作进行
        User user1 = userService.findUserByUserName("john");

        // 正确性检验
        assertEquals(user.getUserId(), user1.getUserId());

        // setComplete();
    }
}

如果testRegisterUser()是直接继承于AbstractDependencyInjectionSpringContextTests类的测试方法,则重复运行该测试方法就会发生数据冲突问题。但因为它位于继承于 AbstractTransactionalSpringContextTests的测试用例类中,测试方法中对数据库的操作会被正确回滚,所以重复运行不会有任何问题。

如果确实希望测试方法中对数据库的操作持久生效而不是被回滚,Spring也可以满足你的要求,你仅需要在测试方法中添加setComplete()方法就可以了。

AbstractTransactionalSpringContextTests还拥有几个可用于初始化测试数据库,并在测试完成后清除测试数据的方法,分别介绍如下:
1)    onSetUpBeforeTransaction()/onTearDownAfterTransaction():子类可以覆盖这两个方法,以便在事务性测试方法运行的前后执行一些数据库初始化的操作并在事务完成后清除。
2)    onSetUpInTransaction()/onTearDownInTransaction():这对方法和前面介绍的方法完成相同的功能,只不过它们是在测试方法的相同事务中执行的。

延迟加载(lazy模式)
AbstractTransactionalSpringContextTests另外还提供了一组用于测试延迟数据加载的方法:endTransaction()/startNewTransaction()。Hibernate、JPA等允许延迟数据加载的应用会先在Service层中建立事务,然后加载部分数据,然后结束事务;当数据被传递到Web层时,重新打开事务完成延迟部分数据的加载。你可以在测试方法中显式调用endTransaction()方法以模拟从Service层中获取部分数据后返回,尔后,再通过 startNewTransaction()开启一个和原事务无关新事务——模拟在Web层中重新打开事务,接下来你就可以访问延迟加载的数据。(注意,延迟的实现始终是在不同的事务中。)

AbstractTransactionalDataSourceSpringContextTests

数据库有效性检查
在前的代码清单的②处,我们通过UserService.findUserByUserName()方法对前面registerUser(user)方法数据操作的正确性进行检验。可是如果UserService不存在这样的方法,我们只好用AbstractTransactionalDataSourceSpringContextTests来实现数据的检查。

该类继承于AbstractTransactionalSpringContextTests,它添加了一个JdbcTemplate。它自动使用Spring容器中的数据源(DataSource)创建好一个JdbcTemplate实例并开放给子类使用。值得注意的是,如果你采用byName自动装配机制,数据源Bean的名称必须取名为“dataSource”。

SpringTransactionalDataSourceContextTest.java
package com.wang.spring.test;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.test.AbstractTransactionalDataSourceSpringContextTests;

import com.wang.spring.User;
import com.wang.spring.UserService;

public class SpringTransactionalDataSourceContextTest
        extends AbstractTransactionalDataSourceSpringContextTests {

    private static Log logger
        = LogFactory.getLog(
            SpringTransactionalDataSourceContextTest.class.getName());
    private UserService userService;
   
    public SpringTransactionalDataSourceContextTest()
    {
        setAutowireMode(AUTOWIRE_BY_NAME);
    }

    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @Override
    protected String[] getConfigLocations() {
        return new String[]{"ApplicationContext.xml"};
    }
   
    public void testRegisterUser() {
        User user = new User();
        user.setUserId(2);
        user.setUserName("john");
        user.setPassword("123456");
        userService.registerUser(user);
       
        // ①AbstractTransactionalDataSourceSpringContextTests 的子类
        // 可以直接使用JdbcTemplate访问数据库以检查结果。
        String sqlStr
            ="SELECT user_id FROM t_user WHERE user_name ='john'";
        try{
            int userId = jdbcTemplate.queryForInt(sqlStr);
            assertEquals(user.getUserId(), userId);
        }
        catch(Exception e)
        {
            logger.warn(e.getMessage(), e);
        }

        // setComplete();
    }
}

这样,即便没有实现类似findByUsername之类的方法时,我们也可以检查执行结果了。

猜你喜欢

转载自zhaixp1949.iteye.com/blog/2278358