一、前言

编写单元测试可以帮助开发人员编写高质量的代码,提升代码质量,减少Bug,便于重构。Spring Boot提供了一些实用程序和注解,用来帮助我们测试应用程序,在Spring Boot中开启单元测试只需引入spring-boot-starter-test即可,其包含了一些主流的测试库。本文主要介绍基于 Service和Controller的单元测试。

二、引入spring-boot-starter-test

在spring项目创建时,默认是引入了spring-boot-starter-test,但实际进行单元测试时是需要引入junit。引入具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
       <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>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>

或者直接引入

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

三、知识准备:

3.1 Unit4注解

JUnit4中包含了几个比较重要的注解:@BeforeClass、@AfterClass、@Before、@After和@Test。其中, @BeforeClass和@AfterClass在每个类加载的开始和结束时运行,必须为静态方法;而@Before和@After则在每个测试方法开始之前和结束之后运行。见如下例子:

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
import org.junit.*;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class TestApplicationTests {

@BeforeClass
public static void beforeClassTest() {
System.out.println("before class test");
}

@Before
public void beforeTest() {
System.out.println("before test");
}

@Test
public void Test1() {
System.out.println("test 1+1=2");
Assert.assertEquals(2, 1 + 1);
}

@Test
public void Test2() {
System.out.println("test 2+2=4");
Assert.assertEquals(4, 2 + 2);
}

@After
public void afterTest() {
System.out.println("after test");
}

@AfterClass
public static void afterClassTest() {
System.out.println("after class test");
}
}

运行输出如下:

1
2
3
4
5
6
7
8
9
10
...
before class test
before test
test 1+1=2
after test
before test
test 2+2=4
after test
after class test
...

从上面的输出可以看出各个注解的运行时机。
注意:引入头信息必须按照上面的进行加载,否则会造成@After等不能生效。

3.2 Assert

上面代码中,我们使用了Assert类提供的assert口方法,下面列出了一些常用的assert方法:

assertEquals("message",A,B),判断A对象和B对象是否相等,这个判断在比较两个对象时调用了equals()方法。
assertSame("message",A,B),判断A对象与B对象是否相同,使用的是==操作符。
assertTrue("message",A),判断A条件是否为真。
assertFalse("message",A),判断A条件是否不为真。
assertNotNull("message",A),判断A对象是否不为null。
assertArrayEquals("message",A,B),判断A数组与B数组是否相等。

3.3 MockMvc

下文中,对Controller的测试需要用到MockMvc技术。MockMvc,从字面上来看指的是模拟的MVC,即其可以模拟一个MVC环境,向Controller发送请求然后得到响应。

在单元测试中,使用MockMvc前需要进行初始化,如下所示:

1
2
3
4
5
6
7
8
9
private MockMvc mockMvc;

@Autowired
private WebApplicationContext wac;

@Before
public void setupMockMvc(){
mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}

MockMvc模拟MVC请求

模拟一个get请求:

mockMvc.perform(MockMvcRequestBuilders.get("/hello?name={name}","mrbird"));

模拟一个post请求:

mockMvc.perform(MockMvcRequestBuilders.post("/user/{id}", 1));

模拟文件上传:

mockMvc.perform(MockMvcRequestBuilders.fileUpload("/fileupload").file("file", "文件内容".getBytes("utf-8")));

模拟请求参数:

1
2
3
4
// 模拟发送一个message参数,值为hello
mockMvc.perform(MockMvcRequestBuilders.get("/hello").param("message", "hello"));
// 模拟提交一个checkbox值,name为hobby,值为sleep和eat
mockMvc.perform(MockMvcRequestBuilders.get("/saveHobby").param("hobby", "sleep", "eat"));

也可以直接使用MultiValueMap构建参数:

1
2
3
4
5
MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
params.add("name", "mrbird");
params.add("hobby", "sleep");
params.add("hobby", "eat");
mockMvc.perform(MockMvcRequestBuilders.get("/hobby/save").params(params));

模拟发送JSON参数:

