1 什么是单元测试(UT)?
最近工作涉及到给一些代码写单元测试,这可谓是功能上线前评判代码质量极其重要的一步,当然单元测试之后还有系统测试、集成测试等,这些测试的关系和意义不是本文的重点内容,并且比较好理解,这里贴一个链接大家可以自行按需阅读:https://blog.csdn.net/u013185349/article/details/123396943
总的来看单元测试有三个核心要点:
1.测试范围:代码中的每一个小的单元(一般是类/方法)
单元测试不测试模块或系统。一般都是由开发工程师而非测试工程师完成的,是代码层面的测试,用于测试“自己”编写的代码的正确性。
2.测试依赖:不依赖于任何不可控组建
单元测试不依赖于任何不可控的组件,即使代码中依赖了其他这些不可控的组件(比如复杂外部系统,复杂模块或类),也需要通过 mock 的方式将其变成可控。
3.测试意义:得到预期的输入输出
在你写完一个功能代码之后,怎么保证你的代码运行正确?在各种异常(数据异常、输入异常、调用异常等)情况下,程序运行结果都符合你预先设计的预期,返回合适的报错呢?这个时候,单元测试就派上了用场
2 单元测试的工具
常用的单元测试工具有三种,在这里我们都会介绍,从基础到进阶的用法慢慢都会写到
JUnit
经典中的经典,相信哪怕是入门java的学习者,绝大部分也使用过JUnit的@Test注解来进行单元测试,但目前已经更新到JUnit5,很多属性是有变化的,SpringBoot默认集成的也是JUnit5版本,所以本文可能会与一些网上能找到的资料有所不同
Asserts
就是我们常说的“断言”,也是非常简单好用的工具,功能强悍,支持各种断言方法
Mockito
Mock可能对于部分初学者来说比较陌生,所以这里多说一些:JUnit固然很简单方便,但如果在单元测试的过程中吗,需要构建请求头和查询参数、构建复杂的脚本等特殊需求,用标准的JUnit是无法实现的,这个时候我们就要用到MockMVC
什么是mock?
即mock object,模拟对象,是在OOP中模拟真实对象行为的假对象,在单元测试中有时无法使用真实对象,因此需要用到模拟对象来测试。
比如在以下情况可以采用模拟对象来替代真实对象:
1.真实对象的行为是不确定的 (例如,当前的时间或温度) ;
2.真实对象很难搭建起来;
3.真实对象的行为很难触发 (例如,网络错误) ;
4.真实对象速度很慢(例如,一个完整的数据库,在测试之前可能需要初始化) ;
5.真实的对象是用户界面, 或包括用户界面在内;
6.真实的对象使用了回调机制:
7.真实对象可能还不存在:
8.真实对象可能包含不能用作测试(而不是为实际工作)的信息和方法。
举例:只做了一点简单的更改,但是验证需要重启底层资源,一等就是五六分钟;要模拟在某个操作系统/浏览器环境下的功能表现,总不能专门去搭一套环境吧
因此spring给我们提供了Mockito工具,即模拟对象的生成器,开发分为三个步骤:
1.模拟外部依赖,比如我们需要的底层数据、网络请求头等
2.执行具体的测试代码
3.验证产生的结果与预期是否相符
spring-test包提供了一个核心的对象——MockMVC,MockMvc是由spring-test包提供,实现了对Htp请求的模拟,能够直接使用网络的形式,转换到Controller的调用,使得测试速度快.不依赖网络环境。同时提供了一套验证的工具, 结果的验证十分方便。
3 编写单元测试的原则
一般来说,单元测试需要遵循3A模式(Arrange/Act/Assert)来编写具体的代码,具体的概念可以参考:https://juejin.cn/post/7005448543192252423
实际上3A是一个简单实用的概念,举个简单的例子:
Arrange:创建测试所需要的实例
Act:运行你所需要测试的具体行动(方法)
Assert:判断返回的是否与预期一样
4 Junit 5 实现基础单元测试
1.建立相应的包存储测试代码
实际上Spring Boot对Junit进行了整合,我们可以从工程架构中看到,创建一个spring工程后(springinitializer),会自带一个src下的test包和一个默认测试的代码(如下图),如果没有的话,也可以自己创建一个(因为有些开发者可能创建的是maven工程)
2.引入依赖
当然要想使用这个测试,我们当然要引入相关的依赖,一般来说只要你创建了springboot项目,它是自动导入的。但是需要注意的是,我们通常需要排除junit-vintage-engine这个依赖,为什么呢?
如果不排除这个依赖,用的不会是spring整合的Junit,从而后面写测试代码的时候可能需要加RunWith注解来指定是用哪一个Spring整合的Junit
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
3.编写测试代码
这里我们可以参考给我们的默认测试代码来写,默认代码会是这种格式(@SpringBootTest就是spring整合了junit之后的一个注解):
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class Springboot01ApplicationTests {
@Test
void contextLoads() {
}
}
我们这里简单写一下,新建一个User类,来让测试代码输出User的某个参数
package wy.springboot01.domain;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
/**
* springboot项目启动的时候,自动将application.yml内容加载到实体对象中
*/
@Data
//将实体类交给spring管理,自动扫描
@Component
public class User {
private Integer uid;
private String uname;
private String password;
private ArrayList<String> addrs;
public User() {
}
public User(Integer uid, String uname, String password, ArrayList<String> addrs) {
this.uid = uid;
this.uname = uname;
this.password = password;
this.addrs = addrs;
}
}
单元测试代码:
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import wy.springboot01.domain.User;
@SpringBootTest(classes = {User.class})
class Springboot01ApplicationTests {
@Autowired
private User user;
@Test
void contextLoads() {
System.out.println(user.getAddrs());
}
}
4.直接运行测试代码,可能会失败,常见错误是:Found multiple @SpringBootConfiguration annotated classes,这是因为之前在做其他代码的时候,可能有多个文件用了@SpringBootConfiguration从而导致冲突,只需要注释掉多余的只留一个即可,或者指定你要用哪个@SpringBootConfiguration来测试,因此我们这里用了:@SpringBootTest(classes = {User.class})
5.正常会输出null,因为我们并没有给Addrs这个参数赋默认值,到这里我们就完成了一个没有3A的最后一个A(断言)的单元测试
6.但是我们在开发中通常不会只测试某一个类的参数,比如我们想测试在某个启动类中(某个功能加载的过程中)User对象的值是否载入成功,那么也可以按如下来实现:
首先我们先写一个yml文件来给User赋默认值,就比如叫做application.yml
user:
uid: 1998051110
uname: wuyu
password: wuyu1999
addrs:
- Beijing
- Sichuan
- Nanchang
然后给User类加上注解:
//加载配置内容,设定配置前缀,注意:prefix参数不支持小驼峰原则,必须全部小写
@ConfigurationProperties(prefix = "user")
最后把测试的启动类改成这个工程的默认启动类:
@SpringBootTest(classes = {Springboot01Application.class})
这样启动之后我们就会得到如下结果,默认值加载成功了:
[Beijing, Sichuan, Nanchang]
7.那么如何给结果做一个断言呢?换而言之我不需要用人眼去判断结果是不是对的,系统就西东判断了,也很简单,这里我们就要介绍到我们的第二种测试工具:asserts
5 Asserts方法判断返回值
实际上我们在日常写代码中有时候也会用到assert,比如判断某个入参是否为空,直接使用:assert data.length != 0; 即可
那么回到单元测试,我们如何判断返回值?
//判断方法返回是否为Flase
Assertions.assertFalse(SomeClass.someMethod());
//判断方法返回是否为True
Assertions.assertTure(SomeClass.someMethod());
//判断方法返回是否与对应值相等
Assertions.assertTure("预期返回", SomeClass.someMethod());
同样将刚刚的Juint判断user的例子扩充一下,我们判断用户名是否与预期一致,可以写成:
@SpringBootTest(classes = {Springboot01Application.class})
class Springboot01ApplicationTests {
@Autowired
private User user;
@Test
void contextLoads() {
Assertions.assertArrayEquals(String.join("","Beijing", "Sichuan", "Nanchang" ).toCharArray()
,String.join("",user.getAddrs() ).toCharArray());
}
}
最终运行结果如下图,就实现了不由人眼判断而是系统判断,非常方便:
6 MockMVC
刚刚说过,mock就是用来模拟一些不太好去创建的代码外部依赖,最典型的就是对于controller的测试,传统的方法是代码起来之后使用postman,而单元测试要求我们不能有这种外部依赖,因此我们就用mock来模拟这样的一个环境。
还是拿user类来举例,比如我们有一个controller长这个样子,如果我们要验证他的返回是不是对的,按照常理我们应该去启动spring并且用浏览器、postman、http脚本等来看看它的返回是什么,非常的浪费时间
@GetMapping("/user")
public ArrayList<User> getUser(){
System.out.println("user get......");
ArrayList<User> users = new ArrayList<>();
users.add(new User(1001,"wu","1212",new ArrayList<>(Arrays.asList("nanchang", "sichuan", "beijing"))));
users.add(new User(1002,"du","1313",new ArrayList<>(Arrays.asList("chang", "sica", "beng"))));
return users;
}
那么用mock怎么去做呢?
首先我们先在test包下,创建一个与默认测试代码平行的测试类,比如就叫MockMVCTester,如下:
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import javax.annotation.Resource;
//进行每一次mock模拟tomcat容器的时候,使用随机端口启动,这样不会有端口占用的问题
@SpringBootTest(classes = {Springboot01Application.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//自动配置以及启用mvc对象
@AutoConfigureMockMvc
public class MockMVCTester {
//注入MockMVC对象,它是springtest依赖中自带的
@Resource
private MockMvc mockMvc;
@Test
public void testMock() throws Exception {
//获取mock返回的对象
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/user"))//perform模拟一个http请求,这里是get方法
.andExpect(MockMvcResultMatchers.status().isOk())//添加预期,如果服务器返回的是200
.andDo(MockMvcResultHandlers.print())//那我们就把请求和响应的信息在控制台中打印输出
.andReturn();//将结果返回出来
}
}
启动测试方法testMock,可以发现:
同时也会返回controller的信息,可以对照是否符合预期,这样我们就实现了不需要启动浏览器、postman等即可测试controller
但我们还可以更加便利一些,直接在运行的时候就判断它的返回是不是符合预期,本例的返回为:
[{"uid":1001,"uname":"wu","password":"1212","addrs":["nanchang","sichuan","beijing"]},{"uid":1002,"uname":"du","password":"1313","addrs":["chang","sica","beng"]}]
因此我们将测试代码做一些改动
package wy.springboot01;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import javax.annotation.Resource;
//进行每一次mock模拟tomcat容器的时候,使用随机端口启动,这样不会有端口占用的问题
@SpringBootTest(classes = {Springboot01Application.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//自动配置以及启用mvc对象
@AutoConfigureMockMvc
public class MockMVCTester {
//注入MockMVC对象,它是springtest依赖中自带的
@Resource
private MockMvc mockMvc;
@Test
public void testMock() throws Exception {
//获取mock返回的对象
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/user"))//perform模拟一个http请求,这里是get方法
.andExpect(MockMvcResultMatchers.status().isOk())//添加预期,如果服务器返回的是200
.andDo(MockMvcResultHandlers.print())//那我们就把请求和响应的信息在控制台中打印输出
.andExpect(MockMvcResultMatchers.content().string("[{\"uid\":1001,\"uname\":\"wu\"," +
"\"password\":\"1212\",\"addrs\":[\"nanchang\",\"sichuan\",\"beijing\"]}," +
"{\"uid\":1002,\"uname\":\"du\",\"password\":\"1313\",\"addrs\"" +
":[\"chang\",\"sica\",\"beng\"]}]"))//content表示对于返回的请求体数据进行判断,string表示进行比对
.andReturn();//将结果返回出来
}
}
再次启动,运行依然成功说明比对正确,返回符合预期,如果这个时候我们改了controller的逻辑,则返回“test fail”,非常好用,甚至会比对预期值和实际值
注:如果觉得testMock()看起来不舒服,可以在@Test下面加注解@DisplayName("get方法测试用例"),来自定义测试方法名称
如果有入参、且返回是一个json可以参考如下案例:
@Test
@DisplayName("get方法+有入参+有json返回")
public void testMock1() throws Exception {
//mock返回的对象可以不获取,因为单纯的判断对错用不上
mockMvc.perform(MockMvcRequestBuilders.get("/user/para")//perform模拟一个http请求,这里是get方法
.header("token", "akakak")//请求头
.param("id","wy")//请求参数
.param("password","asd"))//请求参数
.andExpect(MockMvcResultMatchers.status().isOk())//添加预期,如果服务器回的是200
.andDo(MockMvcResultHandlers.print())//那我们就把请求和响应的信息在控制台中打印输出
.andExpect(MockMvcResultMatchers.jsonPath("ak").value("asd"))//获取返回的json并核对对应的值是否一样
.andReturn();//将结果返回出来
}
如果方法为post,可以参考如下案例
@Test
@DisplayName("post方法测试用例")
public void testMock1() throws Exception {
//IoC
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("IoC.xml");
User user = context.getBean(User.class);
ObjectMapper mapper = new ObjectMapper();
user.setUname("wy");
//mock返回的对象可以不获取,因为单纯的判断对错用不上
mockMvc.perform(MockMvcRequestBuilders.post("/user")//perform模拟一个http请求,这里是get方法
.content(mapper.writeValueAsString(user))//用IoC建立一个User对象
.contentType(MediaType.APPLICATION_JSON_VALUE))//添加json类数据,转化为入参
.andExpect(MockMvcResultMatchers.status().isOk())//添加预期,如果服务器回的是200
.andDo(MockMvcResultHandlers.print())//那我们就把请求和响应的信息在控制台中打印输出
.andExpect(MockMvcResultMatchers.jsonPath("uname").value("wy"))//获取返回的json并核对对应的值是否一样
.andReturn();//将结果返回出来
}