一、背景
随着团队对单元测试建设的要求与推广,越来越多的项目开始接入了单元测试,但是随着单元测试的接入越来越多各种场景下的单元测试的问题也在不断的暴露出来,这就导致我们不但要和业务代码斗智斗勇,甚至还得和单测代码斗智斗勇,极大的降低了研发效率。在诸多问题中最根本的原因莫过于我们目前的项目都是基于 Spring 环境的应用,并且在单元测试的推广文档对单元测试的配置是启动了整个 Srping 上下文的,这其实导致了一个严重的问题,那就是我们基于 Spring 的单元测试严重违背了单元测试的环境无关、测试单元最小化原则,那么我们如何做到真正的环境无关、测试单元最小化呢?
下面我就结合自身以及团队经验来分析一下我们面临的问题以及相应的解决方案,让我们来看看在 Spring 环境下单元测试的最佳实践是怎么样的吧!
二、介绍
上面我们说到在 Spring 环境下我们的单元测试违背了环境无关、测试单元最小化原则,下面我们就来看一看违背原则的一些表象。
2.1 当前面临的问题
2.2 当前问题的根本原因
上面是我和团队内的同学遇到的比较典型的单元测试问题,这些问题其实在非 Spring 环境下(一些不需要类似于Spring这种复杂的工业级框架的简单工程)是比较少见的,在我们目前的用法中我们启动了整个 Spring 容器,这就导致我们在启动单测的时候我们的应用会去连接 OCTO(注册服务,占用本地端口启动服务等等)、连接 Cellar(如果工程中引入了Cellar)、连接 Squirrel(如果工程中引入了Squirrel)、连接 Lion(拉取配置中心的配置缓存在本地)、连接 Mafka(如果工程中引入了Mafka)、连接 Mysql(如果工程引入了Mysql)、连接 Crane(如果工程引入了Crane)等,如果我们引入的中间件有很多,那么我们在启动单元测试的时候这一系列的中间件我们的应用都要进行连接,我们都知道在工程实践中,我们引入的功能、中间件越多,那我们的稳定性将越差,这一点在单元测试中也是同样的。
所以,上面的问题我们都可以归纳为 Spring 容器启动过程中加载了过多的内容导致的,如果我们在运行单元测试的时候不去加载这么多的东西,只加载我们的单测必要的东西是不是就可以避免这些问题了呢?答案是肯定的,我们只让 Spring 加载必要的部分(即要测试的部分),然后 mock 掉依赖的部分,其余所有部分都不进行加载,这样我们就得到了一个环境无关的、测试单元最小化的单元测试了。在第四节我们会介绍具体的使用方式,让我们拭目以待吧!
三、收益
那么,我们解决了上面的问题后可以得到什么样的收益呢?
首先,收益最大的就是我们可以提升研发效率,比如我们在编写代码和单测的时候我们进行的测试通常都是一个一个的进行,如果我们每次进行单个方法的测试都从30秒缩短到3秒,那么我们的研发效率会有极大的提升。
其次,当我们彻底解决了单元测试的环境无关后,我们就可以从跟各种环境斗智斗勇的关系中脱离出来,解决了令人痛苦的环境问题,同时对新人也更加友好(新人在不清楚各种基础组件的情况下环境问题会非常要命)。
再其次,当我们我们的单元测试符合测试单元最小化的原则的时候,我们的单元测试会规避很多未知因素的干扰,一个测试单元有问题一定是这个单元相关的依赖或者自身的问题,再也不用愁眉苦脸的看着红色报错来猜测是不是哪个根本不需要的模块加载报错导致的单测启动失败了。
如上,我们可以发现解决了这些问题后对我们的研发效率、代码纠错等方面的帮助与收益还是非常明显的,无论是从短期还是长期收益来看这些问题都是非常值得去解决的。
四、最佳实践
4.1 如何最小化启动单元测试?
在第二节我们遇到的问题其实都可以归结为没有最小化启动单元测试,也就是说我们首先要解决的问题就是最小化启动 Spring 容器。我们可以思考一下,我们目前的大多数工程其实都是通过 SQL 与 Mysql 等数据库进行通信的上层应用,由于我们编写了很多 SQL,而且这些 SQL 也是需要进行单元测试的,所以我们确定了我们的单测需要使用数据库(我们不会连接外部的数据库,而是在引入一个内嵌在应用工程中的数据库来替代,比如我们在单测中使用的 H2),但是其他的大多数中间件我们其实都是简单的通过 API 接口的形式进行交互,这部分中间件在我们的单测阶段其实是完全没必要的对于这部分的测试是属于集成测试的范畴,所以我们在单测中完全可以 mock 掉它们或者按需引入(按需引入的前提条件是不依赖任何环境即可启动,例如我们用内嵌数据库 H2 来代替 Mysql,如果我们想测试 Redis 我们也可以用类似的思路,引入一个内嵌的 Redis,但是绝不能引入任何的外部 Redis),最终我们确定了我们 Spring 容器启动应该包含的内容:只有数据库相关的部分!下面我们看一看具体的代码。
首先我们看一下依赖的包
<!-- spring 环境的单元测试的核心包 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${当前工程 spring 的版本}</version>
<scope>test</scope>
</dependency>
<!-- 下文我们用到的 @Import 功能在该包里引入,虽然这是个 spring boot 的包,但是普通的 spring 项目也是可以使用的,没有任何问题 -->
<!-- 这里的版本我们需要注意下,如果我们用的是 Spring4.x 的版本,这里要引入 1.5.xx.RELEASE 的版本,推荐使用 1.5.22.RELEASE -->
<!-- 如果我们用的是 Spring5.x 的版本,这里要引入 2.3.xx.RELEASE 的版本,推荐使用 2.3.8.RELEASE -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<version>2.3.8.RELEASE</version>
<scope>test</scope>
</dependency>
单元测试基类:
/**
* 单元测试基类,基于Testng
*/
// Spring容器配置文件,我们是最小化启动,所以只加载DB的配置,用H2替换mysql
@ContextConfiguration("classpath:dataSource.xml")
// 使 @MockBean 和 @SpyBean 注解生效的配置,具体使用方式我们在4.3小节讲
@TestExecutionListeners(MockitoTestExecutionListener.class)
// Spring上下文清理配置,表示每个测试都重新清理一下Spring上下文,这样能够保证每个单测都是隔离的环境
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public abstract class BaseTest extends AbstractTestNGSpringContextTests {
}
/**
* 单元测试基类,基于Junit4
*/
// Junit4 启动器
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:dataSource.xml")
// 基于 Junit4 需要添加 DirtiesContextBeforeModesTestExecutionListener.class,DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class
@TestExecutionListeners({MockitoTestExecutionListener.class, DirtiesContextBeforeModesTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class BaseTest {
}
/**
* 单元测试基类,基于Junit5
*/
// Junit5 启动器
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "classpath:dataSource.xml")
// 基于 Junit5 需要添加 DirtiesContextBeforeModesTestExecutionListener.class,DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class
@TestExecutionListeners({MockitoTestExecutionListener.class, DirtiesContextBeforeModesTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class BaseTest {
}
上面 @ContextConfiguration 注解引入的 dataSource.xml 配置文件如下(如果大家的项目是多数据源的话只需要把所有的表都放在这一个数据源下即可,分库分表可以忽略掉):
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:p="http://www.springframework.org/schema/p"
xmlns="http://www.springframework.org/schema/beans" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd">
<jdbc:embedded-database id="h2TestDataSource" type="H2" database-name="gradeDataSource;DATABASE_TO_UPPER=false;MODE=MYSQL;">
<!-- 这里的 h2/init.sql 是我们要初始化表结构的文件 -->
<jdbc:script location="classpath:h2/init.sql"/>
</jdbc:embedded-database>
<!-- sqlSessionFactory for mybatis -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="h2TestDataSource"/>
<!-- 配置mybatis配置文件的位置 -->
<property name="configLocation" value="classpath:mybatis-config.xml"/>
<property name="mapperLocations">
<list>
<!-- 这里的 value 要替换成真实的 mybatis 的 mapper 文件地址 -->
<value>classpath:mapper/**/*DAO.xml</value>
</list>
</property>
</bean>
<bean id="sqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate">
<constructor-arg ref="sqlSessionFactory"/>
</bean>
<!-- 配置扫描Mapper接口的包路径 -->
<bean class="com.dianping.zebra.dao.mybatis.ZebraMapperScannerConfigurer">
<!-- 如果是多个包名可用",; \t\n"中任意符号分隔开,详见:MapperScannerConfigurer[269行] -->
<!-- 这里的 value 需要替换成当前项目的真实包路径 -->
<property name="basePackage" value="com.sankuai.meituan.banma.rider.gms.dao"/>
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/>
</bean>
<bean id="mybatisTransactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager"
p:dataSource-ref="h2TestDataSource"/>
<tx:annotation-driven transaction-manager="mybatisTransactionManager"/>
</beans>
dataSource.xml 配置文件需要放在 test/resources 目录下,我们在写单测的时候只需要继承上面经过改造后的 BaseTest 类即可。
最后我们还需要一个日志配置文件放在 test/resources 目录下,因为咱们这边的所有项目都改变了默认的日志配置文件的路径,当我们最小化单测的时候无法加载到日志配置文件,所以我们直接将 log4j2.xml 放到 test/resources 目录下,也就是 classpath 下
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="OFF">
<appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{DEFAULT} [%t] %-5p (%C{1}:%L) - %m%n"/>
</Console>
</appenders>
<loggers>
<root level="info">
<appender-ref ref="Console"/>
</root>
</loggers>
</configuration>
4.2 如何注入被依赖的Spring Bean?
由于我们使用最小化的 Spring 环境来启动我们的单元测试,所以我们的 Spring 容器中只有 DAO 相关的 Bean,而其他的所有 Bean 都需要我们手动引入,这样我们就得到了一个纯净的、最小化的、按需引入的 Spring 上下文。
我们先来看一种简单的情况,如果我们要测试的服务是 ServiceA,该服务依赖关系如下:
ServiceA 依赖 ServiceB 和 ADao
ServiceB 依赖 BDao
代码片段如下:
// ServiceA 代码片段
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
@Autowired
private ADao aDao;
}
// ServiceB 代码片段
@Service
public class ServiceB {
@Autowired
private BDao bDao;
}
我们要对 ServiceA 进行测试,由于 ServiceA 的所有依赖和间接依赖都是本地依赖,没有任何外部依赖,所以我们可以将其依赖的所有服务都加载到 Spring 上下文中,测试类代码如下:
@DBRider(dataSourceBeanName = "h2TestDataSource") //这个是DBRider的注解,与本次介绍内容无关,放在这里表示当前方案兼容该功能
@Import({ServiceB.class, ServiceA.class}) // 引入依赖到 Spring 上下文中的核心注解,使用该注解把所有需要加入到 Spring 上下文的 Bean 引入
public class ServiceATest extends BaseTest {
@Autowired
private ServiceA serviceA;
@Test
public void testServiceA() {
}
我们再来看一个稍微复杂一点的情况,如果我们要测试的服务还是 ServiceA,该服务依赖关系如下:
ServiceA 依赖 ServiceB 和 ADao
ServiceB 依赖 ServiceC 和 BDao
ServiceC 依赖 ServiceD
ServiceD 没有依赖
我们可以看到这个例子要比上面的例子的依赖层级要深一些,这里是4层依赖关系(从ServiceA为第一层算起),这里虽然比上面的例子要复杂一些,但是解决方案还是一样的,我们只需要使用 @Import 注解将所有本地依赖全部引入到当前的 Spring 上下文中即可。代码如下:
@DBRider(dataSourceBeanName = "h2TestDataSource") //这个是DBRider的注解,与本次介绍内容无关,放在这里表示当前方案兼容该功能
@Import({ServiceA.class, ServiceB.class, ServiceC.class, ServiceD.class}) // 引入依赖到 Spring 上下文中的核心注解,使用该注解把所有需要加入到 Spring 上下文的 Bean 引入
public class ServiceATest extends BaseTest {
@Autowired
private ServiceA serviceA;
@Test
public void testServiceA() {
}
}
这里大家可能会担心如果我们的依赖层级特别深,依赖的 Bean 特别多,那岂不是 @Import 的时候会特别麻烦?其实不然,通常我们的依赖层级都是在3到4级左右,依赖的 Bean 也是在10个以内,如果依赖层级、依赖 Bean 太多的话那就要思考一下服务拆分的是否合理了,是否满足单一职责原则了,所以这也算是提供了一个服务拆分是否合理的自我检查方法。
补充:由于 @Import 注解需要我们自己梳理 Spring 依赖关系,导致效率比较低,所以我写了一个 @AutoImport 注解来简化流程,具体使用细节看补充5.1
4.3 如何 Mock 掉外部依赖并改变其行为?
首先,我们需要引入一个 Spring 官方的 mock 功能的依赖和 Mockito 的依赖:
<!-- 这里推荐使用的版本号是 3.8.0,如果出现不兼容的情况可以适当降低版本 -->
<!-- 这里注意下我们可以选择引入 mockito-core 还是 mockito-inline,这是因为 mockito-core 默认是不支持 mock final类/方法和static方法的 -->
<!-- 所以如果我们想要 mock final类/方法和static方法需要引入 mockito-inline,但是目前 mockito-inline 与 jmockit 是有些冲突的,所以目前推荐引入 mockito-core -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<!-- <artifactId>mockito-inline</artifactId> -->
<version>3.8.0</version>
<scope>test</scope>
</dependency>
<!-- 由于 mockito-inline 与 jmockit 兼容性的一些问题,可以使用下面的方式使编辑器环境下的 mvn test 可用,但是脱离编辑器环境的 mvn test 依然不可用 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<!-- 添加如下参数来保证编辑器环境 mvn test 命令成功执行 -->
<additionalClasspathElements>
<additionalClasspathElement>${java.home}/../lib/tools.jar</additionalClasspathElement>
</additionalClasspathElements>
</configuration>
</plugin>
这里我们要说明一下为什么团队推广单元测试的时候推荐的 Mock 工具是 Jmockit 而我们引入的是 Mockito,首先 Spring 官方推荐使用的 Mock 框架就是 Mockito,所以 Spring 官方在只提供了 Mockito 的 Mock 支持,所以我们可以非常简单的只用 @MockBean 和 @SpyBean 注解就可以解决非常复杂的 Spring 上下文自动注入 Mock 对象的问题,如果我们要使用 Jmockit 那我们就得自己写这个实现,本着不重复造轮子的原则,并且 Mockito 与 Jmockit 并不会造成项目冲突导致无法启动,所以我们可以引入两个 Mock 框架,按需使用。
下面就是介绍单元测试的重中之重,堪称单元测试的灵魂之中的灵魂的 Mock 了,Mock 主要分为两类,我会通过几个例子和大家介绍一下。
首先,如我们想要使 mock 功能生效的话需要在 BaseTest 上加一个注解,在上文中我们也提到过
/**
* 单元测试基类,基于Testng
*/
@ContextConfiguration("classpath:dataSource.xml")
// 使 @MockBean 和 @SpyBean 注解生效的配置
@TestExecutionListeners(MockitoTestExecutionListener.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public abstract class BaseTest extends AbstractTestNGSpringContextTests {
}
/**
* 单元测试基类,基于Junit4
*/
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:dataSource.xml")
@TestExecutionListeners({MockitoTestExecutionListener.class, DirtiesContextBeforeModesTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class BaseTest {
}
/**
* 单元测试基类,基于Junit5
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "classpath:dataSource.xml")
@TestExecutionListeners({MockitoTestExecutionListener.class, DirtiesContextBeforeModesTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class BaseTest {
}
第一类 Mock 是使用 @MockBean 注解,该注解会创建一个所有方法都返回 null 的对象来 mock 原来的对象,适合用来 mock 外部依赖,然后配合方法打桩来改变原有方法的行为,举个例子
我们要测试的服务是 ServiceA 依赖了 ThriftIfaceB 的 methodB 方法,ThriftIfaceB 是一个外部的 RPC 接口,此时我们需要 mock 这个接口来保证我们的单测是环境隔离的,代码如下
// ServiceA 代码片段
@Service
public class ServiceA {
@Autowired
private ThriftIfaceB thriftIfaceB;
public Integer methodA(){
Param param = new Param();
Result result = thriftIfaceB.methodB(param);
return result.getData();
}
}
// 测试类代码
@DBRider(dataSourceBeanName = "h2TestDataSource") //这个是DBRider的注解,与本次介绍内容无关,放在这里表示当前方案兼容该功能
@Import({ServiceA.class}) // 引入依赖到 Spring 上下文中的核心注解,使用该注解把所有需要加入到 Spring 上下文的 Bean 引入
public class ServiceATest extends BaseTest {
@Autowired
private ServiceA serviceA;
// 当我们加上这段代码之后,当前启动的 Spring 容器中就生成了一个 name = thriftIfaceB 的 Mock Bean,
// 然后该容器中所有的依赖注入 ThriftIfaceB 的 Bean 都注入了我们指定的这个 Mock Bean,此时我们就可以
// 使用 Mockito 的打桩来任意修改该 Mock Bean 的方法的行为了,没有修改的行为默认返回 null
@MockBean(name = "thriftIfaceB")
private ThriftIfaceB thriftIfaceB;
@Test
public void testServiceA() {
Result result = new Result();
result.setData(1);
// 这就是我们上文所说的打桩(stub),这段代码的意思就是我们改变 thriftIfaceB 这个对象的 methodB 方法的行为,变成我们想要
// 的样子,这样在我们测试 serviceA.methodA() 时,内部调用 thriftIfaceB.methodB(param) 的时候就会按照我们期望的样子进行返回
// 这个打桩函数的内容有很多,基本大家想用的都可以实现,此处就不一一介绍了,大家感兴趣可以自己查看 API
Mockito.when(thriftIfaceB.methodB(Mockito.any())).thenReturn(result);
Integer resultData = serviceA.methodA();
Assert.assertEquals(resultData, 1);
}
}
第二类 Mock 是使用 @SpyBean 注解,该注解会创建一个所有方法都调用原方法的 mock 对象,适合用来 mock 内部依赖,然后配合方法打桩来改变原有方法的行为,举个例子
比如我们想要测试 ServiceA 的 methodA 的事务是否生效,我们就可以使用该注解来实现,因为我们想要某一个方法抛出异常,二其他的所有方法都保持原样正常运行,所以我们可以用 @SpyBean 注解来解决这个问题
被测试类 ServiceA 依赖 DaoA 与 DaoB,DaoA 的 insertA 方法与 DaoB 的 insertB 方法在 ServiceA 的 methodA 方法中,并在一个事务下,代码如下
// ServiceA 代码片段
@Service
public class ServiceA {
@Autowired
private DaoA daoA;
@Autowired
private DaoB daoB;
@Transaction(rollbackFor = Exception.class)
public void methodA(){
A a = new A();
daoA.insertA(a);
B b = new B();
daoB.insertB(b);
}
}
// 测试类代码
@DBRider(dataSourceBeanName = "h2TestDataSource") //这个是DBRider的注解,与本次介绍内容无关,放在这里表示当前方案兼容该功能
@Import({ServiceA.class}) // 引入依赖到 Spring 上下文中的核心注解,使用该注解把所有需要加入到 Spring 上下文的 Bean 引入
public class ServiceATest extends BaseTest {
@Autowired
private ServiceA serviceA;
// 当我们加上这段代码之后,当前启动的 Spring 容器中就生成了一个 name = daoB 的 Spy Bean,
// 然后该容器中所有的依赖注入 daoB 的 Bean 都注入了我们指定的这个 Spy Bean,此时我们就可以
// 使用 Mockito 的打桩来任意修改该 Spy Bean 的方法的行为了,没有修改的行为默认调用原对象的方法
@SpyBean(name = "daoB")
private DaoB daoB;
@Test
public void testServiceA() {
Result result = new Result();
result.setData(1);
// 这里我们使用 do.when 的方式进行打桩,在上一个例子里用了另一种打桩方式,我们可以称其为 when.then 的打桩方式,
// 两种方式的 API 基本一样,但是使用效果有两点区别,下面会详细介绍
Mockito.doThrow(new RuntimeException("transaction rollback")).when(daoB).insert(Mockito.any());
try{
serviceA.methodA();
} catch (RuntimeException e){
if (!e.getClass().equals(RuntimeException.class) || Objects.equals(e.getMessage(), "transaction rollback")){
throw e;
}
}
A a = serviceA.queryAById(1);
Assert.assertNull(a);
}
}
在上面的两个例子中我们使用了两种打桩方式,第一种我们称之为 when.then 的方式,第二种我们称之为 do.when 的方式,这两种方式的核心区别在于两点
① do.when 不是类型安全的(不会进行编译期返回类型校验,比如使用 doReturn 的时候),这可能带来意想不到的失败
② when.then 的方式打桩时会调用一次原方法(在使用@SpyBean注解时会先执行一次并造成影响,不过此场景使用概率极低,所以一般不会遇到),do.when 的方式打桩的话不会调用原方法
举个例子:上面我们使用 @SpyBean 来 mock 的 daoB,如果我们使用 when.then 的方式打桩的话,其实 daoB.insert() 方法还是会被真实的调用一次,数据也写入到 DB 中了,然后才会执行打桩方法抛出异常回滚,但是如果我们使用 do.when 的方式打桩的话 daoB.insert() 就不会被调用,数据也不会写入 DB,而是直接执行打桩方法抛出异常回滚,所以我们需要根据是否需要调用原方法来选择到底使用哪种打桩方式。
额外说一句,其实我个人是推荐完全使用 Mockito 来替换 Jmockit 的,我推荐的理由有两点
1.Mockito 的社区活跃度完爆 Jmockit,并且 Jmockit 已经一年多没有更新了,感觉要凉凉的节奏
2.我们的项目都是依赖于 Spring 的,但是 Spring 对 mock 的支持方面仅支持了 Mockito,并没有支持 Jmockit
4.4 如何迁移历史项目的单元测试?
如果我们是一个全新的项目的话那肯定不用多说了,我是强烈推荐这种方式进行单元测试的,相信我,你会爱上这种简单、便捷、不用与代码斗智斗勇的单元测试方式的!
如果是历史项目呢?那其实是有一定的改造成本的,因为我们需要为每个单元测试划清边界,要找到他的依赖树然后将本地依赖直接 @Import,外部依赖 @MockBean,所以还是有一些改造成本的,所以我给大家想了个办法,那就是慢慢的迭代迁移,比如我们做某个项目的时候要修改或者新增某个 Service 的单测,这时候我们就可以使用我们上文讲到的方案对该测试对象进行改造,我们可以增加一个单测基类,然后逐步改造,这样成本最低,也最容易被大家接受,测试基类名字我都为大家想好了就叫 SmartBaseTest(这个名字就充满了机智,哈哈哈哈),代码和上面的 BaseTest一样,不过基于 Testng、Junit4 和 Junit5 的实现方案有些许差别,代码如下:
/**
* 单元测试基类,基于Testng
*/
@ContextConfiguration("classpath:dataSource.xml")
@TestExecutionListeners(MockitoTestExecutionListener.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public abstract class SmartBaseTest extends AbstractTestNGSpringContextTests {
}
/**
* 单元测试基类,基于Junit4
*/
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:dataSource.xml")
@TestExecutionListeners({MockitoTestExecutionListener.class, DirtiesContextBeforeModesTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class SmartBaseTest {
}
/**
* 单元测试基类,基于Junit5
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "classpath:dataSource.xml")
@TestExecutionListeners({MockitoTestExecutionListener.class, DirtiesContextBeforeModesTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class SmartBaseTest {
}
五、补充
5.1 @Import 引入依赖太繁琐?快使用 @AutoImport 替代吧~
首先,引入依赖包
<dependency>
<groupId>io.github.bigwg</groupId>
<artifactId>easy-spring-test</artifactId>
<version>1.0.1</version>
<scope>test</scope>
</dependency>
然后使用 @AutoImport 注解替代 @Import 注解即可,举个例子
// 改造前
@Slf4j
@DBRider(dataSourceBeanName = "h2TestDataSource")
// 待测试的 service 类是 MentoringMentorService 但是由于该类依赖或间接依赖了很多其他 service,所以需要手动梳理依赖的 Spring Bean 并引入上下文中,流程比较繁琐,导致易用性降低
@Import({RiderDomainService.class, MentoringMentorService.class, MentoringMentorHelper.class, MentoringCommonService.class,
MentoringApprenticeService.class, MentoringApprenticeHelper.class})
public class MentoringMentorServiceTest extends BaseTest {
// 注入待测试的 service
@Autowired
private MentoringMentorService mentorService;
}
// 改造后
@Slf4j
@DBRider(dataSourceBeanName = "h2TestDataSource")
// 使用 @AutoImport 替代后,仅需要 MentoringMentorService 即可,@AutoImport 注解会自动检索依赖树,然后将依赖的 Spring Bean 引入上下文中
@AutoImport({MentoringMentorService.class})
public class MentoringMentorServiceTest extends BaseTest {
// 注入待测试的 service
@Autowired
private MentoringMentorService mentorService;
}
注意:由于是利用业务时间写的小工具,目前还没有经过大量的场景验证,所以可能在某些场景下存在点小问题,源码已经放在 Github 上,有问题可以提 Issue
六、参考资料
Springboot单元测试:SpyBean vs MockBean
Spring Boot Test官方文档
Mockito 官网
Mockito: doReturn vs thenReturn
Github issue: AttachNotSupportedException: no providers installed
Maven Surefire 插件官方文档
|