最近的暑假小学期上课做的项目有要求用到junit来对后端项目进行单元测试,但是网上找到的教程大多数是junit4的,junit5的教程较少,且由于之前并没接触过测试的概念,直接上手读官方文档有些犯晕QAQ。故记录一下junit5进行单元测试的基础使用方法。
由于需要赶着课程项目的进度来做,其中的很多东西并没有深入的去了解,只是从“能用”的角度记下这篇blog,记录一下这一阶段的学习心得,并方便在日后使用的时候能够快速搭建起来一套测试。
本文项目为win10下用Intellij创建的Spring Boot项目
对JUint单元测试的简单理解
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。打个比方,要想对一辆车能否正常运行进行检验,先对发动机单独检验是否正常运转;对轮子检验是否能平稳转动;对挂挡部分检验是否能正常切换挡位(我还没考驾照QAQ不知道描述是否准确),这样对每个零件部分进行测试就是单元测试。
JUnit是一个Java语言的单元测试框架。在本次项目中我就简单将其理解为测试各个类的每个方法对于不同的输入情况是否都能给出正确处理结果。
例如有一个方法int calculate(int param1, int param2, int op);其根据输入的op对参数param1和param2进行计算后返回一个结果,那么测试时给出不同的参数让其执行,并检测返回值和预期值是否相等。使用多种不同的输入参数来尽量覆盖该函数需要处理的不同情况,以此检验该函数是否完成其预期功能。
在Spring Boot项目中使用JUnit5
首先搭建一个demo项目并向其中加入JUint。
利用Intellij新建一个project,选择Spring Initializr来搭建项目,后面均选择默认。
生成的项目大致结构:
现在为其加入一个简单的test类,其包含两个函数(函数过于简单,就不注释了)
// JUnitDemo.java内容
package com.example.demo.JUnitDemo;
public class JUnitDemo {
public int sum(int a, int b){
return a + b;
}
public int calcu(int param1, int param2, int op){
if(op == 0){
return param1 + param2;
}else if(op == 1){
return param1 - param2;
}else{
return param1;
}
}
}
在pom.xml的dependencies标签里添加如下标签引入JUnit5:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.6.2</version>
<scope>test</scope>
</dependency>
现在想对这两个函数sum和calcu进行测试,则在这个新加的类上右键选择 generate -> Test...,在弹出的小窗口里将这两个待测试的函数选中,此时在test文件夹下会自动生成一个测试类:
在被测试的类上按alt+Enter,选择“Create Test”也可以自动添加测试类
// 自动生成的测试类
package com.example.demo.JUnitDemo;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class JUnitDemoTest {
@Test
void sum() {
}
@Test
void calcu() {
}
}
当然上述添加这个测试类也可以手动添加,安排好文件夹后添加新的测试文件,然后手动打入上述代码。
每个参与测试的测试方法前面都需要加一个@Test注解,且该方法必须为public void
此时可以运行这个测试类,在其中一个@Test的方法 右键 -> Run 'xxx()' 或者直接在该测试方法上按快捷键Ctrl+Shift+F10可以运行该测试。此时由于该方法内没有进行任何实质性测试故没有失败的测试案例,会直接测试通过:
Process finished with exit code 0
现在为其添加两条简单的测试:
@Test
void sum() {
assertEquals(0, 0); // 显然测试通过
}
@Test
void calcu() {
assertEquals(1, 0); // 显然测试不通过
}
然后在JUintDemoTest上执行测试(会执行该类下所有@Test的方法)
则会显示sum()测试通过,而caclu()测试出错:
org.opentest4j.AssertionFailedError:
Expected :1
Actual :0
但我们现在还并未对我们写的代码进行实质性的测试,此时先为JUnitDemoTest类加上注解,然后在里面引入一个JUnitDemo的数据成员。之后为sum()和calcu()写几个测试:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class JUnitDemoTest {
@Autowired
private JUnitDemo demo;
@Test
void sum() {
assertEquals(3, demo.sum(1, 2));
assertEquals(100, demo.sum(110, -10));
}
@Test
void calcu() {
assertEquals(3, demo.calcu(2, 1, 0));
assertEquals(10, demo.calcu(20, 10, 1));
assertEquals(24, demo.calcu(24, 48, 5));
}
}
可能在demo处会报错“Could not autowire. No beans of 'JUnitDemo' type found.”,这里只是个小demo项目,可以在JUnitDemo类前面加上注解@Component等(加其他的例如@Service,@Controller等都可以),反正...这里没什么实际意义
运行JUnitDemoTest下的两个测试,可以得到测试通过。
获取代码覆盖率报告
同样在执行测试的菜单(在测试方法、测试类或者文件目录中的test目录下的整个文件夹上右键)选择“Rum 'xxx' with Coverage”,图标是个小盾盾加一个绿色小三角
下面是执行test/java/com/example/demo/JUnitDemo目录下的所有测试(其实也就上面写的俩了)的结果
同时在左侧文件树中也会直观的展示每个包及每个类的测试代码覆盖率:
点击图中红箭头指向的地方可以以网页形式导出详细的代码覆盖率,点击其中的index.html即可查看代码覆盖率的详情,甚至还会显示代码中哪个文件哪一行没有覆盖到。
@BeforeEach和@AfterEach等
编写测试方法时经常发现在每个测试前都要执行一些一样的操作,例如需要创建相同的对象。使用@BeforeEach注解的public void方法会在每一个@Test注解的方法前执行一遍,同样,@AfterEach注解的public void方法会在每一个@Test注解的方法后执行,使用@BeforeEach分配的资源需要在@AfterEach中释放。
使用Mock
Mock通常是指,在测试一个对象A时,我们构造一些假的对象来模拟与A之间的交互,而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试A在正常逻辑,异常逻辑或压力情况下工作是否正常。
例如在对一个类ClassA中的方法进行测试时,ClassA中有一个私有成员ClassB的对象,在A的一个方法method_a中调用了该ClassB对象的一个方法method_b获得返回值,由于一些原因我们无法控制method_b得到稳定的预期的输出,此时可用mock来操控method_b返回一些特定的值,以方便对method_a的输出做出预期,并能够测试到method_a中的每一行代码。由于对ClassB的模拟不会影响到ClassA中方法的正常行为逻辑,所以仍能达到测试的目的。
仍按照上述例子:在对ClassA中方法进行测试,ClassA中有一个ClassB的成员对象b,在A的方法method_a中调用了b中的一个方法method_b。
class ClassA{
private ClassB b;
...
public int method_a(int param1, int param2){
...
int mockValue1 = b.getValue1();
String mockValue2 = b.getValue2(2, "String_Param");
List<int> param_list = new ArrayList<int>();
...
int mockValue3 = b.getValue3(param_list);
...
}
}
对b.getValueN()函数进行mock,语法如下:
// Mock时的模板写法
/* 参数中使用Mockito.anyInt()匹配int类型参数,
Mockito.anyString()匹配String类型参数,以此类推
或使用Mockito.any()来匹配任意类型的参数
也可以使用Motkito.matches("xxx")来匹配参数
其他还有很多使用方法可以查文档 */
Mockito.when(被Mock的对象.被Mock的方法(参数1, 参数2, ..., 参数n)).thenAnswer(
new Answer<返回类型>(){
@Override
public 返回类型 answer(InvocationOnMock invocation){
// 生成模拟返回值的处理过程
// invocation.getArgument(i)获取参数列表中第i个参数,i从0开始
return 返回值;
}
}
);
// 模拟返回的逻辑很简单时可以简写成这样
Mockito.when(被Mock的对象.被Mock的方法(参数们)).thenReturn(返回值);
例如本例中可以写成下面这样:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ClassATest{
@Autowired
private ClassA a;
@MockBean
private ClassB b;
// 每个测试前都会执行一遍@BeforeEach里面的内容,也可以将Mock部分放在对应的@Test方法内
@BeforeEach
public void setUp(){
// getValue1:直接返回数字123
Mockito.when(b.getValue1()).thenAnswer(
new Answer<int>(){
@Override
public int answer(InvocationOnMock invocation){
return 123;
}
}
);
/* Mockito.when(b.getValue1()).thenReturn(123); // 另一种写法 */
// getValue2:把参数2重复参数1遍
Mockito.when(b.getValue2(Mockito.anyInt(), Mockito.anyString())).thenAnswer(
new Answer<String>(){
@Override
public String answer(InvocationOnMock invocation){
String ret = "";
for(int i = invocation.getArgument(0); i > 0; --i)
ret += invocation.getArgument(1);
return ret;
}
}
);
// getValue3:参数(是个list)的奇数号元素提取出来
Mockito.when(b.getValue3(Mockito.any())).thenAnswer(
new Answer<List<int>>(){
@Override
pulbic List<int> answer(InvocationOnMock invocation){
List<int> ret = new ArrayList<>();
int size = invocation.getArgument(0).size();
for(int i = 0; i < size; i+=2)
ret.add(invocation.getArgument(0).get(i));
return ret;
}
}
);
}
@Test
public void test(){
...
}
}
此后对method_a()方法进行测试时,每次调用method_a()方法中遇到ClassB中有匹配到被Mock的方法,就会返回模拟的值而不会继续调用ClassB中以及其下层的其他方法。
Controller层类的方法测试
Controller层可以测试每个接口对应的方法,方法基本同上。但这次项目我是通过接口测试,向每个接口模拟发送请求,然后对返回的值进行判断。
假设下面是一个Controller层某个类AdminController的一个接口:
// 获取所有用户信息
@CrossOrigin
@GetMapping(value = "api/admin/users")
@ResponseBody
public List<Tutor> adminGetUsers() {
// 这个方法只是演示如何通过接口来测试用,本身逻辑十分简单
return this.userService.findAll();
}
首先同样对这个AdminController类生成一个测试类AdminControllerTest并加上需要的注解:
@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
class AdminControllerTest {
private MockMvc mockmvc;
@Autowired
private WebApplicationContext webapplicationcontext;
@MockBean
private UserService userservice;
@BeforeEach
public void setUp() throws Exception {
mockmvc = MockMvcBuilders.webAppContextSetup(webapplicationcontext).build();
}
@Test
public void adminGetUsers() throws Exception {
Mockito.when(userservice.findAll()).thenAnswer(
new Answer<List<User>>(){
@Override
public List<User> answer(InvocationOnMock invocation){
List<User> ret = new ArrayList<>();
ret.add(new User(1, "userid1", "uid1", "username1"));
ret.add(new User(2, "userid2", "uid2", "username2"));
ret.add(new User(3, "userid3", "uid3", "username3"));
return ret;
}
}
);
// 这一段是新的内容
/* 由于没有找到合适的文档,加之赶课程项目进度
* 下面一段代码只是我根据JUnit4的代码摸索来的,完成度仅仅是能用(不求甚解)
* 注释是根据自己的理解加上的
*/
String str = mockmvc.perform(MockMvcRequestBuilders.get("/api/admin/users")) // 模拟发送请求
.andExpect(status().isOk()) // 请求是否正常得到回应
.andReturn().getResponse().getContentAsString(); // 得到字符串形式的返回值
// 以下处理JSON的工具均为fastjson
List<User> get = JSON.parseObject(str, new TypeReference<List<User>>(){});
List<User> exp = new ArrayList<>();
exp.add(new User(1, "userid1", "uid1", "username1"));
exp.add(new User(2, "userid2", "uid2", "username2"));
exp.add(new User(3, "userid3", "uid3", "username3"));
assertEquals(exp, get);
}
}
下面着重说一下模拟发送网络请求部分,即上面代码中新加内容的几种用法(只是自己摸索出来的几种):
// get请求
mockmvc.perform(MockMvcRequestBuilders.get("/xxx"))
...
// post请求
Map<String, String> map = new HashMap<>();
map.put("Key1", "Value1");
map.put("Key2", "Value2");
String cont = JSONObject.toJSONString(map);
mockmvc.perform(MockMvcRequestBuilders.post("/xxx")
.contentType(MediaType.APPLICATION_JSON).content(cont))
...
// 返回值是个简单的json,直接判断返回的值是否符合预期
mockmvc.perform(...)
.andExpect(status().isOk()) // 这里通过status().xxxx()也可以对不同的返回状态做出预期
// 直接对某个键名对应的值进行检测
.andExpect(MockMvcResultMatchers.jsonPath("键名").value(预期的值))
// 简单的JSON可以直接全文对比
.andExpect(MockMvcResultMatchers.content().string("{\"Key1\":expValue1,\"Key2\":\"expValue2\"}"))
.andExpect(...); // 可以继续比对
对复杂类型的返回值进行比对
有的时候被测试的类输入或者返回的结果比较复杂,包含多个键值对或者包含列表等结构,此时再对预期值和返回值进行比对会比较麻烦。尤其是带有列表的情况,如果手动生成一个长长的expect字符串显得很笨重,且本意义相同的JSON字符串可能会因为局部格式不一致(多个空格等)以及键值对的顺序不同而被判定为不相等。我一开始设想的是拿到的返回值和预期值均转化为JSONObject或者JSONArray然后进行比较,但这样的方法仍然可能有局部键值对顺序不一致而导致测试判错。
不知道会有多少人和我一样傻fufu的真就较长一段时间内拿JSON字符串或者JSONArray这样的来比对列表等形式的返回值和预期值,所以把这一点单独拿出来成一段
下面介绍将字符串解析为java对象的方法:
String str = mockmvc.perform(...).....getContententAsString();
类名 get = JSON.parseObject(str, new TypeReference<类名>(){});
// 例如上面代码出现过的
List<User> get = JSON.parseObject(str, new TypeReference<List<User>>(){});
加入token之后的Controller层测试问题
由于token部分并不是我负责加的(是其他组员),所以我对token是如何加入项目中并正常工作的暂时并不是很了解。
在加入token部分内容之后,再对后端的Controller层进行检测会因为发送模拟网络请求的时候没有正确的token而测试结果出错(那么恭喜你,至少token是起了作用的)
由于我目前也还没有深入理解token的工作方法,我只是粗暴的找到了负责核对token并返回true/false的函数直接队其mock,让其强制返回true(token验证通过)。至少之前写的测试在加入token之后还能继续正常运行了。后续还会深入的了解token的原理并尝试更高级的方法来绕过这个检测。
后记
JUnit单元测试的使用方法我只是了解了皮毛,是在本次的课程项目实践中逐步形成了自己对单元测试等的理解。上面摸索出来的每一步解决方案都可能有更多更好的替代方案,或者在该步还能实现更强大更复杂的测试功能。后续的提升还是需要阅读相关的文档以及更多的项目磨砺。