如何写好Spring /Spring Boot 单元测试
前言
初入公司接手项目,当写完一个小功能模块时,需要对此功能进行测试,因为不是http接口,所以也没有办法用Postman测试,所以需要编写单元测试用例,之前只接触过Spring Boot的项目,也简单写过一些小的测试用例,但是通过研究公司里前辈们的写法及其搜索了一些文章发现,里面还是有很多值得挖掘和学习的地方。因此有了这篇文章的诞生。
一、测试的分类
-
单元测试
- 单元测试又可以称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。可以根据实际情况判定其具体含义,如C语言中单元指一个函数,Java里指一个类,图形化的软件中可以指一个窗口或一个菜单等,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
-
集成测试
-
系统测试
- 系统测试是将需测试的软件,作为整个基于计算机系统的一个元素,与计算机硬件、外设、某些支持软件、数据和人员等其他系统元素及环境结合在一起测试。
二、测试所需要的工具
-
Junit
-
Spring(Spring的项目)
-
IDEA
三、如何写好单元测试
通过搜索一些文章不难发现,大多数文章都在用SpringBootTest注解(Spring Boot项目)或者使用ContextConfiguration注解加载bean 的XML文件(Spring项目),其实这种方式如果论可行性完全说得通,起码测试功能是具备的。
这种方式是告诉Spring/Spring Boot 我要加载整个项目程序的上下文,你要给我准备好,所以这种方式就需要去扫描所有的Bean文件,加载更多的实例到容器中来,那么就向相当于启动了整个项目,如果项目非常大,那么需要启动耗时很长;同时,如果牵扯到多人开发,可能需要用到其他人写的bean,如果此时bean还没有写好,那么加载到bean 容器里面我们也是没用的;同时如果我们要测试功能模块不需要依赖其他的Bean或者仅仅依赖几个Bean,那么通过上述方式进行单元测试就有种杀鸡焉用宰牛刀的感觉了。
如何根据实际情况写好测试用例?这里附上Spring 官网关于单元测试的说明截图:

翻译了一下:
与传统 Java EE 开发相比,依赖注入应该使您的代码对容器的依赖更少。组成您的应用程序的 POJO 应该可以在 JUnit 或 TestNG 测试中进行测试,使用 new 运算符实例化对象,无需 Spring 或任何其他容器。您可以使用模拟对象(结合其他有价值的测试技术)来单独测试您的代码。如果您遵循 Spring 的架构建议,那么代码库的干净分层和组件化将有助于更轻松的单元测试。例如,您可以通过存根或模拟 DAO 或存储库接口来测试服务层对象,而无需在运行单元测试时访问持久数据。
真正的单元测试通常运行得非常快,因为不需要设置运行时基础设施。强调真正的单元测试作为开发方法的一部分可以提高您的生产力。您可能不需要测试章节的这一部分来帮助您为基于 IoC 的应用程序编写有效的单元测试。然而,对于某些单元测试场景,Spring 框架提供了模拟对象和测试支持类,本章将对此进行介绍。
根据自己的理解和查询网上的资料,可以得知,官方不推荐通过运行整个项目的方式来执行测试用例,因为通常情况下真正的单元测试运行速度非常快,尽量使用new的操作来实例化对象,根本不需要容器,如果遇到一些数据库依赖或者其他未完成的依赖,官方推荐我们使用Mock注解模拟对象来完成单元测试。根据官方的建议结合代码,下面分析一个例子具体感受下。
四、测试单元例子
首先定义service接口及其实现类
public interface StudentService {
String getStudentName();
}
@Component(value = "studentServiceImpl")
public class StudentServiceImpl implements StudentService {
@Override
public String getStudentName(){
return "张三";
}
}
这里为了方便起见,直接返回一个学生名字--张三。同时在接口实现类中添加@Component注解表明这是一个bean,需要交给IOC容器去管理。下面开始编写我们的测试类,其中测试类的写法有多种:
例子1
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"classpath:bean.xml"})
public class StudentTest {
@Resource
private StudentServiceImpl studentServiceImpl;
@Test
public void getStudentName() {
System.out.println(studentServiceImpl.getStudentName());
}
}
熟悉Spring的同学应该清楚,首先RunWith注解来源于Junit,就是一个运行器,具体要运行什么呢,需要我们指定,在这里指定使用SpringJUnit4ClassRunner,告诉我们,在测试开始的时候自动创建Spring的应用上下文,那具体从哪里加载bean呢?这里我们使用ContextConfiguration注解,通知Spring去bean.xml文件里面去加载,文件内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<context:component-scan base-package="com.qhw.example" />
</beans>
component-scan 就代表Spring需要去com.qhw.example包下及其子包下去寻找是否有@Component、@Service等等注解,如果有的话,就把注解标注的类当作一个bean加载到IOC容器里面去。当然此处还有一些子标签可以用,不再说明。
最终通过这种方式,把我们的StudentServiceImpl类加载到容器中,同时测试的时候通过@Resource注解加载到测试用例中来,进而调用其getStudentName方法输出学生的名字。
如果换成SpringBoot框架,那就更简单了,在测试类中将ContextConfiguration注解替换成SpringBootTest注解就会自动扫描Bean
- 这种方式可以实现测试的功能,但是需要加载整个Spring项目,由于此处写的Demo比较简单,感觉不出来,在实际的开发环境中,项目启动需要加载一系列的东西,比如Spring上下文环境、注册中心、远程调用等等,大概跑完一个简单的单元测试需要耗时三四秒的时间,其中很大一部分完全是跟单元测试不相干的,所以根据官方的建议,我们需要对其进行改进。
例子2
接口及其实现类不变,仅仅改动测试类,代码如下:
public class StudentTest {
private StudentServiceImpl studentServiceImpl;
@Before
public void setBean(){
this.studentServiceImpl=new StudentServiceImpl();
}
@Test
public void getStudentName() {
System.out.println(this.studentServiceImpl.getStudentName());
}
}
通过对比可以看到几个变化
- 不再使用依赖注入的方式,而是通过new的方式去生成一个对象
- 使用@Before注解,其作用是在执行测试函数的时候,先去执行此注解标记的前置函数,顾名思义,先去执行Before注解下的函数,再去执行Test注解下的函数,同时如果有多个Test函数,需要依次运行,那么studentServiceImpl对象初始化一次就可以在多个Test函数里面公用,十分方便。
- 最后经过测试,并对比控制台的输出,发现使用这种方式不需要加载Spring的上下文环境, 而且速度很快,大约 0.6秒 ,不到一秒钟
例子3
到这里,可能有小伙伴就要问了,StudentServiceImpl里面如果也有@Resource依赖注入的bean呢?这在实际开发环境中十分常见,类似于这样
@Component(value = "studentServiceImpl")
public class StudentServiceImpl implements StudentService {
@Resource
private StudentMapper studentMapper;
@Override
public String getStudentName(){
return studentMapper.getStudentName();
}
}
@Service
public class StudentMapper {
public String getStudentName(){
return "张三";
}
}
此处我把getStudentName函数放到了StudentMapper类中,一般来说Mapper类与MyBatis结合使用,用于定义查询数据接口,此处为了方便起见,暂时这么用了,后续代表Mock数据库查询的时候也会使用到。
如果此时,我们再去运行测试类,就会出现这个错误:

