Spring Boot-如何优雅的写单元测试
Spring Boot-如何优雅的写单元测试
- [Spring Boot-如何优雅的写单元测试](#Spring Boot-如何优雅的写单元测试)
什么是单元测试
- 当一个测试满足下面任意一点时,测试就不是单元测试
- 与数据库交流
- 与网络交流
- 与文件系统交流
- 不能与其他单元测试在同一时间运行
- 不得不为运行它而作一些特别的事
- 如果一个测试做了上面的任何一条,那么它就是集成测试
Mockito 介绍
- 单元测试就是对一个系统中的某个最小单元的逻辑正确性的测试,通常是对一个方法来进行测试,因为只测试逻辑正确性,所以这个测试是独立的,不与任何外界环境相关,比如不需要连接数据库,不访问网络和文件系统,不依赖其他单元测试
- Mockito 是一个用来在单元测试中快速模拟那些需要与外界环境沟通的对象,以便我们快速的、方便的进行单元测试而不用启动整个系统
- 采用 Mock 框架,我们可以 虚拟 出一个 外部依赖,只注重代码的 流程与结果,真正地实现测试目的
- Mock测试框架的好处:
- 可以很简单的虚拟出一个复杂对象(比如虚拟出一个接口的实现类);
- 可以配置 mock 对象的行为;
- 可以使测试用例只注重测试流程与结果;
- 减少外部类、系统和依赖给单元测试带来的耦合
- Mockito的局限
- 不能mock静态方法
- 不能mock私有方法
- 不能mock final class
- Mock原理
- mock 模拟的对象可以理解为真实方法的一个代理,每次对方法的调用其实都是调用了代理方法,这个代理方法是一个空方法,不会做任何事情
- Mock两种使用方法
- 直接代码mock一个对象
- 用@Mock造一个对象
Mockito使用
-
方法1:调用完方法后指定返回值
//格式 Mockito.when(调用的类.方法).thenReturn(指定的返回值); // 示例: Mockito.when(service01.sendMail(Mockito.anyString(), Mockito.anyString()
-
如果是 @Mock 标注的对象方法,这样设置后不会进去方法执行,直接返回指定值。
-
如果是 @Spy 标注的对象方法,这样设置后会进去执行方法,但是返回指定的返回值。
-
指定方法的返回值时,入参的设置可以使用 matcher 做匹配,其中的 any 方法匹配任意值
// 示例1:当使用任何整数值调用 userService 的 getUser() 方法时,就回传一个自定义 User 对象 Mockito.when(userService.getUserById(Mockito.anyInt())).thenReturn(new User(3, "I'm mock")); User user1 = userService.getUserById(3); // 回传的user的名字为I'm mock User user2 = userService.getUserById(200); // 回传的user的名字也为I'm mock // 示例2:限制只有当参数的数字是 3 时,才会回传名字为 I'm mock 3 的 user 对象 Mockito.when(userService.getUserById(3)).thenReturn(new User(3, "I'm mock")); User user1 = userService.getUserById(3); // 回传的user的名字为I'm mock User user2 = userService.getUserById(200); // 回传的user为null // 示例3:当调用 userService 的 insertUser() 方法时,不管传进来的 user 是什么,都回传 100 Mockito.when(userService.insertUser(Mockito.any(User.class))).thenReturn(100); Integer i = userService.insertUser(new User()); //会返回100
- 有多个入参的方法,一个入参使用了 matcher 做匹配,那么其他入参也要用 matcher 匹配。例如下面用了 any 方法,那么第二入参就不能写死了,可以用 eq 方法来做匹配
// 错误的写法: Mockito.when(service01.sendMail(Mockito.anyString(), "王五")).thenReturn(true); // 正确的写法: Mockito.when(service01.sendMail(Mockito.anyString(), Mockito.eq("王五"))).thenReturn(true);
- 方法2: 直接返回指定值
// 格式: Mockito.doReturn(方法返回值).when(spy标注的对象).调用的方法; // 示例: Mockito.doReturn(false).when(service01).sendMail(Mockito.anyString(), Mockito.anyString());
- 方式3: 设置抛出异常
// 格式: Mockito.when(调用方法).thenThrow(抛出的异常类); // 示例: Mockito.when(service01.sendMail(Mockito.anyString(),Mockito.anyString())).thenThrow(RuntimeException.class);
@Spy 的使用
- Spy 跟 Mock 不同之处在于,它是会真正执行方法逻辑的。相同之处是它可以指定方法的返回值。
InjectMocks 的使用
- @InjectMocks 用来给标注的成员变量填充带有 @Mock 和 @Spy 标签的 bean,可以理解为它会吸取所有 @Mock 和 @Spy 标注的bean 为自己所用;
- 如果是嵌套的 bean 可以用 ReflectionTestUtils.setFileld() 绑定成员变量。
@MockBean 的使用
- @MockBean 是 SpringBoot 中增加的,用来支持容器中的 mock 测试。它跟 mock 的使用逻辑是一样,只是它修饰的对象是容器中的对象,也就是 bean 对象。
@SpyBean 的使用
- @SpyBean 也是 SpringBoot 增加的一个注解,用来支持 Spring 容器的单元测试,它与 Spy 的逻辑基本一致,不同之处就在于它标注的对象是容器对象。具体使用可以参考上面 @MockBean 的使用方法。
方法的校验和断言
- 通常写单元测试就是要断言方法的执行是否符合预期,除了 junit 提供的 Assert 类中的方法外,Mockito 也提供了几种校验方法
- 方法1:Mockito.verify() 方法断言方法是否被调用过
// 格式:
Mockito.verify(对象).对象的方法;
// 示例1:校验list对象是否调用了add(“22”)方法
Mockito.verify(list).add(“22”);
// 示例2:检查调用 userService 的 getUserById()、且参数为3的次数是否为1次
Mockito.verify(userService, Mockito.times(1)).getUserById(Mockito.eq(3)) ;
// 示例3:验证调用顺序,验证 userService 是否先调用 getUserById() 两次,并且第一次的参数是 3、第二次的参数是 5,然后才调用insertUser() 方法
InOrder inOrder = Mockito.inOrder(userService);
inOrder.verify(userService).getUserById(3);
inOrder.verify(userService).getUserById(5);
inOrder.verify(userService).insertUser(Mockito.any(User.class));
- 方法2:断言异常
@Before
public void init(){
MockitoAnnotations.initMocks(this);
// 让方法抛出异常
Mockito.when(service01.sendMail(Mockito.anyString(),Mockito.anyString()))
.thenThrow(RuntimeException.class);
}
// 必须抛出指定的异常才会通过测试
@Test(expected=RuntimeException.class)
public void testThrowException(){
service01.sendMail("张三","李四");
}
- 方法3:Assert 类中的断言方法
测试Controller
- 方式1:使用 @AutoConfigureMockMvc(推荐)
- @AutoConfigureMockMvc 会自动注入 MockMvc,可以方便的指定入参或者是 header
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class BookControllerTest2 {
@Autowired
public MockMvc mockMvc;
@Test
public void testGetBookInfo() throws Exception {
MvcResult result = mockMvc.perform(
MockMvcRequestBuilders.post("/getBookInfo2").param("id","123").header("user","xiaoming"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn();
System.out.println(result.getResponse().getContentAsString());
}
}
@RestController
public class BookController2 {
@PostMapping("/getBookInfo2")
public String getBookById(@RequestParam String id, @RequestHeader String user){
System.out.println(user + "查询书籍信息,bookId=" + id);
return "《java语言》";
}
}
- 方式2:使用 TestRestTemplate 模板
@RunWith(SpringRunner.class)
//指定web环境,随机端口
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookControllerTest {
//这个对象是运行在web环境的时候加载到spring容器中
@Autowired
private TestRestTemplate testRestTemplate;
@Test
public void testGetBookInfo(){
String result = testRestTemplate.getForObject("/getBookInfo?id=123456", String.class);
System.out.println(result);
}
}
@RestController
public class BookController2 {
@GetMapping("/getBookInfo")
public String getBookById(@RequestParam String id){
System.out.println("查询书籍信息,bookId=" + id);
return "《java语言》";
}
}
RunWith使用
-
@RunWith(SpringRunner.class) 或者 @RunWith(SpringJUnit4ClassRunner.class):代表在 Spring 容器中运行单元测试。如果配合 @SpringBootTest 就是在 SpringBoot 容器中运行单元测试。
-
注:SpringRunner 就是 SpringJUnit4ClassRunner 的别名,它们作用是一样的。
@SpringBootTest(classes = MyApplication.class) // classes 加载启动类 @RunWith(SpringRunner.class) public class BaseTest { public void runUnitTest(){ } }
-
@RunWith(MockitoJUnitRunner.class)
可以理解为使用 Mockito工作运行单元测试,它会初始化 @Mock 和 @Spy 标注的成员变量 -
@RunWith(Suite.class):代表是一个集合测试类,一般是如下用法,也就是其可一次性测试多个用例
@RunWith(Suite.class) @Suite.SuiteClasses({ServiceTest.class, A.class}) public class AllTest { } public class ServiceTest{ @Test public void test01(){ } @Test public void test02(){} }
加速Spring Boot 单元测试的执行速度
- 如果单元测试不涉及到Controller接口调用,可以配置webEnvironment = SpringBootTest.WebEnvironment.NONE不启动web容器
- 可以通过classes = {...}手动指定需要注册到容器中的类,如果不设置该属性,默认会注册应用中所有类
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,classes = {UserService.class, UserMapper.class}) public class UserServiceTest { @Autowired private UserService userService; @Test public void testUserService() {assertEquals("user list", userService.queryUsers());} }
- webEnvironment
- MOCK 启动一个模拟的Servlet环境 默认值
- RANDOM_PORT 启动一个Tomact容器,并监听一个随机端口
- DEFINED_PORT 启动一个Tomcat容器,并监听配置文件中定义的端口(未定义则默认监听8080)
- NONE 不启动Tomcat容器
- @Before @BeforeClass 有啥区别?
- @Before 和@BeforeClass 都是JUnit测试框架注解
- @Before 这个注解应用于一个方法上,这个方法会在每一个测试方法执行之前被调用。对应执行一些每个测试都需要准备工作,如初始化变量 打开数据库连接
- @BeforeClass 这个注解应用于一个静态方法上,这个方法会在测试类中的所有测试方法执行之前被调用一次,而且只会被调用一次。对于执行一些只需要在开始时执行一次的准备工作,如加载配置文件,设置环境变量等,非常有用。
- Springboot 和Junit版本关系?
- SpringBoot2.2.x 之前使用的是Junit4,之后就使用的是Junit5
- 参考项目中的一个示例
package com.aicloud.controller.voice; import cn.hutool.json.JSONUtil; import com.aicloud.EdasCoreServerApplication; import com.aicloud.bean.RestResult; import com.aicloud.secret.AppSecretService; import com.aicloud.utils.HttpHeaderUtils; import com.aicloud.utils.JsonUtil; import com.aicloud.voice.model.OutputRouteExtendsModel; import com.aicloud.voice.params.RouteAddBatchParams; import com.aicloud.voice.params.RouteAddParams; import com.aicloud.voice.params.RouteSearchParams; import org.apache.commons.compress.utils.Lists; import org.junit.jupiter.api.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(classes = EdasCoreServerApplication.class) @AutoConfigureMockMvc(addFilters = false) @ActiveProfiles("test") @Transactional @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class VoiceRouteInfoControllerTest { private static final Logger logger = LoggerFactory.getLogger(VoiceRouteInfoControllerTest.class); private static final String orgCode = "100001"; private static String secret = null; @BeforeAll public static void before(@Autowired AppSecretService appSecretService) { secret = appSecretService.queryAppSecret(orgCode); } @Test @Timeout(value = 1000, unit = TimeUnit.MILLISECONDS) @DisplayName("录音随路-批量上传随路") @Order(1) public void batchUploadRouteInfo(@Autowired MockMvc mvc) throws Exception { RouteAddBatchParams params = new RouteAddBatchParams(); RouteAddParams routeAddParams = new RouteAddParams(); routeAddParams.setRouteUid("MTAwMDAx_5_q8OEWRGMvQudfV9LfT2g"); routeAddParams.setSourceType(1); routeAddParams.setAgentId("hyf"); routeAddParams.setRecordId("10B-24-04-10-001"); routeAddParams.setRecordUrl("MjAyNC8wMy8wNS8xNi80OTUwNzE2N2ZlZmM0MWFlYTBiY2Y1NDNiNjU1MGQ4MQ.mp3"); routeAddParams.setSource(1); routeAddParams.setAudioSource("file_server"); routeAddParams.setCallNumber("15735655109"); routeAddParams.setCallTime("2024/09/12 22:08:00"); routeAddParams.setRecordForm("mp3"); routeAddParams.setSamplingRate("8k"); routeAddParams.setEncoding("16bit"); routeAddParams.setSoundChannel(1); routeAddParams.setAgentChannel(1); routeAddParams.setBusinessType("中国电信"); routeAddParams.setCallbackUrl("http://10.0.3.128:19000/api/aicc-znzj/routeinfo/api/callback/voice"); routeAddParams.setAudioProp("{\"speed\":10.0,\"silence\":800.0,\"volume\":600.0,\"forceTalk\":500.0}"); OutputRouteExtendsModel outputRouteExtendsModel = new OutputRouteExtendsModel(); outputRouteExtendsModel.setCode("field214"); outputRouteExtendsModel.setDataType("string"); outputRouteExtendsModel.setValue("测试字符串"); List<OutputRouteExtendsModel> extendFields = Lists.newArrayList(); extendFields.add(outputRouteExtendsModel); routeAddParams.setExtendFields(extendFields); List<RouteAddParams> routeParamList = Lists.newArrayList(); routeParamList.add(routeAddParams); params.setRouteParamList(routeParamList); //发起请求 MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/edas/service/route/upload/batch"); requestBuilder.contentType(MediaType.APPLICATION_JSON); requestBuilder.content(JsonUtil.encodeToString(params)); requestBuilder.headers(HttpHeaderUtils.getHeaders(orgCode, secret)); ResultActions resultActions = mvc.perform(requestBuilder).andExpect(status().isOk()); //获取响应结果 打印 MvcResult mvcResult = resultActions.andReturn(); MockHttpServletResponse response = mvcResult.getResponse(); response.setCharacterEncoding("utf8"); logger.info("content={}", response.getContentAsString()); RestResult restResult = JsonUtil.decode(response.getContentAsString(), RestResult.class); assertTrue(restResult.isSuccess()); } @Test @Timeout(value = 1000, unit = TimeUnit.MILLISECONDS) @DisplayName("录音随路-根据录音转写编号查询录音随路信息") @Order(2) public void queryRouteByRouteUid(@Autowired MockMvc mvc) throws Exception { //参数 RouteSearchParams.RouteUidParams params = new RouteSearchParams.RouteUidParams(); params.setRouteUid("MTAwMDAx_5_q8OEWRGMvQudfV9LfT1g"); params.setCreateTimeStart("2024/04/01 00:00:00"); params.setCreateTimeEnd("2024/04/30 23:59:59"); //发起请求 MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/edas/service/route/routeUid"); requestBuilder.contentType(MediaType.APPLICATION_JSON); requestBuilder.content(JsonUtil.encodeToString(params)); requestBuilder.headers(HttpHeaderUtils.getHeaders(orgCode, secret)); ResultActions resultActions = mvc.perform(requestBuilder).andExpect(status().isOk()); //获取响应结果 打印 MvcResult mvcResult = resultActions.andReturn(); MockHttpServletResponse response = mvcResult.getResponse(); response.setCharacterEncoding("utf8"); logger.info("content={}", response.getContentAsString()); RestResult restResult = JsonUtil.decode(response.getContentAsString(), RestResult.class); assertTrue(restResult.isSuccess()); } @Test @Timeout(value = 1000, unit = TimeUnit.MILLISECONDS) @DisplayName("录音随路-分页查询录音随路信息") @Order(3) public void queryRouteInfoPage(@Autowired MockMvc mvc) throws Exception { //参数 RouteSearchParams params = new RouteSearchParams(); params.setCreateTimeStart("2024/04/01 00:00:00"); params.setCreateTimeEnd("2024/04/30 23:59:59"); //发起请求 MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/edas/service/route/query"); requestBuilder.contentType(MediaType.APPLICATION_JSON); requestBuilder.content(JsonUtil.encodeToString(params)); requestBuilder.headers(HttpHeaderUtils.getHeaders(orgCode, secret)); ResultActions resultActions = mvc.perform(requestBuilder).andExpect(status().isOk()); //获取响应结果 打印 MvcResult mvcResult = resultActions.andReturn(); MockHttpServletResponse response = mvcResult.getResponse(); response.setCharacterEncoding("utf8"); logger.info("content={}", response.getContentAsString()); RestResult restResult = JsonUtil.decode(response.getContentAsString(), RestResult.class); assertTrue(restResult.isSuccess()); } }