针对Spring某个类及方法的单元测试,结合单元测试框架(比如JUnit)和Mock的框架(比如EasyMock)就足以完成,当前Bean需要的依赖对象通过Mock创建,隔离所有的依赖,不需要使用到Spring IoC容器。但实际开发中所进行的Spring测试并不是完全意义上的单元测试,而是依赖于容器的测试。
Spring提供的测试框架严格意义上是侧重集成测试的框架,这在Spring官方文档中有明确的区分。但Spring针对单元测试提供了一些支持,包括用于单元测试的一些共用方法和模拟类,这些方法和模拟类,可以使用在一般应用和Web应用中,让测试变得非常方便。
JUnit中加入Spring容器进行测试
结合单元测试框架和Mock框架基本可以对Spring进行单元测试,比如JUnit+EasyMock的组合。在JUnit的框架下,使用EasyMock模拟依赖对象对某个组件类或服务类进行脱离容器的单元测试。
如果要结合容器进行集成测试,在测试类中的固件初始化时(比如@Before注解方法)初始化容器后获取需要的Bean实例进行测试,以JUnit4中初始化容器进行测试的代码示例如下:
public class JunitSpringTest { //单元测试类
private ApplicationContext context; //定义上下文变量
@Before //测试环境初始化注解
public void initSpring() { //环境初始化方法
context = new ClassPathXmlApplicationContext("applicationContext.xml");//初始上下文
}
@Test //注解测试方法
public void testMethod1() { //测试方法
HelloBean bean1 = (HelloBean)context.getBean("bean1"); //从容器获取bean实例
}
}
以上在initSpring()方法中,通过ClassPathXmlApplicationContext初始化容器后就可以从容器中获取Bean进行测试。上面的代码段是较为常见的Spring测试示例,虽然可以达成测试效果,但是存在着如下问题:
频繁初始化容器,开销大、效率低且浪费资源
在JUnit4中,@Before注解在每个标准@Test方法之前都会执行,这就意味着这个测试类中有多少个@Test标识的方法,Spring容器就会被重复化初始多少次。如果容器在初始化时要加载ORM映射和数据源,这笔开销还是很可观的。这个问题虽然可以通过使用@BeforeClass注解修改成类层级的初始化,但是在整个项目使用Maven进行批量测试时,对应每个测试类,都会初始化一个新的容器。
这种效果可以在Eclipse使用Maven批量测试进行验证。新建两个测试类,打印ApplicationContext对象的id,可以看到每个测试类的应用上下文都不一样的,验证的代码如下:
public class JUnitSpring1Test { //测试类
private static ApplicationContext context; //静态应用上下文
@BeforeClass //类层级环境初始化注解
public static void initSpring() { //类层级环境初始化
context = new ClassPathXmlApplicationContext("applicationContext.xml");//上下文
}
@Test //注解测试方法
public void testMethod() { //测试方法
System.out.println("JUnitSpring1Test, applicationContext=" + context.toString());
}
}
测试代码繁琐、冗余
对每个Bean的测试都要先通过getBean()方法从容器中先获取对应的实例,之后做强制类型转换之后方可使用和测试。在涉及多个Bean的集成测试的时候,这样重复额外的代码会极大的影响测试效率。
在数据持久化处理上不便捷
在开发时候的单元测试,或是使用工具批量的单元测试,很多状况希望测试方法不要将数据操作持久化到数据库中。虽然可以在方法层级上添加事务的处理,但是针对测试的特别改动有可能出现忘记回退而影响实际的功能。
针对上面那些问题,Spring提供了专门的测试模块来简化测试工作和加快测试的效率。
Spring测试模块
Spring提供了通用的、注解驱动的测试模块(spring-test)辅助进行Spring相关的测试。spring-test支持结合JUnit或TestNG框架来进行单元测试和集成测试。spring-test的测试模块需要导入,Maven的导入方式直接在pom.xml中加入依赖项,类似:
<dependency>
<groupId>org.springframework</groupId> <!—组名
<artifactId>spring-test</artifactId> <!—组件名
<version>5.0.8.RELEASE</version> <!—版本
</dependency>
此外,Spring是结合JUnit或TestNG进行测试,所以需要确保对应的测试框架导入。
spring-test测试模块的代码目录结构如图1所示。
图1 Spring测试模块的目录结构
测试模块分为mock和test两大部分,mock包里面提供了四种类型的模拟对象,可以单独使用于单元测试。context和web分别对应核心框架和Web框架的测试支持;annotation是用于测试的注解定义;jdbc包提供了数据库测试的支持;util包提供了一些公用方法可以用于脱离Spring测试框架进行独立的单元测试。
Spring测试模块可以结合单元测试框架,对一般桌面应用和Web应用进行测试。针对两种不同类型应用的测试,Spring对应的有通用测试框架和MVC Web的测试框架。使用Spring测试框架,应用上下文可以一次性加载并进行缓存,大大提高了测试的速度。更方便的是其提供了很多便捷的注解辅助测试。
Spring测试模块对单元测试的支持
Spring对单元测试的支持主要有两个方面:提供了多种类型的模拟对象和提供了用于单元测试的一些共用方法。
Spring测试模块的模拟类
对于简单的POJO和服务端的类和方法,可以使用Mock框架模拟创建,但对于复杂的状况,Mock框架不易处理。Spring提供了更高层级的类的模拟,包含:
1、环境的模拟
Java中的Property是以键值对方式存储的数据类型,PropertyResolver是Spring提供的属性解析器,可以通过key查找对应的值,Environment继承自PropertyResolver,但是额外提供了Profile特性,即可以根据不同环境(比如:开发、测试和正式环境)得到相应数据。MockEnvironment就是对Environment的模拟,可以在测试时设置需要的属性键和值。使用的示例如下:
//代码方式的bean配置和注册
DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); //bean工厂
GenericBeanDefinition beanDefinition = new GenericBeanDefinition();//Bean定义
beanDefinition.setBeanClass(User.class); //设置bean定义类
beanDefinition.getPropertyValues().add("name", "${name}") //设置bean属性;使用占位符
bf.registerBeanDefinition("user", beanDefinition); //注册bean
//模拟环境并设置属性后,对bean中的占位符进行替换
PropertySourcesPlaceholderConfigurer pc = new PropertySourcesPlaceholderConfigurer();
pc.setEnvironment(new MockEnvironment().withProperty("name", "Oscar"));//设置环境属性
pc.postProcessBeanFactory(bf); //替换占位符
上面的示例代码中,使用GenericBeanDefinition类进行Bean的配置和注册,对应的Bean类是User,使用占位符${name}设置该类的属性值。接着创建MockEnvironment和设置了属性name的值,最后设置PropertySourcesPlaceholderConfigurer的环境为创建的模拟环境并替换占位符为模拟环境中设置的属性值。
2、JNDI的模拟
JNDI(Java Naming and Directory Interface),Java命名与目录接口。直观点理解就是给资源(比如数据库资源)一个通用的名字,通过这个名字就可以查找这个资源了。
在开发和测试时,数据源可以通过配置url、name和password在XML中配置,但在正式环境中,为了保障安全,数据源更多的是使用JNDI的方式配置在应用服务器上,而且除了数据源以外的其他资源,类似EJB等就必须使用JNDI的方式了。Spring提供了根据JNDI名称查找资源对象的类JndiObjectFactoryBean,以JNDI:“java:comp/env/jdbc/mydatasource”为例,通过XML配置数据源bean的方式如下:
<bean id="jndiDataSource" class="org.springframework.jndi.JndiObjectFactoryBean">
<property name="jndiName"> <!—JNDI属性定义
<value>java:comp/env/jdbc/mydatasource</value>
</property>
</bean>
现在问题是:测试的时候不希望开启应用服务器,怎么找到JNDI对应的资源呢?Spring提供了使用SimpleNamingContextBuilder来构造JNDI资源的模拟。以上面配置的数据源的JNDI模拟为例:
public void initForTest() throws IllegalStateException, NamingException { //模拟JNDI方法
DriverManagerDataSource ds = new DriverManagerDataSource(); //创建数据源
ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); //驱动类设置
ds.setUrl("jdbc:mysql://localhost:3306/ssmi?serverTimezone=UTC");//数据源url
ds.setUsername("root"); //用户名
ds.setPassword("123456"); //密码
SimpleNamingContextBuilder builder = new SimpleNamingContextBuilder();
builder.bind("java:comp/env/jdbc/mydatasource", ds); //绑定数据源
builder.activate();//激活
}
以上通过DriverManagerDataSource创建一个数据源,使用SimpleNamingContextBuilder给该资源绑定一个JNDI的名字。以上方法可以使用@Before注解后,读取包含以上JNDI的配置文件就可以使用模拟的数据源了。DB及数据源相关的部分会在后面章节深入介绍。
3、HTTP和Web相关的模拟
Spring提供了Http和Servlet的模拟对象类用于Web测试,相关部分的会在后面Spring Web测试中介绍,另外,还提供了Spring Web Reactive的响应式 Web测试的模拟对象,本书不做探讨。
Spring测试模块的共用方法
org.springframework.test.util包中的ReflectionTestUtils类,提供了基于反射机制的方法,可以修改变量、非公有的属性和访问非公有的方法,还可以调用生命周期回调方法。在基于Spring框架开发中,经常使用注解(包括@Autowired、@Inject和@Resource等)对私有的方法或属性进行依赖注入,这些私有的变量和方法不能直接获取和调用。举例来看,组件类Foo定义如下:
public class Foo { //组件类
@Autowired //自动装载注解
private String name; //字符串变量
@PostConstruct //组件初始化注解
private void onInit(){ //组件初始化回调方法
System.out.println("onInit... " + name);
}
@PreDestroy //组件销毁注解
private void onDestroy(){ //组件销毁方法回调
System.out.println("onDestroy... " + name);
}
}
该组件类使用@Autowired注解了一个私有的属性name,@PostConstruct、@PreDestroy分别注解了两个私有的回调方法。这种代码风格是实际开发中常见的风格,可是在单元测试中不启动Spring容器,无法得到依赖对象也无法执行注解的生命周期回调方法下如何测试呢?答案就是通过ReflectionTestUtils,测试代码如下:
@Test //测试方法注解
public void test () { //测试方法
Foo foo = new Foo(); //组件创建
ReflectionTestUtils.setField(foo, "name", "Oscar"); //设置私有变量的值
ReflectionTestUtils.invokeMethod(foo, "onInit"); //调用组件初始化方法
ReflectionTestUtils.invokeMethod(foo, "onDestroy"); //调用组件销毁方法
}
Spring测试框架
Spring测试框架在JUnit和TestNG框架之上进行扩展。在测试类似使用JUnit的@RunWith(JUnit4)或@ExtendWith(JUnit5)指定Spring对应的运行器扩展,就可以在测试类中很容易进行上下文的初始化和缓存。通过测试执行***,可以在测试类中使用依赖注入和事务管理等注解,也可以自定义注解,简化测试。
Spring测试框架使用
使用Spring框架开发的应用,更常使用的是对容器初始化之后的测试,也就是严格意义上的集成测试。Spring测试框架主要位于org.springframework.test.context包中,提供了通用的、注解驱动和集成测试支持。其无缝集成了JUnit和TestNG的测试框架。
以JUnit4为例,在Spring测试框架下编写测试类的步骤如下:
- 在测试类上使用@RunWith注解测试运行器。
- 使用@ContextConfiguration注解指定Spring的配置(可以是XML配置文件,也可以是配置类)。
- 装载需要的Bean并完成JUnit标签@Test注解的测试方法。
完整的代码实例如下:
@RunWith(SpringJUnit4ClassRunner.class) //运行器注解
@ContextConfiguration(locations="classpath:cn/osxm/ssmi/chp6/applicationContext.xml")//配置
public class SpringTest { //测试类
@Autowired //自动装载注解
private HelloService helloService;
@Test //测试方法注解
public void hello() { //测试方法
helloService.sayHello();
}
}
@RunWith是JUnit的注解,用于标注测试运行器类。Spring扩展了JUnit4的BlockJUnit4ClassRunner类, 额外实现了容器的测试上下文的初始化和维护(JUnit5使用的是@ExtendWith)。初始化容器依据的配置通过注解@ContextConfiguration指定。在测试类中也可以使用Spring容器依赖注入等(类似@Autowired)注解。这些类在使用Maven批量执行时,应用上下文(容器)会缓存,不需要重复创建,节省测试开销,加快了测试效率。
Spring测试框架原理
Spring提供的测试运行器SpringJUnit4ClassRunner会创建测试上下文管理类TestContextManager,TestContextManager主要维护测试上下文TestContext和测试执行的监听TestExecutionListener,TestContext维护了根据配置初始化的容器应用上下文ApplicationContext和测试类、方法、和异常等信息,TestExecutionListener则是测试类和方法执行前后的一些操作,这几个类的关系如图2所示。
图2 Spring测试框架的核心类及关系
对上面的测试框架的核心类说明如下:
- TestContextManager,测试上下文管理器类,在每次测试时都会创建。提供对测试上下文(TestContext) 实例的管理,还负责测试过程中更新TestContext的状态并代理到TestExecutionListener,用来监测测试的执行,在测试执行点向每个注册的TestExecutionListener发送信号事件。
- TestContext,测试上下文类,封装测试执行的上下文。提供访问容器和应用上下文的能力,并对applicationContext进行缓存。
- TestExecutionListener,测试执行***,与TestContextManager发布的测试事件进行交互,这个***就是注册到TestContextManager上的。提供依赖注入、事务管理等能力。
除了上面的核心类之外,还有以下一些重要的类:
- ContextLoader:负责根据配置加载 Spring 的 Bean 定义,以构建 applicationContext 实例对象。ContextLoader是Spring 2.5中引入的一个策略接口,用于为Spring TestContext Framework管理的集成测试加载ApplicationContext。
- SmartContextLoader:Spring 3.1中引入的ContextLoader接口的扩展。可以选择处理资源位置,带注释的类或上下文初始值设定项。支持按照profile加载。
@TestExecutionListeners注解使用在测试类上,用于指定需要的测试执行***。测试执行***用于实现类执行之前被执行或实现类的测试方法之前被执行测试执行之前进行一些额外的处理。Spring默认提供了TestExecutionListener的三个实现,包括:事务管理的TransactionalTestExecutionListener、依赖注入的DependencyInjectionTestExecutionListener和上下文检查的DirtiesContextTestExecutionListener。
- TransactionalTestExecutionListener:事务测试执行***,用于对事务进行管理,负责解析@Transaction、@NotTransactional 以及 @Rollback 等事务注解的注解。@Transaction 注解让测试方法工作于事务环境中。可以使用 @Rollback(false) 让测试方法返回前提交事务。而@NotTransactional 注解则可以让测试方法不工作于事务环境中。此外,还可以使用类或方法级别的 @TransactionConfiguration 注解改变事务管理策略。
- DependencyInjectionTestExecutionListener:依赖注入测试执行***,该***提供了自动注入的功能,它负责解析测试用例类中的@Autowried注解并完成依赖对象自动注入。
- DirtiesContextTestExecutionListener:脏上下文测试执行***,一般情况下测试方法并不会对Spring容器上下文造成破坏(比如改变Bean的配置信息等),如果某个测试方法确实会破坏Spring容器上下文,可以显式地为该测试方法添加 @DirtiesContext注解,以便Spring TestContext在测试该方法后刷新Spring容器的上下文,DirtiesContextTestExecutionListener***的工作就是解析 @DirtiesContext注解。
除了以上***外,也可以实现自己的***类完成需要的操作,自定义的***需要继承类org.springframework.test.context.support.AbstractTestExecutionListener,具体可以参照DependencyInjectionTestExecutionListener的实现
默认DependencyInjectionTestExecutionListener和TransactionalTestExecutionListener是开启的,也就是在测试类上不添加@TestExecutionListeners注解配置,就可以在测试类上直接使用@Autowried和@Transaction等注解。但是如果有自定义的新增注解的话,则需要把DependencyInjectionTestExecutionListener或是TransactionalTestExecutionListener加上,TransactionalTestExecutionListener可以不加。
接下来自定义一个注解@MyLogTestAnno,如果在测试方法中使用了此注解,则添加测试日志的功能为例,通过添加一个测试执行监听的实现MyLogTestExecutionListener来达成此功能。
注解定义类MyLogTestAnno:
@Retention(RetentionPolicy.RUNTIME) //运行时注解
@Target(ElementType.METHOD) //注解使用在方法上
@Inherited //允许继承
@Documented //注解包含在Java Doc中
public @interface MyLogTestAnno { //注解定义
public String logFileName();
}
日志测试执行监听实现类:
public class MyLogTestExecutionListener implements TestExecutionListener {
public void prepareTestInstance(TestContext testContext) throws Exception {//准备测试实例
}
public void beforeTestClass(TestContext testContext) throws Exception {//测试类之前执行
System.out.println("测试类之前执行");
}
public void afterTestClass(TestContext testContext) throws Exception { //测试类之后执行
System.out.println("测试类之后执行");
}
public void beforeTestMethod(TestContext testContext) throws Exception {//测试方法之前执行
System.out.println("测试方法之前执行");
MyLogTestAnno myLogTest = testContext.getTestMethod().getAnnotation(MyLogTestAnno.class); //获取注解
if (myLogTest == null) {
return;
}
String logfile = myLogTest.logFileName();
System.out.println("注解参入的参数"+logfile);
}
public void afterTestMethod(TestContext testContext) throws Exception {//测试方法之后执行
System.out.println("测试方法之后执行");
}
}
在完成beforeTestMethod()的接口方法,获取该方法上标注的注解,如果有找到@MyLogTestAnno,则执行日志相关的逻辑代码。
测试类中配置自定义测试执行监听类:
@RunWith(SpringJUnit4ClassRunner.class) //测试运行器注解
@ContextConfiguration(locations = { "classpath:cn/osxm/ssmi/chp6/applicationContext.xml" })
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class, TransactionalTestExecutionListener.class, MyLogTestExecutionListener.class,}) //测试执行***注解
public class MyExecutionListenerTest{ //测试类
@Autowired //自动装载注解
private UserController userController;
@Test //测试方法注解
@MyLogTestAnno(logFileName = "testLog.txt") //自定义注解使用,logFileName赋值
public void test() {
}
}
在@TestExecutionListeners注解中,指定自定义的***,需要注意将框架用于依赖和事务处理的一并加上。
从测试执行监听实现类的代码可以看到,可以在测试类或测试方法之前和之后进行预置或是善后操作。这个和 JUnit的@Before,@BeforeClass很类似,只是这里处理的是自定义的注解。
Spring测试框架的优点
Spring测试框架在不需要依赖应用服务器和其他的部署环境的状况下,可以进行基于容器的集成测试。Spring测试框架与主流的测试框架JUnit或TestNG的整合使用,学习成本较低。Spring测试框架还支持:
- 在测试类中使用依赖注入。
- 测试类的自动化事务管理。
- 使用各种注解,提高开发效率和代码简洁性。
除此,Spring测试框架还可以管理测试之间的Spring IoC容器缓存。特别是在Web应用中可以大大提高测试的方便性和效率性。
本文节选自《Spring+SpringMVC+MyBatis整合开发实战》,由兴趣的读者朋友可以关注后私信我获取这本书的pdf