一、做好单测,慢即是快
对于单元测试的看法,业界同仁理解多有不同,尤其是在业务变化快速的互联网行业,通常的问题主要有,必须要做吗?做到多少合适?现在没做不也挺好的吗?甚至一些大佬们也是存在不同的看法。我们如下先看一组数字:
“在 STICKYMINDS 网站上的一篇名为 《 The Shift-Left Approach to Software Testing 》 的文章中提到,假如在编码阶段发现的缺陷只需要 1 分钟就能解决,那么单元测试阶段需要 4 分钟,功能测试阶段需要 10 分钟,系统测试阶段需要 40 分钟,而到了发布之后可能就需要 640 分钟来修复。”——来自知乎网站节选
对于这些数字的准确性我们暂且持保留意见。大家可以想想我们实际中遇到的线上问题大概需要消耗多少工时,除了要快速找到bug,修复bug上线,还要修复因为bug引发的数据问题,最后还要复盘,看后续如何能避免线上问题,这样下来保守估计应该不止几人日吧。所以这篇文章作者所做的调研数据可信度还是很高的,
缺陷发现越到交付流程的后端,其修复成本就越高。
有人说写单测太耗费时间了,会延长交付时间,其实不然:
1)研测同学大量的往返交互比编写单测的时间要长的多,集成测试的时间被拖长。
2)没经过单测的代码bug会多,开发同学忙于修复各种bug,对代码debug跟踪调试找问题,也要消耗很多精力。
3)后期的线上问题也会需要大量的精力去弥补。
如果有了单元测试的代码,且能实现一个较高的行覆盖率,则可以将问题尽可能消灭在开发阶段。同时有了单测代码的积累,每次代码改动后可以提前发现这次改动引发的其他关联问题,上线也更加放心。单测虽然使提测变慢了一些,软件质量更加有保障,从而节省了后续同学的精力,从整体看其实效率更高。
所以做好单测,慢即是快。
做为一名开发者我们需要对自己的代码质量负责,也更能体现我们开发者的工匠精神。
二、编写单元测试
Junit5使用
maven依赖
<!-- Springboot提供的单测框架,提供一些单测工具支持,默认支持Mockito、junit5 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><version>2.5.4</version>
</dependency><!-- 或单独引入 -->
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.7.2</version><scope>compile</scope>
</dependency>
<dependency><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId><version>3.9.0</version><scope>compile</scope>
</dependency>
<dependency><groupId>org.mockito</groupId><artifactId>mockito-junit-jupiter</artifactId><version>3.9.0</version><scope>compile</scope>
</dependency>
Juint常用注解
单类示例
通过idea的Squaretest插件直接生成的测试类如下
@ExtendWith(MockitoExtension.class)
public class MockUserServiceTest {@Mockprivate UserManager mockUserManager;@InjectMocksprivate MockUserService mockUserService;@BeforeEachpublic void setUp() {mockUserService = new MockUserService(mockUserManager);}@Testpublic void testGetUserByAge() {// Setupwhen(mockUserManager.findByAge(0)).thenReturn(Arrays.asList(new User(0, "name", 0),new User(1, "name", 0)));// Run the testfinal List<User> result = mockUserService.getUserByAge(0);// Verify the results}@Testpublic void testGetUserByAge_UserManagerReturnsNoItems() {// Setupwhen(mockUserManager.findByAge(0)).thenReturn(Arrays.asList(new User(0, "name", 0), new User(1, "name", 1)));// Run the testfinal List<User> result = mockUserService.getUserByAge(0);// Verify the resultsassertThat(result).isEqualTo(Collections.emptyList());}
}
需注意Junit5.x 与Junit4.x 生成的测试类中,Junit4的测试类和测试方法必须要public关键字修改。
因为JUnit 4.x使用Java反射机制来查找和运行测试,而Java反射要求被访问的类和方法必须是public的。
JUnit 5.x(也称为Jupiter)在设计和实现上更加现代化,它引入了一些新的特性和改进,包括更灵活的测试发现机制。在JUnit 5.x中,测试类和测试方法的访问修饰符要求更加宽松。
将测试方法和类声明为public也有助于确保它们能够被其他测试框架或工具(如Maven、Gradle、IDE等)正确地发现和运行。因此,在编写JUnit测试时,即使JUnit 5.x允许更宽松的访问修饰符,但将测试类和测试方法声明为public仍然是一个好习惯。
springboot集成测试
springboot集成测试旨在验证Spring Boot应用程序的各个组件之间的交互和整体行为。集成测试非常重要,因为它可以帮助开发人员确保应用程序在不同的环境中都能正常运行。通过集成测试,可以检测应用程序中的潜在问题,提高代码的可靠性和稳定性。
Mockito常用注解
@MockBean: 用于在 Spring Boot 测试环境中创建并注入一个 mock 的 bean。
用途:用于在 Spring Boot 测试环境中创建一个 mock 的 bean,并将其注入到 Spring 应用程序上下文中。
特点:
适用于集成测试,特别是在使用 @SpringBootTest 注解的测试类中。
替换掉 Spring 容器中已有的 bean,或者添加一个新的 mock bean。
可以在测试类中直接使用 @Autowired 注解来注入这个 mock bean。
@Mock: 用于创建一个 mock 对象,但不将其注入到 Spring 应用程序上下文中。
用途:用于创建一个 mock 对象,但不将其注入到 Spring 应用程序上下文中。
特点:
适用于单元测试,特别是在不需要 Spring 上下文的测试中。
需要手动注入到测试类或方法中。
通常与 @InjectMocks 一起使用,以便将 mock 对象注入到被测试的类中。
@Spy: 用于创建一个部分 mock 对象,即一个真实的对象,但可以对其中的某些方法进行 mock。
用途:用于创建一个部分 mock 对象,即一个真实的对象,但可以对其中的某些方法进行 mock。
特点:
适用于需要调用真实对象的方法,同时对某些方法进行 mock 的场景。
可以使用 doReturn(...).when(...) 或 when(...).thenReturn(...) 来模拟方法的行为。
@InjectMocks: 用于创建一个被测试的类的实例,并将带有 @Mock 或 @Spy 注解的 mock 对象注入到该实例中。
用途:用于创建一个被测试的类的实例,并将带有 @Mock 或 @Spy 注解的 mock 对象注入到该实例中。
特点:
适用于需要将 mock 对象注入到被测试的类中的场景。
自动将 mock 对象注入到被测试类的构造函数、字段或 setter 方法中。
集成示例
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MockInjectServiceImplTest{ /*** 通过@MockBean的方式创建一个Mock的MockRpcService的Bean* 并将其注入到spring的上下文中*/@MockBeanprivate MockRpcService mockRpcService;@Resourceprivate MockInjectService mockInjectService;@BeforeEachvoid setUp() {MockitoAnnotations.openMocks(this);ReflectionTestUtils.setField(mockInjectService, "systemEnv", "{\"key\", \"value\"}");when(mockRpcService.queryCardNo(anyString())).thenReturn("cardNo");/*** 1、when(...).thenReturn(...) 语法:* 这种语法在某些情况下可能会被 Mockito 误认为是在调用 toString 方法,特别是当 mockRpcService 对象的 toString 方法被重写时。* 若在使用 Mockito 模拟这个接口时遇到了 WrongTypeOfReturnValue 异常,这通常意味着 Mockito 误认为你在调用 toString 方法而不是 queryMockResp 方法* 如果 mockRpcService 的 toString 方法返回 MockResp 类型,那么 Mockito 会抛出 WrongTypeOfReturnValue 异常。** 2、doReturn(...).when(...) 语法:* 这种语法更加明确,直接指定了方法的返回值,避免了类型不匹配的问题。适用于所有需要模拟方法返回值的场景。* 为了确保代码的健壮性和可读性,建议使用 doReturn(...).when(...) 语法。**** 下面的例子 使用when(...).thenReturn(...)时 抛出了org.mockito.exceptions.misusing.WrongTypeOfReturnValue:* MockResp cannot be returned by toString() toString() should return String* 这样的异常。*///when(mockRpcService.queryMockResp(any(MockReq.class))).thenReturn(MockRespReflection.getMockResp());doReturn(MockRespReflection.getMockResp()).when(mockRpcService).queryMockResp(any(MockReq.class));doReturn(MockRespReflection.getMockRespList()).when(mockRpcService).getMockRespList(anyInt());}@Testpublic void testGeneralDeal(){// 执行被测方法MockReq mockReqInput1 = new MockReq();mockReqInput1.setName("True-Person");MockResp mockRespResult = mockInjectService.generalDeal(mockReqInput1);log.info("mockResp:{}", JSON.toJSONString(mockRespResult));// 结果比对断言Assert.assertNotNull(mockRespResult);}@Testpublic void testInjectDeal() {// 执行被测方法MockReq mockReqInput1 = new MockReq();mockReqInput1.setName("True-Person");MockResp mockRespResult = mockInjectService.injectDeal(mockReqInput1);// 结果比对断言Assert.assertNotNull(mockRespResult);}@Testpublic void testBeautifulDeal() {// Setupfinal MockResp mockResp = new MockResp("cardNo", 0, false);// Run the testfinal String result = mockInjectService.beautifulDeal(mockResp);// Verify the resultsassertThat(result).isEqualTo("result");}@Testpublic void testVoidDeal() {// Setupfinal MockReq req = new MockReq();req.setName("name");// Run the testmockInjectService.voidDeal(req);}
}
以上示例,通过@MockBean创建一个Rpc服务MockRpcService的mock实例,可以对接口的相关方法通过when(...).thenReturn(...) 或doReturn(...).when(...)语法mock。
需注意when(...).thenReturn(...)语法在某些情况下可能会被 Mockito 误认为是在调用 toString 方法,特别是当 mockRpcService 对象的 toString 方法被重写时。
而doReturn(...).when(...) 语法更加明确,直接指定了方法的返回值,避免了类型不匹配的问题。适用于所有需要模拟方法返回值的场景。建议使用 doReturn(...).when(...) 语法
RPC接口MockRpcService
*** Mockito框架研发场景-RPC接口*/
public interface MockRpcService {String queryCardNo(String name);MockResp queryMockResp(MockReq req);public List<MockResp> getMockRespList(Integer age);
通过MockRespReflection类中的静态方法 对RPC接口的方法数据进行mock,可以采用直接字符串、文件等形式提前准备数据,这里采用读取文件形式进行mock
ublic class MockRespReflection {public static MockResp getMockResp() {try {String json = new String(Files.readAllBytes(Paths.get("src/test/file/xxx.json")));return JSON.parseObject(json, new TypeReference<MockResp>(){});} catch (IOException e) {throw new RuntimeException(e);}}/*** 从指定的JSON文件中读取并解析MockResp对象列表** @return 解析后的MockResp对象列表* @throws RuntimeException 如果读取文件时发生IO异常,将其包装成RuntimeException抛出*/public static List<MockResp> getMockRespList() {try {// 读取JSON文件内容并解析为MockResp对象列表String json = new String(Files.readAllBytes(Paths.get("src/test/file/mockRespList.json")));return JSON.parseObject(json, new TypeReference<List<MockResp>>(){});} catch (IOException e) {// 捕获IO异常并将其包装成RuntimeException抛出throw new RuntimeException(e);}}
}
通过以上配置就可以进行springboot流程的集成测试。Spring Boot集成测试是确保应用程序正确性和可靠性的重要手段。通过上述实践,可以有效地进行集成测试并提高代码质量。