如何写好单元测试:Mock脱离数据库+不使用@SpringBootTest「建议收藏」

2022-08-14 16:38:27 浏览数 (1)

大家好,又见面了,我是你们的朋友全栈君。

2022年03月25日更新:觉得没必要Mock的人,估计是没做过多个团队合作的项目,没经历过服务间的调用的。没关系,人总是会长大的。如果你以后接触到了,会感谢现在看到这文章的你。

注意:如果下述内容有说连数据库的单元测试错误,那就是我的错。因为多年不做单机项目了,都是多服务,UT都是mock的。

如果你有不同意见,不要怀疑,你是对的,我是错的。

补充:当代码里有new 对象的时候PowerMockito.whenNew(entityDao.class).withAnyArguments().thenReturn(entity);

void方法可以使用donothing

目录

1、一般的单元测试写法

2、单元测试步骤

3、对一般的单元测试写法分析优化

4、最佳的单元测试写法:Mock脱离数据库 不启动Spring 优化测试速度 不引入项目组件

一、普遍的单元测试方法

作为一个Java后端程序员,肯定需要写单元测试。我先提供一个典型的单元测试例子:

代码语言:javascript复制
@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源码,顺便学习一下

代码语言:javascript复制
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Indexed
public @interface Component {
    //这个值可能作为逻辑组件(即类)的名称,在自动扫描的时候转化为spring bean,
    //即相当<bean id="" class="" />中的id
    String value() default "";
}

@Component是一个元注解,意思是可以注解其他类注解,如@Controller @Service @Repository @Aspect。官方的原话是:带此注解的类看为组件,当使用基于该注解的配置和类路径扫描的时候,这些类就会被实例化。其他类级别的注解也可以被认定为是一种特殊类型的组件,比如@Repository @Aspect。所以,@Component可以注解其他类注解。

三、优化单元测试写法

我先来上图,这样子写单元测试运行一次所需要的时间。然后我们通过对比,得出编写最佳单元测试的方法。我这个6年前的笔记本,运行一次单元测试,需要差不多1分钟,而经过代码优化,只需要几秒钟。下面是优化方式:

首先,我们要明确单元测试的终极目标,就是完全脱离数据库完全脱离数据库完全脱离数据库!其次,单元测试是只针对某一个类的一个方法(一个小的单元)来测,在测试过程中,我们不要启动其它东西,要脱离项目中其它因素可能产生的干扰。

所以可以发现上面的例子简直是侮辱了单元测试,最初级的入门的学生才这样写。众所周知,现在看到这里的各位都是架构师的能力,接下来我们一行行代码,一秒五喷,严厉抨击这段错误的单元测试:

1、不应使用@Autowired

代码语言:javascript复制
@Autowired
private HelloService helloService;

这个@Autowired简直是画蛇添足!就是这个东西启动了Spring。以前没有@Autowired的时候,我们需要这样配置bean属性

代码语言:javascript复制
<property name="属性名" value=" 属性值"/>

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

@Autowired的原理

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

代码语言:javascript复制
<bean class="org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor"/> 

注意事项:

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

2、如果查询结果刚好为一个,就将该bean装配给@Autowired指定的属性值

  3、如果查询的结果不止一个,那么@Autowired会根据属性名来查找。

  4、如果查询的结果为空,那么会抛出异常。解决方法:使用required=false

那么问题就来了,我们只是要写单元测试,为什么要启动Spring呢?首先,启动Spring只会让你run->Junit Test的时候程序变慢,这是每次运行单元测试都很慢的原因之一。然后单元测试是只针对某一个类的方法来测,启动Spring完全是多余的,所以我们只需要对应的实体类实例就够了。在需要注入bean的时候,我们直接new,如下

代码语言:javascript复制
@Autowired
private HelloService helloService;

改为:

private HelloService helloService = new HelloServiceImpl();

// 这个HelloServiceImpl是你每个接口的对应实现类

2、不应使用@SpringBootTest

代码语言:javascript复制
@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服务器。

使用方式

代码语言:javascript复制
@SpringBootTest(classes = Application.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

现在一般写成这样
@SpringBootTest(classes = Application.class)

或者这样
@SpringBootTest

但不管写成怎样,这个注解都不该用

classes = Application.class指定启动类,在执行这里的时候,会读取、解析一些项目配置文件,还会连接数据库,然后如果启动类又带有别的启动类、@Component、多线程等,在你执行单元测试的时候,程序不止运行慢,时间长,而且由于多线程任务的影响,就可能对你的数据库造成了数据修改,即使你使用了事务回滚注解@Transactional

3、不应调用数据库

代码语言:javascript复制
@Transactional
@Rollback(true) // 事务自动回滚,默认是true。可以不写

单元测试的目标,就是完全脱离数据库!这个注解如果使用,就是完全背道而驰了,一般使用了这个注解的单元测试,脱离数据库后很多都会执行报错

4、应使用Assert断言

Assert断言的使用方式,可以看这篇博客:单元测试中Assert断言的使用

那么我们到底应该如何写单元测试呢?

四、正确的单元测试写法:Mock脱离数据库

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

代码语言:javascript复制
    //@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在这里已经包含

代码语言:javascript复制
        <dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

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

@MockBean

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

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

@SpyBean

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

二者的主要用法区别:

MockBean 适用本地,模拟全部方法

SpyBean适用远程不同环境, 只模拟个别方法

然后我们这里Mock的是JPA官方的EntityManager,对于官方的接口、类在我们的实现类里面作为private属性来操作数据库,我们可以通过这个方法来模拟

代码语言:javascript复制
    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注解就可以了,一般都是用这个,如

代码语言:javascript复制
    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语法就可以了。

你懂我的意思吧?

部分内容参考:

SpringBoot2.X (十四): @SpringBootTest单元测试_大痴小乙的博客-CSDN博客_springboot2 test

springboot test – 简书

Spring注解之@Component详细解析_L小芸的博客-CSDN博客_@component参数

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/133517.html原文链接:https://javaforall.cn

0 人点赞