提升我们在测试环境中找不到StudentMapper这个Bean,为空指针。此时我们可以通过构造函数的方式,将StudentMapper注入进去,直接上代码
@Component(value = "studentServiceImpl")
public class StudentServiceImpl implements StudentService {
private StudentMapper studentMapper;
@Autowired //将Autowired作用域构造函数上
public StudentServiceImpl(StudentMapper studentMapper) {
this.studentMapper = studentMapper;
}
@Override
public String getStudentName(){
return studentMapper.getStudentName();
}
}
public class StudentTest {
private StudentServiceImpl studentServiceImpl;
private StudentMapper studentMapper;
@Before
public void setBean(){
//通过new初始化对象再进行调用
this.studentMapper=new StudentMapper();
this.studentServiceImpl=new StudentServiceImpl(this.studentMapper);
}
@Test
public void getStudentName() {
System.out.println(this.studentServiceImpl.getStudentName());
}
}
一般来说@Autowired注解作用在类上,表示获取bean,而此处作用在StudentServiceImpl构造函数中。首先我们要知道,在Spring中如果要生成一个bean首先要进行初始化操作,去执行构造函数,那么如果有多个构造函数,Spring又会怎么选择呢?答案就是 :Spring中构造方法调用优先级为:带@Autowired的有参构造方法 > 不带@Autowired的有参构造方法 > 无参构造方法,所以当Spring检测到带Autowird的有参构造函数A时,观察参数是什么,如果参数B还是一个想要交给Spring管理的bean(即通过@Component等注解标识),那么Spring先去生成B参数的bean,等生成完了,在回来去生成A的bean,那么此时就不会有空指针的错误了。那么在单元测试用例中,我们只需要先将B new出来,在交给A处理就完事了。so easy。
此时可能又有小伙伴问了,如果Mapper类真的是数据库操作连接怎么办,或者是一些网络请求怎么办,这时候官方就建议我们使用Mock对象去模拟了。关于Mock对象具体怎么使用,我会在专门记录一下。

浙公网安备 33010602011771号