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

引入spring-boot-starter-test

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

运行Maven命令dependency:tree可看到其包含了以下依赖:

  1. [INFO] +- org.springframework.boot:spring-boot-starter-test:jar:1.5.9.RELEASE:test
  2. [INFO] | +- org.springframework.boot:spring-boot-test:jar:1.5.9.RELEASE:test
  3. [INFO] | +- org.springframework.boot:spring-boot-test-autoconfigure:jar:1.5.9.RELEASE:test
  4. [INFO] | +- com.jayway.jsonpath:json-path:jar:2.2.0:test
  5. [INFO] | | +- net.minidev:json-smart:jar:2.2.1:test
  6. [INFO] | | | \- net.minidev:accessors-smart:jar:1.1:test
  7. [INFO] | | | \- org.ow2.asm:asm:jar:5.0.3:test
  8. [INFO] | | \- org.slf4j:slf4j-api:jar:1.7.25:compile
  9. [INFO] | +- junit:junit:jar:4.12:test
  10. [INFO] | +- org.assertj:assertj-core:jar:2.6.0:test
  11. [INFO] | +- org.mockito:mockito-core:jar:1.10.19:test
  12. [INFO] | | \- org.objenesis:objenesis:jar:2.1:test
  13. [INFO] | +- org.hamcrest:hamcrest-core:jar:1.3:test
  14. [INFO] | +- org.hamcrest:hamcrest-library:jar:1.3:test
  15. [INFO] | +- org.skyscreamer:jsonassert:jar:1.4.0:test
  16. [INFO] | | \- com.vaadin.external.google:android-json:jar:0.0.20131108.vaadin1:test
  17. [INFO] | +- org.springframework:spring-core:jar:4.3.13.RELEASE:compile
  18. [INFO] | \- org.springframework:spring-test:jar:4.3.13.RELEASE:test
  • JUnit,标准的单元测试Java应用程序;
  • Spring Test & Spring Boot Test,对Spring Boot应用程序的单元测试提供支持;
  • Mockito, Java mocking框架,用于模拟任何Spring管理的Bean,比如在单元测试中模拟一个第三方系统Service接口返回的数据,而不会去真正调用第三方系统;
  • AssertJ,一个流畅的assertion库,同时也提供了更多的期望值与测试返回值的比较方式;
  • Hamcrest,库的匹配对象(也称为约束或谓词);
  • JsonPath,提供类似XPath那样的符号来获取JSON数据片段;
  • JSONassert,对JSON对象或者JSON字符串断言的库。

一个标准的Spring Boot测试单元应有如下的代码结构:

  1. import org.junit.runner.RunWith;
  2. import org.springframework.boot.test.context.SpringBootTest;
  3. import org.springframework.test.context.junit4.SpringRunner;
  4. @RunWith(SpringRunner.class)
  5. @SpringBootTest
  6. public class ApplicationTest {
  7. }

知识准备

JUnit4注解

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

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class TestApplicationTests {
  4. @BeforeClass
  5. public static void beforeClassTest() {
  6. System.out.println("before class test");
  7. }
  8. @Before
  9. public void beforeTest() {
  10. System.out.println("before test");
  11. }
  12. @Test
  13. public void Test1() {
  14. System.out.println("test 1+1=2");
  15. Assert.assertEquals(2, 1 + 1);
  16. }
  17. @Test
  18. public void Test2() {
  19. System.out.println("test 2+2=4");
  20. Assert.assertEquals(4, 2 + 2);
  21. }
  22. @After
  23. public void afterTest() {
  24. System.out.println("after test");
  25. }
  26. @AfterClass
  27. public static void afterClassTest() {
  28. System.out.println("after class test");
  29. }
  30. }

运行输出如下:

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

从上面的输出可以看出各个注解的运行时机。

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数组是否相等。

MockMvc

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

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

  1. private MockMvc mockMvc;
  2. @Autowired
  3. private WebApplicationContext wac;
  4. @Before
  5. public void setupMockMvc(){
  6. mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  7. }

MockMvc模拟MVC请求

模拟一个get请求:

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

模拟一个post请求:

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

模拟文件上传:

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

模拟请求参数:

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

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

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

模拟发送JSON参数:

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

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

  1. User user = new User();
  2. user.setUsername("Dopa");
  3. user.setPasswd("ac3af72d9f95161a502fd326865c2f15");
  4. user.setStatus("1");
  5. String userJson = mapper.writeValueAsString(user);
  6. mockMvc.perform(MockMvcRequestBuilders.post("/user/save").content(userJson.getBytes()));

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

模拟Session和Cookie:

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

设置请求的Content-Type:

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

设置返回格式为JSON:

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

模拟HTTP请求头:

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

MockMvc处理返回结果

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

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

期望返回内容是application/json

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

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

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

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

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

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

比较Model:

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

比较forward或者redirect:

  1. mockMvc.perform(MockMvcRequestBuilders.get("/index"))
  2. .andExpect(MockMvcResultMatchers.forwardedUrl("index.html"));
  3. // 或者
  4. mockMvc.perform(MockMvcRequestBuilders.get("/index"))
  5. .andExpect(MockMvcResultMatchers.redirectedUrl("index.html"));

比较返回内容,使用content()

  1. // 返回内容为hello
  2. mockMvc.perform(MockMvcRequestBuilders.get("/index"))
  3. .andExpect(MockMvcResultMatchers.content().string("hello"));
  4. // 返回内容是XML,并且与xmlCotent一样
  5. mockMvc.perform(MockMvcRequestBuilders.get("/index"))
  6. .andExpect(MockMvcResultMatchers.content().xml(xmlContent));
  7. // 返回内容是JSON ,并且与jsonContent一样
  8. mockMvc.perform(MockMvcRequestBuilders.get("/index"))
  9. .andExpect(MockMvcResultMatchers.content().json(jsonContent));

