为鼓励单元测试,特分门别类示例各种组件的测试代码并进行解说,供开发人员参考。
本文中的测试均基于JUnit5。
单元测试实战(一)Controller 的测试
单元测试实战(二)Service 的测试
单元测试实战(三)JPA 的测试
单元测试实战(四)MyBatis-Plus 的测试
单元测试实战(五)普通类的测试
单元测试实战(六)其它
概述
Controller的测试,要点在于模拟一个HTTP请求过来,相应的handler方法能正确处理之。
测试应遵循BDD三段式:given、when、then;即:假设xxx……那么当yyy时……应该会zzz。
测试类推荐使用@WebMvcTest注解,并传入要测试的Controller类作为参数。
在每个测试之前应清理/重置测试数据,即模拟前端发过来的请求参数或请求体。
断言应主要检查响应对象(包括返回码)。
依赖
测试使用JUnit以及Spring Boot自带的测试工具集,如Mockito、Hamcrest、Assertj等,后续章节也一样。依赖如下:
org.springframework.boot spring-boot-starter-testtest org.junit.jupiter junit-jupiter-apitest
示例1
以下是一个控制器,UserController,主要完成对User实体的CRUD功能:
package com.aaa.api.auth.controller; import com.aaa.api.auth.entity.User; import com.aaa.api.auth.service.UserService; import com.aaa.sdk.rest.global.EnableGlobalResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/users") @EnableGlobalResult public class UserController { private final Logger log = LoggerFactory.getLogger(UserController.class); private final UserService service; public UserController(UserService service) { this.service = service; } @GetMapping("/{id}") public User getUser(@PathVariable("id") Long id) { return service.findById(id); } @GetMapping("/q") public User findUser(@RequestParam("userCode") String userCode) { return service.findByUserCode(userCode); } @GetMapping public ListgetAll() { return service.findAll(); } @PostMapping public User save(@RequestBody User user) { return service.save(user); } }
以下是对UserController进行测试的测试类:
package com.aaa.api.auth.controller; import com.aaa.api.auth.entity.User; import com.aaa.api.auth.service.UserService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import java.util.List; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService svc; @Autowired private ObjectMapper objectMapper; private final User u1 = new User(); private final User u2 = new User(); private final User u3 = new User(); @BeforeEach void setUp() { u1.setName("张三"); u1.setUserCode("zhangsan"); u1.setRole(User.ADMIN); u1.setEmail("zhangsan@aaa.net.cn"); u1.setMobile("13600001234"); u2.setName("李四"); u2.setUserCode("lisi"); u2.setRole(User.ADMIN); u2.setEmail("lisi@aaa.net.cn"); u2.setMobile("13800001234"); u3.setName("王五"); u3.setUserCode("wangwu"); u3.setRole(User.USER); u3.setEmail("wangwu@aaa.net.cn"); u3.setMobile("13900001234"); } @Test void testGetUser() throws Exception { // given - precondition or setup long id = 1L; given(svc.findById(id)).willReturn(u1); // when - action or the behaviour that we are going test ResultActions response = mockMvc.perform(get("/users/{id}", id)); // then - verify the output response.andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.userCode", is(u1.getUserCode()))) .andExpect(jsonPath("$.name", is(u1.getName()))) .andExpect(jsonPath("$.email", is(u1.getEmail()))); } @Test void testFindUser() throws Exception { // given - precondition or setup given(svc.findByUserCode(any())).willReturn(u1); // when - action or the behaviour that we are going test ResultActions response = mockMvc.perform(get("/users/q?userCode=zhangsan")); // then - verify the output response.andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.userCode", is(u1.getUserCode()))); } @Test void testGetAll() throws Exception { // given - precondition or setup ListlistOfUsers = List.of(u1, u2, u3); given(svc.findAll()).willReturn(listOfUsers); // when - action or the behaviour that we are going test ResultActions response = mockMvc.perform(get("/users")); // then - verify the output response.andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.size()", is(listOfUsers.size()))); } @Test void testSave() throws Exception { // given - precondition or setup given(svc.save(any())).willAnswer((invocation)-> invocation.getArgument(0)); // when - action or behaviour that we are going test ResultActions response = mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(u1))); // then - verify the result or output using assert statements response.andDo(print()) .andExpect(status().isOk()) // it's NOT 'created', as our controller is returning 200. .andExpect(jsonPath("$.userCode", is(u1.getUserCode()))) .andExpect(jsonPath("$.name", is(u1.getName()))) .andExpect(jsonPath("$.role", equalTo(u1.getRole().intValue()))); } }
测试类说明:
第28行我们声明这是个WebMvcTest,并且是对UserController类进行测试。
第32行我们声明了一个MockMvc对象,并标注为@AutoWired,这是Spring提供的MVC测试组件,通常每个Controller测试类都需要。
第35行我们声明了一个@MockBean,是个Service。因为UserController会注入一个UserService对象,所以这里我们Mock了一个(@MockBean注解会将Mock出的对象加入测试application context)。
第38行的ObjectMapper则是我们测试中用来进行对象-JSON转换的工具,使用@AutoWired是因为测试框架本身就提供这种Bean。
第40-42行提供了三个测试数据,并在setUp()方法中进行初始化/重置。@BeforeEach注解使得setUp()方法在每个测试之前都会执行一遍。
接下来,从65行开始,是测试方法;每个方法都遵循given - when - then三段式。
testGetUser方法是测试根据id获取User对象的。它假设service组件的findById(1)会返回对象u1;那么当访问"/users/1"这个路径时(即调用UserController的getUser方法时);返回的响应体就应该是转成JSON串的u1对象。注意我们使用jsonPath来取返回对象的属性,'$'代表根对象。
testFindUser方法是测试根据用户编码查询User对象的。它假设service组件的findByUserCode()无论传什么参数都会返回对象u1;那么当访问"/users/q?userCode=zhangsan"这个路径时(即调用UserController的findUser方法时);返回的响应体就应该是转成JSON串的u1对象。
testGetAll方法是测试获取所有User对象的。它假设service组件的findAll()会返回对象u1、u2、u3;那么当访问"/users"这个路径时(即调用UserController的getAll方法时);返回的响应体就应该是转成JSON串的u1、u2、u3对象集合。
testSave方法是测试保存User对象的。它假设service组件在save()任何User对象时都会返回该对象本身;那么当对"/users"这个路径进行POST时(即调用UserController的save方法时);返回的响应体就应该是转成JSON串该User对象。
示例2
以下是集成SSO的Controller(即实现code换token功能的redirect_uri端点,以及logout端点),SSOIntegrationController:
package com.aaa.api.auth.controller; import com.aaa.sdk.auth.sso.OAuth2Token; import com.aaa.sdk.auth.sso.OAuth2TokenHelper; import com.aaa.sdk.rbac.RbacCacheNames; import com.aaa.sdk.rest.global.GlobalResult; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.CacheManager; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; import java.util.Map; /** * 与集团SSO的集成。提供code换token(即所谓的redirect_uri)端点、logout端点。 */ @RestController @RequestMapping("/oauth") public class SSOIntegrationController { private final Logger log = LoggerFactory.getLogger(SSOIntegrationController.class); private final OAuth2TokenHelper oAuth2TokenHelper; private final CacheManager cacheManager; public SSOIntegrationController(OAuth2TokenHelper oAuth2TokenHelper, CacheManager cacheManager) { this.oAuth2TokenHelper = oAuth2TokenHelper; this.cacheManager = cacheManager; } /** * 此为用授权码换token的端点,即redirect_uri。 * @param code 授权码 * @param state 获取token之后,经过Base64编码的重定向url。 * @param request http请求 * @param response http响应 * @return 一个包含"username"和"access_token"两个键值对的Map。 */ @GetMapping({"/token", "callback"}) public GlobalResult
以下是对SSOIntegrationController进行测试的测试类:
package com.aaa.api.auth.controller; import com.aaa.sdk.auth.sso.OAuth2Token; import com.aaa.sdk.auth.sso.OAuth2TokenHelper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(SSOIntegrationController.class) class SSOIntegrationControllerTest { @Autowired private MockMvc mockMvc; @MockBean private OAuth2TokenHelper tokenHelper; private final OAuth2Token oauth2Token = new OAuth2Token(); { oauth2Token.setAccessToken("123"); oauth2Token.setRefreshToken("456"); oauth2Token.setJti("abc"); oauth2Token.setTokenType("jwt"); oauth2Token.setExpiresIn(3600 * 1000); oauth2Token.setUserId("zhangsan"); oauth2Token.setUsername("zhangsan"); } @Test void testGetTokenByCode() throws Exception { // given - precondition or setup given(tokenHelper.getAccessToken(any(), any())).willReturn(oauth2Token); given(tokenHelper.getLoginRedirectUri(any(), any())).willReturn("https://foo/bar"); // when - action or behaviour that we are going test ResultActions response = mockMvc.perform(get("/oauth/token?code=mycode&state=mystate")); // then - verify the result or output using assert statements response.andDo(print()) .andExpect(status().isFound()) .andExpect(header().string("Location", "https://foo/bar")) .andExpect(jsonPath("$.code", is(0))) .andExpect(jsonPath("$.success", is(true))) .andExpect(jsonPath("$.data.username", is("zhangsan"))) .andExpect(jsonPath("$.data.access_token", is("123"))); } @Test void testGetTokenByCode_EmptyCode() throws Exception { // given - precondition or setups given(tokenHelper.buildAuthorizeCodeUri(any(), any())).willReturn("https://foo/oauth/token"); // when - action or behaviour that we are going test ResultActions response = mockMvc.perform(get("/oauth/token?state=mystate")); // then - verify the result or output using assert statements MvcResult result = response.andDo(print()) .andExpect(status().isFound()) .andReturn(); String content = result.getResponse().getContentAsString(); assertThat(content.length()).isEqualTo(0); } @Test void testGetTokenByCode_NullAccessToken() throws Exception { // given - precondition or setups given(tokenHelper.getAccessToken(any(), any())).willReturn(null); // when - action or behaviour that we are going test ResultActions response = mockMvc.perform(get("/oauth/token?code=mycode&state=mystate")); // then - verify the result or output using assert statements response.andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.success", is(false))) .andExpect(jsonPath("$.code", not(0))); } @Test void testLogout() throws Exception { // given - precondition or setups given(tokenHelper.buildLogoutUri(any(), any())).willReturn("https://foo/bar/login"); // when - action or behaviour that we are going test ResultActions response = mockMvc.perform(get("/oauth/logout?state=mystate")); // then - verify the result or output using assert statements response.andDo(print()) .andExpect(status().isFound()) .andExpect(header().string("Location", "https://foo/bar/login")); } }
测试类说明:
第23行,我们声明这是个WebMvcTest,并且是对SSOIntegrationController类进行测试。
第27行,我们同样需要一个MockMvc组件,不再赘述。
第30行,我们有一个类型为OAuth2TokenHelper的MockBean,这是因为SSOIntegrationController里有这个组件,我们需要mock其行为。
第32行,我们定义了一个OAuth2Token类型的测试数据oauth2Token,并且在34行的初始化块中初始化其各个属性。这是个测试中会用到的JWT令牌对象。
接下来,从第44行开始,是测试方法;每个方法都遵循given - when - then三段式。
testGetTokenByCode方法是测试code换token。它假设tokenHelper.getAccessToken会返回我们的测试数据oauth2Token,且tokenHelper.getLoginRedirectUri会返回"https://foo/bar";那么当访问"/oauth/token?code=mycode&state=mystate"这个路径时(即调用SSOIntegrationController的getTokenByCode方法时);返回的响应体就应该是转成JSON串的oauth2Token对象,且响应会指示浏览器重定向到"https://foo/bar"。该测试覆盖了getTokenByCode方法的正常分支。
testGetTokenByCode_EmptyCode方法同样是测试code换token。它假设tokenHelper.buildAuthorizeCodeUri会返回"https://foo/oauth/token";那么当访问"/oauth/token?state=mystate"这个路径时(即调用SSOIntegrationController的getTokenByCode方法但code为空时);返回的响应体是空。该测试覆盖了SSOIntegrationController第55行的if分支。注意在这个方法里我们取了response内容来进行assert,没有用jsonPath。
testGetTokenByCode_NullAccessToken方法仍旧测试code换token。它假设tokenHelper.getAccessToken会返回null;那么当访问"/oauth/token?code=mycode&state=mystate"这个路径时(即调用SSOIntegrationController的getTokenByCode方法时);返回的响应体是一个不成功的GlobalResult。该测试覆盖了SSOIntegrationController第63行的if分支。
testLogout方法是测试登出功能。它假设tokenHelper.buildLogoutUri返回"https://foo/bar/login";那么当访问"/oauth/logout?state=mystate"这个路径时(即调用SSOIntegrationController的logout方法时);返回的响应体会指示浏览器重定向到"https://foo/bar/login"。
总结
对于一个@WebMvcTest,我们一般都注入一个MockMvc组件。
对于待测类依赖的组件(典型的如Service),我们通常使用@MockBean来模拟出一个。如果不是组件(不是Spring Bean),我们可以直接用@Mock模拟。对于方便new出来的,也可以直接new出来(比如我们的三个user实体和oauth2Token对象)。Mockito还有一个@Spy注解,可以监控被注解的对象(该对象通常new出来,但Spy也可以像Mock那样对其行为进行打桩)。总之,@Mock、@Spy、@MockBean、@SpyBean、@Autowired以及new出来的对象,都是为了模拟、订制该Controller所依赖的各种对象及其行为,方便我们测试。这种模拟,或曰打桩,是为了让我们能专注于待测试类的行为,而不致被其依赖的东西转移了注意力。假如我们都用真实的依赖,比如UserService,那么UserService自身的bug会导致我们的UserControllerTest失败,进一步而言,UserService又依赖于UserRepositoy,如果这个UserRepository有bug,那它又会同时导致UserServiceTest和UserControllerTest都失败——这就不是单元测试而是集成测试了;单元测试通常只关注单一类。
此外注意,如果你的项目依赖了spring-security,那么测试类(或方法)一般需要加@WithMockUser注解,其中POST测试都需要设CSRF token,如.with(csrf()),否则会报403。
还没有评论,来说两句吧...