脱离数据库和Spring的Unit Test

目录

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 {

1
2
3
4
5
6
7
@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属性

这种方式代码较多,配置繁琐,于是Spring 2.5 引入了 @Autowired 注释。

@Autowired的原理

在启动spring IOC时,容器自动装载了一个AutowiredAnnotationBeanPostProcessor后置处理器,当容器扫描到@Autowied、@Resource或@Inject时,就会在IOC容器自动查找需要的bean,并装配给该对象的属性

注意事项:

1、在使用@Autowired时,会先在IOC容器中查询要自动引入的对应类型的bean

1
   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脱离数据库

首先放上正确的单元测试例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//@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在这里已经包含

1
2
3
4
5
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

到这里你需要一点Mock的基础,Mock就是模拟一切操作数据库的步骤,不执行任何SQL,我们直接模拟这句操作数据库的代码执行时成功的,而且可以模拟任何返回值,主要有两个注解

@MockBean
只要是本地的,自己写的bean,都可以使用这个注解,它会把所有操作数据库的方法模拟。如果是没有返回值的方法,我们就可以不管。如果是有返回值的方法,我们可以给它返回各自我们需要模拟的值。用法如下:

1
2
3
4
         // any()代替任意类型的参数
        Mockito.doReturn("我是模拟的返回值").when(em).findById( any());
        // 没有返回值的方法,可以不另外写,因为模拟实体类的时候已经自动模拟了
        Mockito.doNothing().when(em).find(any());

@SpyBean
如果是我们本地,调用别的公司,别的地方给我们写好的接口,不是操作我们自己的数据库,是我们写好入参,别人给我们返回值,我们就用这个。它的用法和@MockBean一样

二者的主要用法区别:

MockBean 适用本地,模拟全部方法
SpyBean适用远程不同环境, 只模拟个别方法
然后我们这里Mock的是JPA官方的EntityManager,对于官方的接口、类在我们的实现类里面作为private属性来操作数据库,我们可以通过这个方法来模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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注解就可以了,一般都是用这个,如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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