目录
1、一般的单元测试写法
2、单元测试步骤
3、对一般的单元测试写法分析优化
4、最佳的单元测试写法:Mock脱离数据库+不启动Spring+优化测试速度+不引入项目组件
原文作者:FromNowOnUntilTheEnd
原文连接:https://blog.csdn.net/qq_36688143/article/details/97393949
一、普遍的单元测试方法
作为一个Java后端程序员,肯定需要写单元测试。我先提供一个典型的错误的单元测试例子:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
@Transactional
@Rollback(true) // 事务自动回滚,默认是true。可以不写
public class HelloServiceTest {
@Autowired
private HelloService helloService;
@Test
public void sayHello() {
helloService.sayHello("zhangsan");
}
这个例子错误点有4个:(本文的错误统一指不标准,实际上这样子写单元测试也可以,只是不规范,显示不出在座各位优秀的编程能力)
1、@Autowired启动了Spring
2、@SpringBootTest启动了SpringBoot环境,而classes = Application.class启动了整个项目
3、通过@Transactional可以知道调用了数据库
4、没有Assert断言
二、一般的错误的单元测试步骤(SpringBoot环境下)
1、使用@RunWith(SpringRunner.class)声明在Spring的环境中进行单元测试,这样Spring的相关注解就会被识别并起效
2、然后使用@SpringBootTest,它会扫描应用程序的spring配置,并构建完整的Spring Context。
3、通过@SpringBootTest我们可以指定启动类,或者给@SpringBootTest的参数webEnvironment赋值为SpringBootTest.WebEnvironment.RANDOM_PORT,这样就会启动web容器,并监听一个随机的端口,同时,为我们自动装配一个TestRestTemplate类型的bean来辅助我们发送测试请求。
如果项目稍微复杂一点,像SpringCloud那样多模块,还使用了缓存、分片、微服务、集群分布式等东西,然后电脑配置再差一点,那你每执行一次单元测试的启动-运行-测试时间,漫长得够你去喝杯茶再回来了。
或者你的项目使用了@Component注解(在SpringBoot项目启动的时候就会跟着实例化/启动)
启动类上也定义了启动时就实例化的类
这个@Component注解的类里有多线程方法,随着启动类中定义的ApplicationStartup类启动了,那么在你执行单元测试的时候,由于多线程任务的影响,就可能对你的数据库造成了数据修改,即使你使用了事务回滚注解@Transactional。我出现的问题是:在我运行单元测试的时候,代码里的其他类的多线程中不停接收activeMQ消息,然后更新数据库中对应的数据。跟单元测试的执行过程交叉重叠,导致单元测试失败。其他组员在操作数据库的时候,也因为我无意中带起的多线程更改了数据库,造成了开发上的困难。
另外附带@Component源码,顺便学习一下
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Indexed
public @interface Component {
//这个值可能作为逻辑组件(即类)的名称,在自动扫描的时候转化为spring bean,
//即相当中的id
String value() default “”;
}
@Component是一个元注解,意思是可以注解其他类注解,如@Controller @Service @Repository @Aspect。官方的原话是:带此注解的类看为组件,当使用基于该注解的配置和类路径扫描的时候,这些类就会被实例化。其他类级别的注解也可以被认定为是一种特殊类型的组件,比如@Repository @Aspect。所以,@Component可以注解其他类注解。
三、优化单元测试写法
我先来上图,这样子写单元测试运行一次所需要的时间。然后我们通过对比,得出编写最佳单元测试的方法。我这个6年前的笔记本,运行一次单元测试,需要差不多1分钟,而经过代码优化,只需要几秒钟。下面是优化方式:
首先,我们要明确单元测试的终极目标,就是完全脱离数据库!完全脱离数据库!完全脱离数据库!其次,单元测试是只针对某一个类的一个方法(一个小的单元)来测,在测试过程中,我们不要启动其它东西,要脱离项目中其它因素可能产生的干扰。
所以可以发现上面的例子简直是侮辱了单元测试,最初级的入门的学生才这样写。众所周知,现在看到这里的各位都是架构师的能力,接下来我们一行行代码,一秒五喷,严厉抨击这段错误的单元测试:
1、不应使用@Autowired
@Autowired
private HelloService helloService;
这个@Autowired简直是画蛇添足!就是这个东西启动了Spring。以前没有@Autowired的时候,我们需要这样配置bean属性
@Autowired的原理
在启动spring IOC时,容器自动装载了一个AutowiredAnnotationBeanPostProcessor后置处理器,当容器扫描到@Autowied、@Resource或@Inject时,就会在IOC容器自动查找需要的bean,并装配给该对象的属性
注意事项:1、在使用@Autowired时,会先在IOC容器中查询要自动引入的对应类型的bean
2、如果查询结果刚好为一个,就将该bean装配给@Autowired指定的属性值
3、如果查询的结果不止一个,那么@Autowired会根据属性名来查找。
4、如果查询的结果为空,那么会抛出异常。解决方法:使用required=false
那么问题就来了,我们只是要写单元测试,为什么要启动Spring呢?首先,启动Spring只会让你run->Junit Test的时候程序变慢,这是每次运行单元测试都很慢的原因之一。然后单元测试是只针对某一个类的方法来测,启动Spring完全是多余的,所以我们只需要对应的实体类实例就够了。在需要注入bean的时候,我们直接new,如下
@Autowired
private HelloService helloService;
改为:
private HelloService helloService = new HelloServiceImpl();
// 这个HelloServiceImpl是你每个接口的对应实现类
2、不应使用@SpringBootTest
@SpringBootTest(classes = Application.class)
这个@SpringBootTest简直犯罪有木有!它就是每次运行单元测试都很慢的罪魁祸首,相信我,把它删掉你的单元测试速度会快的飞起。@SpringBootTest和@Autowired一样,在单元测试里面是完全多余的,根本就不搭边的两个东西!每次单元测试都先启动SpringBoot
然后我们来看一下@SpringBootTest的源码
大概意思:
1、@SpringBootTest是在SpringBoot项目上使用的,它在@SpringBootContextLoader的基础上,配置文件属性的读取。
2、在常规Spring TestContext框架之上提供以下特性:
1)当定义没有特定的@ContextConfiguration(loader=…)时,使用SpringBootContextLoader作为默认的ContextLoader。ContextLoader的作用:实际上由ContextLoaderListener调用执行根应用上下文的初始化工作。
2)当不使用嵌套@Configuration时,自动搜索@SpringBootConfiguration,并且没有指定显式的类。
3)允许使用properties属性定义自定义环境属性。
4、为不同的webEnvironment模式提供支持,包括启动一个完全运行的web服务器,监听一个已定义的或随机的端口。
5)注册一个TestRestTemplate或WebTestClient bean,用于在web测试中使用完全运行的web服务器。
使用方式
@SpringBootTest(classes = Application.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
现在一般写成这样
@SpringBootTest(classes = Application.class)
或者这样
@SpringBootTest
但不管写成怎样,这个注解都不该用
classes = Application.class指定启动类,在执行这里的时候,会读取、解析一些项目配置文件,还会连接数据库,然后如果启动类又带有别的启动类、@Component、多线程等,在你执行单元测试的时候,程序不止运行慢,时间长,而且由于多线程任务的影响,就可能对你的数据库造成了数据修改,即使你使用了事务回滚注解@Transactional。
3、不应调用数据库
@Transactional
@Rollback(true) // 事务自动回滚,默认是true。可以不写
单元测试的目标,就是完全脱离数据库!这个注解如果使用,就是完全背道而驰了,一般使用了这个注解的单元测试,脱离数据库后很多都会执行报错
4、应使用Assert断言
Assert断言的使用方式,可以看这篇博客:单元测试中Assert断言的使用
那么我们到底应该如何写单元测试呢?
四、正确的单元测试写法:Mock脱离数据库
首先放上正确的单元测试例子
//@SpringBootTest
//@SpringBootTest(classes = Application.class)
// 在启动类启动的时候也启动了这个类,所以也要引入进来
//@Import(ApplicationStartup.class)
// 不执行项目里Component注解过的方法
//@TestComponent
// 注意点一:保留了RunWith注解
@RunWith(SpringRunner.class)
public class HelloServiceTest {
//@Autowired
// 不使用Autowired,不启动Spring容器,对需要实现的方法实现类直接new进行实例化
private HelloService helloService = new HelloServiceImpl();
@Test
public void sayHello() {
// 模拟JPA的EntityManager,官方的接口、类都要模拟
EntityManager em = init(helloService);
// any()代替任意类型的参数
Mockito.doReturn("我是模拟的返回值").when(em).findById( any());
// 没有返回值的方法,可以不另外写,因为模拟实体类的时候已经自动模拟了
Mockito.doNothing().when(em).find(any());
helloService.sayHello("zhangsan");
Assert.isTrue(true,"完全正确的单元测试");
}
EntityManager init(Object classInstance ){
// 要模拟的类
EntityManager em = Mockito.mock(EntityManager.class);
// 指定反射类
Class<?> clazz = classInstance.getClass();
// 获得指定类的属性
Field field = null;
try {
field = clazz.getDeclaredField("em");
// 值为 true 则指示反射的对象在使用时应该取消 Java 语言访问检查。
// 值为 false 则指示反射的对象应该实施 Java 语言访问检查。
// 默认 false
field.setAccessible(true);
// 更改私有属性的值
field.set(classInstance, em);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
return em;
}
}
// HelloServiceImpl是实现类,以下代码只是为了表达意思,它的sayHello方法代码为
class HelloServiceImpl {
@Autowired
private EntityManager et;
sayHello(String name) {
// 没有返回值的操作数据库的方法
et.find(name);
// 有返回值的方法
String oldSecondName = et.findById(name.substring(2));
}
}
可以看到保留了@RunWith注解
1、@RunWith 在JUnit中有很多个Runner,他们负责调用你的测试代码,每一个Runner都有各自的特殊功能,你要根据需要选择不同的Runner来运行你的测试代码。一般都是使用SpringRunner.class
2、如果我们只是简单的做普通Java测试,不涉及Spring Web项目,你可以省略@RunWith注解,这样系统会自动使用默认Runner来运行你的代码。
然后最主要的就是Mock了,Mock所需的jar在这里已经包含
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
到这里你需要一点Mock的基础,Mock就是模拟一切操作数据库的步骤,不执行任何SQL,我们直接模拟这句操作数据库的代码执行时成功的,而且可以模拟任何返回值,主要有两个注解
@MockBean
只要是本地的,自己写的bean,都可以使用这个注解,它会把所有操作数据库的方法模拟。如果是没有返回值的方法,我们就可以不管。如果是有返回值的方法,我们可以给它返回各自我们需要模拟的值。用法如下:
// any()代替任意类型的参数
Mockito.doReturn("我是模拟的返回值").when(em).findById( any());
// 没有返回值的方法,可以不另外写,因为模拟实体类的时候已经自动模拟了
Mockito.doNothing().when(em).find(any());
@SpyBean
如果是我们本地,调用别的公司,别的地方给我们写好的接口,不是操作我们自己的数据库,是我们写好入参,别人给我们返回值,我们就用这个。它的用法和@MockBean一样
二者的主要用法区别:
MockBean 适用本地,模拟全部方法
SpyBean适用远程不同环境, 只模拟个别方法
然后我们这里Mock的是JPA官方的EntityManager,对于官方的接口、类在我们的实现类里面作为private属性来操作数据库,我们可以通过这个方法来模拟
EntityManager init(Object classInstance ){
// 要模拟的类
EntityManager em = Mockito.mock(EntityManager.class);
// 指定反射类
Class<?> clazz = classInstance.getClass();
// 获得指定类的属性
Field field = null;
try {
field = clazz.getDeclaredField("em");
// 值为 true 则指示反射的对象在使用时应该取消 Java 语言访问检查。
// 值为 false 则指示反射的对象应该实施 Java 语言访问检查。
// 默认 false
field.setAccessible(true);
// 更改私有属性的值
field.set(classInstance, em);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
return em;
}
如果你的项目没有这么复杂,你只需要在你想要模拟的类头顶加上这个@MockBean注解就可以了,一般都是用这个,如
public class HelloServiceTest {
//@Autowired
// 不使用Autowired,不启动Spring容器,对需要实现的方法实现类直接new进行实例化
private HelloService helloService = new HelloServiceImpl();
@MockBean
HelloDao dao;
@Test
public void sayHello() {
// any()代替任意类型的参数
Mockito.doReturn("我是模拟的返回值").when(dao).findById( any());
// 没有返回值的方法,可以不另外写,因为模拟实体类的时候已经自动模拟了
Mockito.doNothing().when(dao).find(any());
helloService.sayHello("zhangsan");
Assert.isTrue(true,"完全正确的单元测试");
}
这段代码可能跟上面有点不通,我随手敲的,我要表达的就是:如果你不需要模拟官方的接口、类来操作数据库,那你直接在你的实现类头顶加@MockBean或者@SpyBean注解,然后使用Mockito语法就可以了。
你懂我的意思吧?
部分内容参考:
https://blog.csdn.net/fxbin123/article/details/80617754
https://www.jianshu.com/p/72b19e24a602
https://blog.csdn.net/lycyl/article/details/82865009