1
2
String jsonStr = "{\"username\":\"Dopa\",\"passwd\":\"ac3af72d9f95161a502fd326865c2f15\",\"status\":\"1\"}";
mockMvc.perform(MockMvcRequestBuilders.post("/user/save").content(jsonStr.getBytes()));

实际测试中,要手动编写这么长的JSON格式字符串很繁琐也很容易出错,可以借助Spring Boot自带的Jackson技术来序列化一个Java对象(可参考Spring Boot中的JSON技术),如下所示:

1
2
3
4
5
6
7
User user = new User();
user.setUsername("Dopa");
user.setPasswd("ac3af72d9f95161a502fd326865c2f15");
user.setStatus("1");

String userJson = mapper.writeValueAsString(user);
mockMvc.perform(MockMvcRequestBuilders.post("/user/save").content(userJson.getBytes()));

其中,mapper为com.fasterxml.jackson.databind.ObjectMapper对象。

模拟Session和Cookie:

1
2
mockMvc.perform(MockMvcRequestBuilders.get("/index").sessionAttr(name, value));
mockMvc.perform(MockMvcRequestBuilders.get("/index").cookie(new Cookie(name, value)));

设置请求的Content-Type:

mockMvc.perform(MockMvcRequestBuilders.get("/index").contentType(MediaType.APPLICATION_JSON_UTF8));

设置返回格式为JSON:

mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1).accept(MediaType.APPLICATION_JSON));

模拟HTTP请求头:

mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1).header(name, values));

MockMvc处理返回结果

期望成功调用,即HTTP Status为200:

mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1))
.andExpect(MockMvcResultMatchers.status().isOk());

期望返回内容是application/json:

mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1))
.andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON));

检查返回JSON数据中某个值的内容:

mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1))
.andExpect(MockMvcResultMatchers.jsonPath("$.username").value("mrbird"));

这里使用到了jsonPath,$代表了JSON的根节点。更多关于jsonPath的介绍可参考 https://github.com/json-path/JsonPath。

判断Controller方法是否返回某视图:

mockMvc.perform(MockMvcRequestBuilders.post("/index"))
.andExpect(MockMvcResultMatchers.view().name("index.html"));

比较Model:

mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1))
.andExpect(MockMvcResultMatchers.model().size(1))
.andExpect(MockMvcResultMatchers.model().attributeExists("password"))
.andExpect(MockMvcResultMatchers.model().attribute("username", "mrbird"));

比较forward或者redirect:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mockMvc.perform(MockMvcRequestBuilders.get("/index")).andExpect(MockMvcResultMatchers.forwardedUrl("index.html"));
// 或者
mockMvc.perform(MockMvcRequestBuilders.get("/index")).andExpect(MockMvcResultMatchers.redirectedUrl("index.html"));

比较返回内容,使用content():
// 返回内容为hello
mockMvc.perform(MockMvcRequestBuilders.get("/index")).andExpect(MockMvcResultMatchers.content().string("hello"));

// 返回内容是XML,并且与xmlCotent一样
mockMvc.perform(MockMvcRequestBuilders.get("/index"))
.andExpect(MockMvcResultMatchers.content().xml(xmlContent));

// 返回内容是JSON ,并且与jsonContent一样
mockMvc.perform(MockMvcRequestBuilders.get("/index"))
.andExpect(MockMvcResultMatchers.content().json(jsonContent));

输出响应结果:

mockMvc.perform(MockMvcRequestBuilders.get("/index"))
.andDo(MockMvcResultHandlers.print());

四、测试Service

具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceTest {

@Autowired
UserService userService;

@Test
public void test() {
User user = this.userService.findByName("scott");
Assert.assertEquals("用户名为scott", "scott", user.getUsername());
}
}

运行后,JUnit没有报错说明测试通过,即UserService的findByName方法可行。

此外,和在Controller中引用Service相比,在测试单元中对Service测试完毕后,数据能自动回滚,只需要在测试方法上加上@Transactional注解,比如:

