单元测试
为什么要单元测试
1、验证代码的正确性:单元测试可以验证代码在预期输入下是否产生了正确的输出。通过编写针对函数、类或模块的小型测试用例,可以确保代码按照预期工作,减少出现潜在错误的可能性。
2、提供反馈和文档:单元测试可以作为代码的规范和文档,它们描述了每个功能的使用方式和预期行为。当其他开发人员查看或修改代码时,他们可以通过阅读单元测试来理解代码的目的和预期结果。
3、改进设计和可维护性:编写单元测试通常需要将代码分解成更小的组件,这有助于改善代码的设计和可维护性。通过将代码拆分为易于测试的单元,可以更容易地识别和修复潜在的问题,并支持重构和更好的模块化。
4、提高代码质量:通过频繁运行单元测试,可以及早发现代码中的问题,并迅速定位和修复它们。这可以减少在后期开发阶段发现和解决问题所需的时间和资源,提高代码的质量和稳定性。
5、支持重构和持续集成:在进行重构操作时,单元测试可以提供信心,确保代码在更改后仍然按预期工作。此外,单元测试还是持续集成流程的重要组成部分,能够自动运行以验证新代码的正确性,并帮助捕获引入的任何错误。
什么是Mock测试
Mock通常是指,在测试一个对象A时,我们构造一些假的对象来模拟与A之间的交互,而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试A在正常逻辑,异常逻辑或压力情况下工作是否正常。
Mock 测试就是在测试过程中,对于某些不容易构造(如 HttpServletRequest 必须在Servlet 容器中才能构造出来)或者不容易获取比较复杂的对象(如 JDBC 中的ResultSet 对象),用一个虚拟的对象(Mock 对象)来创建以便测试的测试方法。Mock 最大的功能是帮你把单元测试的耦合分解开,如果你的代码对另一个类或者接口有依赖,它能够帮你模拟这些依赖,并帮你验证所调用的依赖的行为。
即mock测试是不需要启动SpringBoot的依赖环境的,注解@SpringBootTest是不应该使用的
且单元测试必须不能依赖第三方接口和组件例如数据库,消息中间件等
官方资料
https://gitcode.net/mirrors/imsingle/mockito-doc-zh?utm_source=csdn_github_accelerator#1
Maven包引入
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.7.7</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>2.0.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<version>2.0.9</version>
<scope>test</scope>
</dependency>
单元测试命名规范
以对应方法所属类关联命名:
1、单测的命名空间基与主代码的命名空间一致
2、如果该类逻辑不多,方法也不多,则可以一个测试类对应一个主类(一般这种即可)
3、如果类较复杂或者测试的方法较复杂,建议一个测试类对应一个方法
方法名为6段式(可参考,不必严格执行)
第一段固定为test
第二段为需要测试的方法名,此例为aaa
第三段固定“With”,后面跟着方法输入参数,如果方法没有输入值,则省略with以及第四段
第四段为方法参数描述,多个方法参数用"and"连接,描述上随意,只要能看懂就行,比如此例为bbb,如果过于复杂可以加上注释
第五段固定“Expect”,后面跟着方法的输出结果
第六段为方法的输出结果,此例为ccc,如果预期的结果为异常,也可以加上异常的具体种类
Mockito参数匹配器
Mockito 提供了一系列的参数匹配器,可以根据参数类型和值来匹配参数。
例如 any()、anyXXX()等
单元测试案例
Java的单元测试是以方法为维度的,可能涉及公有方法,静态方法,私有方法
@Slf4j
@Service
public class IdnDebtDelayRecordSpi implements IDebtDelayRecordSpi {
@Autowired
private ListingDebtDelayServerFeignClient listingDebtDelayServerFeignClient;
@Override
public List<DebtDelayRecordDto> queryDebtDelayRecordByUserId(Long userId) {
return listingDebtDelayServerFeignClient.queryDebtDelayRecordByUserId(userId).getData();
}
}
@RunWith(MockitoJUnitRunner.class)
public class IdnDebtDelayRecordSpiTest {
@Mock
private ListingDebtDelayServerFeignClient listingDebtDelayServerFeignClient;
@InjectMocks
private IdnDebtDelayRecordSpi idnDebtDelayRecordSpi;
@Test
public void testQueryDebtDelayRecordByUserId() {
String json = "{ \"id\": 1165, \"userId\": 216812123, \"debtId\": 209727269, \"bizType\": 2, \"operatorId\": 0, \"flowNo\": \"229bbb7c26bd4dbcaa77dd8c662a01fa\", \"status\": 3, \"launchTime\": \"2023-10-16T11:14:19.000+0000\", \"oldDueDate\": \"2023-10-30T15:59:59.000+0000\", \"newDueDate\": \"2023-11-06T15:59:59.000+0000\" }";
DebtDelayRecordDto debtInfo = JSON.parseObject(json, DebtDelayRecordDto.class);
BasicResponse<List<DebtDelayRecordDto>> basicResponse = new BasicResponse<>();
basicResponse.setCode(0);
basicResponse.setMessage("SUCCESS");
List<DebtDelayRecordDto> data = Lists.newArrayList(debtInfo);
basicResponse.setData(data);
basicResponse.setSuccess(true);
Mockito.when(listingDebtDelayServerFeignClient.queryDebtDelayRecordByUserId(216812123L)).thenReturn(basicResponse);
List<DebtDelayRecordDto> debtDelayRecordDtoList = idnDebtDelayRecordSpi.queryDebtDelayRecordByUserId(216812123L);
Assert.assertNotNull(debtDelayRecordDtoList);
}
}
上面的代码是一个的案例
@Mock 和 @InjectMock 的区别
假如我需要测试的目标方法是 IdnDebtDelayRecordSpi 里面的queryDebtDelayRecordByUserId(Long userId)方法,那么我在写测试类的时候,首先需要用 @InjectMocks 注入要被我测试的类,然后因为 queryDebtDelayRecordByUserId 方法中用到了 ListingDebtDelayServerFeignClient ,也就是说依赖了ListingDebtDelayServerFeignClient,所以在测试类中,我又需要使用 @Mock来模拟注入这个 ListingDebtDelayServerFeignClient。
可以简单理解为@InjectMocks就是用在要测试的类上,而@Mock是为了填补@InjectMocks的那个类,这里为IdnDebtDelayRecordSpi里面所注入的成员变量,这里为listingDebtDelayServerFeignClient
私有方法&静态方法注入测试
@Service
@Slf4j
public class SysSmsTemplateConfigureBiz {
@Autowired
private SysSmsTemplateConfigureService smsService;
@Autowired
private AuthUserBiz authUserBiz;
/**
* 更新短信模板
*/
public GenericResponse<AddOrUpdateSmsResp> updateSms(SmsUpdateReq req) {
//1.校验模板名称
List<SmsResp> smsResps = smsService.queryList(new SmsQueryReq());
//排除掉自己
smsResps = smsResps.stream().filter(item -> !item.getId().equals(req.getId())).collect(Collectors.toList());
if (smsResps.stream().anyMatch(item -> item.getName().equals(req.getName()))) {
return GenericResponse.ofFail("短信名称已存在");
}
Boolean resultFlag = smsService.updateSms(req);
String existNotAsciiCharacter = AsciiCodeUtils.extractNonASCIICharacters(req.getContent());
return GenericResponse.ofSuccess(AddOrUpdateSmsResp.builder().existNotAsciiCharacter(existNotAsciiCharacter).resultFlag(resultFlag).build());
}
/**
* 根据userId查询userName
*/
private String getUserName(Long userId) {
UserBo user = authUserBiz.getUserById(userId, Boolean.FALSE);
if (ObjectUtils.isEmpty(user)) {
return null;
}
return user.getName();
}
}
public class AsciiCodeUtils {
private static final Pattern PATTERN = Pattern.compile("[^\\x00-\\x7F]");
private AsciiCodeUtils() {
}
/**
* 提取字符串中的非ASCII字符,返回提取后的字符串,多个非ASCII字符之间用英文分隔
*
* @param content
* @return
*/
public static String extractNonASCIICharacters(String content) {
Matcher matcher = PATTERN.matcher(content);
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
int startIndex = matcher.start();
int endIndex = matcher.end();
String nonAsciiChar = content.substring(startIndex, endIndex);
if (StringUtils.isNotBlank(nonAsciiChar)) {
if (sb.length() > 0) {
sb.append(",");
}
sb.append(nonAsciiChar);
}
}
return StringUtils.isNotBlank(sb.toString()) ? sb.toString() : null;
}
}
@RunWith(PowerMockRunner.class)
@PrepareForTest({AsciiCodeUtils.class, SysSmsTemplateConfigureBiz.class})
public class SysSmsTemplateConfigureBizTest {
@InjectMocks
private SysSmsTemplateConfigureBiz sysSmsTemplateConfigureBiz;
@Mock
private SysSmsTemplateConfigureService smsService;
@Mock
private AuthUserBiz authUserBiz;
@Test
public void testUpdateSms() {
Mockito.when(smsService.queryList(new SmsQueryReq())).thenReturn(Mockito.anyList());
String json = "{\"sendScene\":\"0\",\"name\":\"非ASCII码字符测试02\",\"techniqueId\":\"ASCII123456\",\"relation\":[1,2],\"minDay\":30,\"maxDay\":90,\"businessType\":3,\"content\":\"逾期天数{MAX_OVERDUE_DAYS}\\n借款姓名{CUST_NAME}\",\"id\":10}";
SmsUpdateReq req = JSON.parseObject(json, SmsUpdateReq.class);
Mockito.when(smsService.updateSms(req)).thenReturn(Boolean.TRUE);
PowerMockito.mockStatic(AsciiCodeUtils.class);
String result = "逾,期,天,数,借,款,姓,名";
Mockito.when(AsciiCodeUtils.extractNonASCIICharacters(Mockito.anyString())).thenReturn(result);
GenericResponse<AddOrUpdateSmsResp> addOrUpdateSmsRespGenericResponse = sysSmsTemplateConfigureBiz.updateSms(req);
Assert.assertEquals("逾,期,天,数,借,款,姓,名", addOrUpdateSmsRespGenericResponse.getData().getExistNotAsciiCharacter());
}
@Test
public void testGetUserName() throws Exception {
// mock私有方法是,需要加上@PrepareForTest,并且要用 @Spy 的方式获取对象
SysSmsTemplateConfigureBiz sysSmsTemplateConfigureBiz = Mockito.spy(SysSmsTemplateConfigureBiz.class);
MockitoAnnotations.initMocks(this);
ReflectionTestUtils.setField(sysSmsTemplateConfigureBiz, "authUserBiz", authUserBiz);
UserBo user = new UserBo();
user.setName("maoyidong");
Mockito.when(authUserBiz.getUserById(0L, Boolean.FALSE)).thenReturn(user);
String result = WhiteboxImpl.invokeMethod(sysSmsTemplateConfigureBiz, "getUserName", 0L);
Assert.assertEquals("maoyidong", result);
}
}
我们已经可以对public方法进行mock测试,但此时无法模拟私有方法,静态方法的测试,此时需要用到PowerMockRunner,而不是MockitoJUnitRunner,上面是一个包含了私有方法测试和静态方法测试的例子
空返回测试
@Component
public class DiTingAlarmAdapter implements IAlarmAdapter {
@Value("${com.ppdai.appId}")
private String appId;
@Value("${spring.application.name}")
private String applicationName;
@Override
public Boolean support(Integer alarmPlatform, String alarmChannel) {
return AlarmPlatformEnum.DI_TING.getCode().equals(alarmPlatform);
}
@Override
public void alarm(AlarmEvent event, AlarmRecord record) {
String alarmChannels = Arrays.stream(event.getAlarmType().split(",")).map(type -> AlarmTypeEnum.getInstance(Integer.parseInt(type)).getDesc()).collect(Collectors.joining(","));
AlertDetail alertDetail = new AlertDetail(record.getEventName(), appId, record.getDetail());
alertDetail.setSeverity("warning")
.setAlertType("diting.alert.norecovry")
.setEnv(SpringContextUtil.getEnv())
.setInstance(applicationName)
.setAppname(applicationName)
//"sms"或者"wechat"或者"mail",多个方式,以","分隔
.setChannels(alarmChannels)
//接受者:域帐号,多个接收者,以","分隔
.setReceivers(event.getAlarmTo())
.setValue("")
.setCategory("DITING")
.setSource(AlarmSourceEnum.getInstance(event.getOrigin()).getDesc());
//告警发出
AlertManager.sendAlert(alertDetail);
}
}
如果我们想要调用diTingAlarmAdapter.alarm方法的代码仅跳过,不去做任何操作,可以借助以下代码mock测试
doNothing().when(adapter).alarm(any(), any());
verify(adapter, times(1)).alarm(any(), any());
使用Mockito进行异步测试
下面是使用Mockito进行异步测试的一些常见场景和示例。
模拟异步回调
在异步回调中,当一个异步操作完成时,它将调用一个回调函数来通知调用方。Mockito提供了Answe
@Test
public void testAsyncCallback() {
MyAsyncService service = mock(MyAsyncService.class);
when(service.doSomethingAsync(anyString(), any(Consumer.class))).thenAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
Object[] args = invocation.getArguments();
String arg1 = (String) args[0];
Consumer<String> callback = (Consumer<String>) args[1];
callback.accept(arg1 + " is done");
return null;
}
});
MyAsyncClient client = new MyAsyncClient(service);
String result = client.doSomething("test");
assertEquals("test is done", result);
}
在这个示例中,我们使用Answer接口来模拟异步回调函数。当service.doSomethingAsync方法被调用时,我们从参数中获取回调函数并执行它,然后返回null。在测试中,我们验证异步客户端返回的结果是否正确。
等待异步结果
在异步编程中,我们经常需要等待异步操作完成后获取结果。为了测试异步方法,我们需要等待异步操作完成后再断言结果。Mockito提供了一些方法来处理这种场景。
@Test
public void testAsyncResult() throws Exception {
MyAsyncService service = mock(MyAsyncService.class);
CompletableFuture<String> future = new CompletableFuture<>();
when(service.doSomethingAsync(anyString())).thenReturn(future);
MyAsyncClient client = new MyAsyncClient(service);
CompletableFuture<String> result = client.doSomethingAsync("test");
assertFalse(result.isDone()); // 验证异步方法还未完成
future.complete("test is done");
assertTrue(result.isDone()); // 验证异步方法已完成
assertEquals("test is done", result.get()); // 验证异步方法的结果是否正确
}
在这个示例中,我们使用CompletableFuture来模拟异步方法的结果。当service.doSomethingAsync方法被调用时,我们返回一个CompletableFuture对象。在测试中,我们验证异步方法是否已经启动,然后手动完成CompletableFuture对象并验证结果是否正确。
需要注意的是,在使用CompletableFuture对象进行异步测试时,我们需要等待异步操作完成后再获取结果。我们可以使用isDone()方法来判断异步操作是否完成,使用get()方法来获取异步操作的结果。
总结
Mockito是一个流行的Java模拟框架,它可以帮助开发人员编写单元测试,以便更好地验证代码的正确性。Mockito提供了一些常用的方法,例如模拟对象、测试桩、参数匹配、异步测试等,这些方法可以大大简化测试代码的编写和维护。Mockito的优点包括易学易用、广泛支持、文档丰富等,但也存在局限性,例如不支持mock final方法等。对于开发人员而言,使用Mockito进行单元测试可以提高代码质量,降低代码维护成本,是一个非常值得掌握的技能。
扩展
Junit5
前面的例子都是基于Junit4的,但是目前SpringBoot是默认支持Junit5的
Mockito,Junit5在SpringBoot 内部已依赖只需引入spring-boot-starter-test即可。
<!-- Maven依赖 -->
<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>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
Junit5
Spring Boot 2.2.0 版本开始引入 JUnit 5 作为单元测试默认库
作为最新版本的JUnit框架,JUnit5与之前版本的Junit框架有很大的不同。由三个不同子项目的几个不同模块组成。
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
JUnit Platform: Junit Platform是在JVM上启动测试框架的基础,不仅支持Junit自制的测试引擎,其他测试引擎也都可以接入。
JUnit Jupiter: JUnit Jupiter提供了JUnit5的新的编程模型,是JUnit5新特性的核心。内部 包含了一个测试引擎,用于在Junit Platform上运行。
JUnit Vintage: 由于JUint已经发展多年,为了照顾老的项目,JUnit Vintage提供了兼容JUnit4.x,Junit3.x的测试引擎。
Junit5基本使用
1、@TestInstance(Lifecycle.PER_CLASS)注解
如果您希望JUnit Jupiter在同一个测试实例上执行所有测试方法,只需使用@TestInstance(Lifecycle.PER_CLASS)注释您的测试类。使用此模式时,每个测试类将创建一个新的测试实例。如果没使用@TestInstance(Lifecycle.PER_CLASS)注解,使用@BeforeAll和@AfterAll注解必须在static静态方法上使用。
2、@ExtendWith(MockitoExtension.class)注解
@ExtendWith注解类似于@RunWith
用在springboot项目中,涉及spring的单元测试需要使用@ExtendWith(SpringExtension.class)注解,可以mock spring bean。不涉及spring时使用@ExtendWith(MockitoExtension.class)。
@ExtendWith(SpringExtension.class)注解在Spring boot 2.1.x需要配合@SpringBootTest 使用,Spring boot 2.1.x之后可以不使用@ExtendWith(SpringExtension.class)注解
3、@SpringBootTest(classes = Application.class)注解
classes = ApplicationStarter.class指向SpringBoot启动类,启动spring容器。
在不同的Spring Boot版本中@ExtendWith的使用:
其中在Spring boot 2.1.x之前:
@SpringBootTest 需要配合@ExtendWith(SpringExtension.class)才能正常工作的。
而在Spring boot 2.1.x之后:
@SpringBootTest 已经组合了@ExtendWith(SpringExtension.class),因此,无需在进行该注解的使用了,进一步简化。如下图@SpringBootTest注解中已包含@ExtendWith(SpringExtension.class)
4、方法注解
基本的注解都是方法上的注解,意思就是只在测试方法上进行添加,对应注解有以下几种:
这里用Junit4和Junit5做了比较
5、断言校验
Assertions.assertEquals()值比较校验:
Assertions.assertThrows()异常捕获校验:
等等其他断言就不做不赘述了