输出响应结果:

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

测试Service

现有如下Service:

  1. @Repository("userService")
  2. public class UserServiceImpl extends BaseService<User> implements UserService {
  3. @Override
  4. public User findByName(String userName) {
  5. Example example = new Example(User.class);
  6. example.createCriteria().andCondition("username=", userName);
  7. List<User> userList = this.selectByExample(example);
  8. if (userList.size() != 0)
  9. return userList.get(0);
  10. else
  11. return null;
  12. }
  13. }

编写一个该Service的单元测试,测试findByName方法是否有效:

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class UserServiceTest {
  4. @Autowired
  5. UserService userService;
  6. @Test
  7. public void test() {
  8. User user = this.userService.findByName("scott");
  9. Assert.assertEquals("用户名为scott", "scott", user.getUsername());
  10. }
  11. }

运行后,JUnit没有报错说明测试通过,即UserServicefindByName方法可行。Spring Boot中编写单元测试 - 图1

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

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

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

测试Controller

现有如下Controller:

  1. @RestController
  2. public class UserController {
  3. @Autowired
  4. UserService userService;
  5. @GetMapping("user/{userName}")
  6. public User getUserByName(@PathVariable(value = "userName") String userName) {
  7. return this.userService.findByName(userName);
  8. }
  9. @PostMapping("user/save")
  10. public void saveUser(@RequestBody User user) {
  11. this.userService.saveUser(user);
  12. }
  13. }

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

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class UserControllerTest {
  4. private MockMvc mockMvc;
  5. @Autowired
  6. private WebApplicationContext wac;
  7. @Before
  8. public void setupMockMvc(){
  9. mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  10. }
  11. @Test
  12. public void test() throws Exception {
  13. mockMvc.perform(
  14. MockMvcRequestBuilders.get("/user/{userName}", "scott")
  15. .contentType(MediaType.APPLICATION_JSON_UTF8))
  16. .andExpect(MockMvcResultMatchers.status().isOk())
  17. .andExpect(MockMvcResultMatchers.jsonPath("$.username").value("scott"))
  18. .andDo(MockMvcResultHandlers.print());
  19. }
  20. }

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

  1. MockHttpServletRequest:
  2. HTTP Method = GET
  3. Request URI = /user/scott
  4. Parameters = {}
  5. Headers = {Content-Type=[application/json;charset=UTF-8]}
  6. Handler:
  7. Type = demo.springboot.test.controller.UserController
  8. Method = public demo.springboot.test.domain.User demo.springboot.test.controller.UserController.getUserByName(java.lang.String)
  9. Async:
  10. Async started = false
  11. Async result = null
  12. Resolved Exception:
  13. Type = null
  14. ModelAndView:
  15. View name = null
  16. View = null
  17. Model = null
  18. FlashMap:
  19. Attributes = null
  20. MockHttpServletResponse:
  21. Status = 200
  22. Error message = null
  23. Headers = {Content-Type=[application/json;charset=UTF-8]}
  24. Content type = application/json;charset=UTF-8
  25. Body = {"id":23,"username":"scott","passwd":"ac3af72d9f95161a502fd326865c2f15","createTime":1514535399000,"status":"1"}
  26. Forwarded URL = null
  27. Redirected URL = null
  28. Cookies = []

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

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class UserControllerTest {
  4. private MockMvc mockMvc;
  5. @Autowired
  6. private WebApplicationContext wac;
  7. @Autowired
  8. ObjectMapper mapper;
  9. @Before
  10. public void setupMockMvc(){
  11. mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  12. }
  13. @Test
  14. @Transactional
  15. public void test() throws Exception {
  16. User user = new User();
  17. user.setUsername("Dopa");
  18. user.setPasswd("ac3af72d9f95161a502fd326865c2f15");
  19. user.setStatus("1");
  20. String userJson = mapper.writeValueAsString(user);
  21. mockMvc.perform(
  22. MockMvcRequestBuilders.post("/user/save")
  23. .contentType(MediaType.APPLICATION_JSON_UTF8)
  24. .content(userJson.getBytes()))
  25. .andExpect(MockMvcResultMatchers.status().isOk())
  26. .andDo(MockMvcResultHandlers.print());
  27. }
  28. }

运行过程如下所示:

  1. MockHttpServletRequest:
  2. HTTP Method = POST
  3. Request URI = /user/save
  4. Parameters = {}
  5. Headers = {Content-Type=[application/json;charset=UTF-8]}
  6. Handler:
  7. Type = demo.springboot.test.controller.UserController
  8. Method = public void demo.springboot.test.controller.UserController.saveUser(demo.springboot.test.domain.User)
  9. Async:
  10. Async started = false
  11. Async result = null
  12. Resolved Exception:
  13. Type = null
  14. ModelAndView:
  15. View name = null
  16. View = null
  17. Model = null
  18. FlashMap:
  19. Attributes = null
  20. MockHttpServletResponse:
  21. Status = 200
  22. Error message = null
  23. Headers = {}
  24. Content type = null
  25. Body =
  26. Forwarded URL = null
  27. Redirected URL = null
  28. Cookies = []

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

  1. private MockMvc mockMvc;
  2. private MockHttpSession session;
  3. @Autowired
  4. private WebApplicationContext wac;
  5. @Before
  6. public void setupMockMvc(){
  7. mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  8. session = new MockHttpSession();
  9. User user =new User();
  10. user.setUsername("Dopa");
  11. user.setPasswd("ac3af72d9f95161a502fd326865c2f15");
  12. session.setAttribute("user", user);
  13. }