1
2
3
4
5
6
7
8
9
10
11
@Test
@Transactional
public void test() {
User user = new User();
user.setId(this.userService.getSequence("seq_user"));
user.setUsername("JUnit");
user.setPasswd("123456");
user.setStatus("1");
user.setCreateTime(new Date());
this.userService.save(user);
}

运行,测试通过,查看数据库发现数据并没有被插入,这样很好的避免了不必要的数据污染。

五、测试Controller

现有如下Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class UserController {
@Autowired
UserService userService;

@GetMapping("user/{userName}")
public User getUserByName(@PathVariable(value = "userName") String userName) {
return this.userService.findByName(userName);
}

@PostMapping("user/save")
public void saveUser(@RequestBody User user) {
this.userService.saveUser(user);
}
}

现在编写一个针对于该ControllergetUserByName(@PathVariable(value = "userName") String userName)方法的测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {

private MockMvc mockMvc;

@Autowired
private WebApplicationContext wac;

@Before
public void setupMockMvc(){
mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}

@Test
public void test() throws Exception {
mockMvc.perform(
MockMvcRequestBuilders.get("/user/{userName}", "scott")
.contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.username").value("scott"))
.andDo(MockMvcResultHandlers.print());
}
}

运行后,JUnit通过,控制台输出过程如下所示:

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
MockHttpServletRequest:
HTTP Method = GET
Request URI = /user/scott
Parameters = {}
Headers = {Content-Type=[application/json;charset=UTF-8]}

Handler:
Type = demo.springboot.test.controller.UserController
Method = public demo.springboot.test.domain.User demo.springboot.test.controller.UserController.getUserByName(java.lang.String)

Async:
Async started = false
Async result = null

Resolved Exception:
Type = null

ModelAndView:
View name = null
View = null
Model = null

FlashMap:
Attributes = null

MockHttpServletResponse:
Status = 200
Error message = null
Headers = {Content-Type=[application/json;charset=UTF-8]}
Content type = application/json;charset=UTF-8
Body = {"id":23,"username":"scott","passwd":"ac3af72d9f95161a502fd326865c2f15","createTime":1514535399000,"status":"1"}
Forwarded URL = null
Redirected URL = null
Cookies = []

继续编写一个针对于该ControllersaveUser(@RequestBody User user)方法的测试类:

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
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {

private MockMvc mockMvc;

@Autowired
private WebApplicationContext wac;

@Autowired
ObjectMapper mapper;


@Before
public void setupMockMvc(){
mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}

@Test
@Transactional
public void test() throws Exception {
User user = new User();
user.setUsername("Dopa");
user.setPasswd("ac3af72d9f95161a502fd326865c2f15");
user.setStatus("1");

String userJson = mapper.writeValueAsString(user);
mockMvc.perform(
MockMvcRequestBuilders.post("/user/save")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(userJson.getBytes()))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print());
}
}

运行过程如下所示:

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
MockHttpServletRequest:
HTTP Method = POST
Request URI = /user/save
Parameters = {}
Headers = {Content-Type=[application/json;charset=UTF-8]}

Handler:
Type = demo.springboot.test.controller.UserController
Method = public void demo.springboot.test.controller.UserController.saveUser(demo.springboot.test.domain.User)

Async:
Async started = false
Async result = null

Resolved Exception:
Type = null

ModelAndView:
View name = null
View = null
Model = null

FlashMap:
Attributes = null

MockHttpServletResponse:
Status = 200
Error message = null
Headers = {}
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []

值得注意的是,在一个完整的系统中编写测试单元时,可能需要模拟一个登录用户信息Session,MockMvc也提供了解决方案,可在初始化的时候模拟一个HttpSession:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private MockMvc mockMvc;
private MockHttpSession session;

@Autowired
private WebApplicationContext wac;

@Before
public void setupMockMvc(){
mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
session = new MockHttpSession();
User user =new User();
user.setUsername("Dopa");
user.setPasswd("ac3af72d9f95161a502fd326865c2f15");
session.setAttribute("user", user);
}