在上一篇文章中,我们介绍了如何使用mockito对public方法进行模拟(mocking)。通过模拟方法,我们可以避免访问外部资源,例如数据库、RPC和HTTP请求,从而能够更高效地进行单元测试,并验证我们编写的方法是否正确。
然而,有些场景需要特殊处理,例如私有方法、静态方法、变量和异常处理。接下来,我们将介绍如何处理这些特殊场景。
环境准备
依赖选择
对于私有和静态方法的处理需引入增强依赖进行mock。常用的依赖有powermock-api-mockito、powermock-api-mockito2、mockito-inline,但据我所查powermock-api-mockito和powermock-api-mockito2分别于2018年和2020年就不再更新,在这里我推荐使用mockito-inline。
依赖引入
版本选择
首先,mockito-core依赖在3.4.0版本开始才将mockStatic方法引入,这意味着,从Mockito 3.4.0版本开始,用户可以直接使用Mockito来模拟静态方法,而无需依赖其他工具(如之前常用的PowerMock)。但要使用mockStatic方法,你需要确保你的项目中包含mockito-inline依赖,因为mockStatic功能是由mockito-inline模块提供的。
依赖详情
本次是采用junit5+mockito进行的单元测试,依赖jar如下
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>3.12.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.12.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>3.12.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.6.3</version>
<scope>test</scope>
</dependency>
关键注意事项
-
依赖冲突:
-
mockito-inline
会隐式包含mockito-core
,无需重复声明。 -
若项目中已有
mockito-core
,需确保版本与mockito-inline
完全一致
-
版本选择建议
-
新项目:直接使用 Mockito 5.x(最新版兼容性和性能更优)。
-
旧项目升级:建议逐步升级到 4.x/5.x,避免从 2.x 直接跳跃。
特殊场景处理
静态方法
在编码过程中常常把一些工具类方法、工厂方法以及常量写到静态方法中,便于重复使用。但在单元测试时这些方法是不便于mock。以下为静态方法mock示例。
1. 有返参
public interface YmContextRegister {
/**
* 获取客户端
*
* @param portId 接口ID
* @return 客户端处理器
* @throws Exception 异常
*/
@NonNull
public static BatchComputeHelper getBatchComputeHelper(@NonNull Long portId) throws Exception {
if (batchComputeHelperMap.containsKey(portId)) {
return batchComputeHelperMap.get(portId);
}
batchComputeHelperMap.putIfAbsent(portId, buildBatchComputeHelper(portId));
return batchComputeHelperMap.get(portId);
}
private static BatchComputeHelper buildBatchComputeHelper(Long portId) throws Exception {
PortConfig portConfig = PortConfigFactory.getPortConfig(portId);
if (portConfig instanceof AsyncPortConfig) {
return new BatchComputeHelper(portConfig.getAppId(), portConfig.getTokenId());
}else{
throw new RuntimeException("接口失败");
}
}
2. 无返参
public class TableNameHandlerContainer {
private static final Set<String> DYNAMIC_TABLE_NAME_PREFIX_SET = new HashSet<>();
private static final ThreadLocal<Map<String, String>> DYNAMIC_TABLE_NAME_HOLDERS = new ThreadLocal<>();
private static final Map<String, TableNameHandler> TABLE_NAME_HANDLER_MAP = new HashMap<>();
public static final String TB_PREFIX_TARGET_DATA="tb_target_data";
public static final String TB_PREFIX_SOURCE_DATA="tb_source_data";
static {
TableNameHandler tableNameHandler = (sql, tableName) -> {
Map<String, String> tableNameMap = DYNAMIC_TABLE_NAME_HOLDERS.get();
return tableNameMap.getOrDefault(tableName, tableName);
};
DYNAMIC_TABLE_NAME_PREFIX_SET.add(TB_PREFIX_TARGET_DATA);
DYNAMIC_TABLE_NAME_PREFIX_SET.add(TB_PREFIX_SOURCE_DATA);
for (String tbName : DYNAMIC_TABLE_NAME_PREFIX_SET) {
TABLE_NAME_HANDLER_MAP.put(tbName, tableNameHandler);
}
}
public static void setTableName(String prefix, String tableName) {
Map<String, String> tableNameMap = DYNAMIC_TABLE_NAME_HOLDERS.get();
if (tableNameMap == null) {
tableNameMap = new HashMap<>();
DYNAMIC_TABLE_NAME_HOLDERS.set(tableNameMap);
}
tableNameMap.put(prefix, tableName);
}
public static void clearTableName(String prefix) {
Map<String, String> tableNameMap = DYNAMIC_TABLE_NAME_HOLDERS.get();
if (tableNameMap != null) {
tableNameMap.remove(prefix);
}
}
public static Map<String, TableNameHandler> getHandlerMap(){
return TABLE_NAME_HANDLER_MAP;
}
}
public interface TableNameHandler {
String dynamicTableName(String sql, String tableName);
}
3. 静态方法mock示例
class YmRegisterTest {
@Mock
BatchComputeHelper mockBatchComputeHelper;
MockedStatic<YmContextRegister> ymContextRegisterMockedStatic;
SelectComputeTask2ResponseBody.SelectComputeTask2ResponseBodyData responseBodyData;
MockedStatic<TableNameHandlerContainer> mockedStatic;
@SneakyThrows
@BeforeEach
void setUp() {
ymContextRegisterMockedStatic = mockStatic(YmContextRegister.class);
mockedStatic = mockStatic(TableNameHandlerContainer.class);
/**
* 需要在mockStatic之后
* MockitoAnnotations.initMocks(this) 方法用于初始化带有 @Mock、@InjectMocks 等注解的字段,而 mockStatic 方法用于模拟静态方法的行为。根据 Mockito 的工作原理:
* mockStatic 必须在 initMocks 之前调用,以确保静态方法的模拟生效。
* 如果 initMocks 在 mockStatic 之前调用,可能会导致静态方法的模拟失效,因为此时 Mockito 尚未正确初始化 Mock 对象。
* 因此,initMocks 方法应该放在 mockStatic 之后。
*/
MockitoAnnotations.initMocks(this);
//set up
//有返参静态方法的mock 方式1
when(YmContextRegister.getBatchComputeHelper(any())).thenAnswer(invocationOnMock -> mockBatchComputeHelper);
//方式2
ymContextRegisterMockedStatic.when(() -> YmContextRegister.getBatchComputeHelper(any())).thenAnswer(invocationOnMock -> mockBatchComputeHelper);
//对无返参的静态方法mock
mockedStatic.when(() -> TableNameHandlerContainer.setTableName(anyString(), anyString())).thenAnswer(invocationOnMock -> {
return null;
});
}
@Test
void test() throws Exception {
responseBodyData = new SelectComputeTask2ResponseBody.SelectComputeTask2ResponseBodyData();
when(mockBatchComputeHelper.getBatchComputeTask(any(), any())).thenAnswer(invocationOnMock -> responseBodyData);
BatchComputeHelper batchComputeHelper = YmContextRegister.getBatchComputeHelper(1L);
SelectComputeTask2ResponseBody.SelectComputeTask2ResponseBodyData responseBodyData = batchComputeHelper.getBatchComputeTask(1L, null);
log.info(JSON.toJSONString(responseBodyData));
}
@AfterEach
public void tearDown() {
// 恢复静态方法调用
if (ymContextRegisterMockedStatic != null) {
ymContextRegisterMockedStatic.close();
}
if (mockedStatic != null) {
mockedStatic.close();
}
}
}
私有方法
在做单元测试时,我们有时需要对private修饰的方法进行测试,通过public方法作为入口又嫌流程太长不好测试,可以通过利用反射机制,对private方法进行mock。
注意:
1、如果方法名称不存在或参数类型不正确的话,会报错,不会返回null
2、因对private修饰的方法mock,需要用getDeclaredMethod
代码示例
private PageContext getPageContext(PageContext dataSegment, PlanContext planContext) {
PageContext pageQueryContext = new PageContext();
Long querySize = dataSegment.getEndId() - dataSegment.getStartId() + 1;
pageQueryContext.setStartId(dataSegment.getStartId());
pageQueryContext.setEndId(dataSegment.getEndId());
//按照分批次处理的逻辑 需要处理多少批次
long partPageCount = PageContext.getPageCount(querySize, planContext.getBatchQueryPageSize());
pageQueryContext.setPageCount(partPageCount);
pageQueryContext.setTotalCount(querySize);
log.info("当前数据段分配到的数据量 querySize: {}, batchQueryPageSize: {}, pageCount:{}, start: {}, endId: {}",
querySize, planContext.getBatchQueryPageSize(), pageQueryContext.getPageCount(), pageQueryContext.getStartId(), pageQueryContext.getEndId());
return pageQueryContext;
}
mock示例
利用反射机制对private方法进行调用
@ExtendWith(MockitoExtension.class)
class AsyncTriggerTest {
@InjectMocks
private AsyncTrigger asyncTrigger;
@SneakyThrows
@BeforeEach
void setUp() {
/**
* MockitoAnnotations.initMocks(this) 方法用于初始化带有 @Mock、@InjectMocks 等注解的字段,而 mockStatic 方法用于模拟静态方法的行为。根据 Mockito 的工作原理:
* mockStatic 必须在 initMocks 之前调用,以确保静态方法的模拟生效。
* 如果 initMocks 在 mockStatic 之前调用,可能会导致静态方法的模拟失效,因为此时 Mockito 尚未正确初始化 Mock 对象。
* 因此,initMocks 方法应该放在 mockStatic 之后。
*/
MockitoAnnotations.initMocks(this);
}
/**
* 私有方法mock
*/
@Test
void getPageContextTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
PlanContext planContext = PlanContext.builder()
.planConfig(new AsyncPlanConfig())
.exchangePlanDetailWrapList(null)
.scheduleContext(ScheduleContext.builder()
.planIds(Collections.singletonList(0L))
.build())
.build();
PageContext pageContext = new PageContext();
pageContext.setTotalCount(1L);
Class<? extends AsyncTrigger> clazz = asyncTrigger.getClass();
// 通过指定方法名称和参数类型的方法来获取Method对象(注意: 如果方法名称不存在或参数类型不正确的话,会报错,不会返回null)
// 注意:这里测试的是 private 修饰的私有方法,需要用 getDeclaredMethod
// getPageContext 是需要测试的方法名,后面为该方法需要的参数类型
Method method = clazz.getDeclaredMethod("getPageContext", PageContext.class, PlanContext.class);
//设置方法可访问
method.setAccessible(true);
PageContext result = (PageContext) method.invoke(asyncTrigger, pageContext, planContext);
Assertions.assertEquals(1L, result.getPageCount());
}
}
lambda表达式
在使用mybatis-plus框架操作数据库时,常用到lambda表达式进行sql的组装。但针对这种表达式不同的条件时并不是好mock的。首先需对表达式中的方法提前mock。
注意:mock的方法数量和类型不能比实际用到的少,不然会报NullPointerExecption异常
代码示例
private PlanExecRecord getPlanExecRecord(PlanContext planContext) {
return planExecRecordManager.getOne(new LambdaQueryWrapper<PlanExecRecord>()
.eq(PlanExecRecord::getPlanId, planContext.getPlanId())
.eq(PlanExecRecord::getYn, YesOrNoEnum.NO.getCode()).orderByDesc(PlanExecRecord::getId).last("limit 1"));
}
mock示例
@ExtendWith(MockitoExtension.class)
class AsyncTriggerTest {
@Mock
private PlanExecRecordManager mockPlanExecRecordManager;
@InjectMocks
private AsyncTrigger asyncTrigger;
@SneakyThrows
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
// Configure PlanExecRecordManager.getOne(...).
//mockConstruction 方法用于创建类的 mock 实例,并允许你配置这些 mock 实例的行为
mockConstruction(LambdaQueryWrapper.class, (mock, context) -> {
//对表达式中的方法进行mock
when(mock.eq(any(), any())).thenReturn(mock);
when(mock.ne(any(), any())).thenReturn(mock);
when(mock.in(any())).thenReturn(mock);
when(mock.orderByAsc(any())).thenReturn(mock);
when(mock.orderByDesc(any())).thenReturn(mock);
when(mock.last(any())).thenReturn(mock);
});
}
/**
* mock私有方法
*/
@Test
void privateMethodTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
PlanExecRecord planExecRecord = PlanExecRecord.builder()
.id(0L)
.planId(0L)
.state(1)
.yn(0)
.build();
when(mockPlanExecRecordManager.getOne(any())).thenReturn(planExecRecord);
Class<? extends AsyncTrigger> clazz = asyncTrigger.getClass();
// 通过指定方法名称和参数类型的方法来获取Method对象(注意: 如果方法名称不存在或参数类型不正确的话,会报错,不会返回null)
// 注意:这里测试的是 private 修饰的私有方法,需要用 getDeclaredMethod
// setUserInfo 是需要测试的方法名,后面为该方法需要的参数类型
Method method = clazz.getDeclaredMethod("getPlanExecRecord", PlanContext.class);
//设置方法可访问
method.setAccessible(true);
AsyncPlanConfig asyncPlanConfig = new AsyncPlanConfig();
asyncPlanConfig.setSourceData(new SourceDataConfig());
PlanContext planContext = PlanContext.builder().planConfig(asyncPlanConfig).build();
PlanExecRecord result = (PlanExecRecord) method.invoke(asyncTrigger, planContext);
Assertions.assertEquals(planExecRecord, result);
}
}
变量
在实际开发中我们采用一些动态配置以达到不同场景下的灵活使用。在对方法mock时,由于是通过@InjectMocks注解创建一个被测试类的实例,那么类中的变量是没法加载获取配置的,可以使用反射机制进行变量的mock。这样就可以在mock类中正常使用变量。
代码示例
@Value("${mock.value}")
private String mockValue;
mock示例
@ExtendWith(MockitoExtension.class)
class AsyncTriggerTest {
@Mock
private PlanExecRecordManager mockPlanExecRecordManager;
@InjectMocks
private AsyncTrigger asyncTrigger;
@SneakyThrows
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
// Configure PlanExecRecordManager.getOne(...).
//mockConstruction 方法用于创建类的 mock 实例,并允许你配置这些 mock 实例的行为
mockConstruction(LambdaQueryWrapper.class, (mock, context) -> {
//对表达式中的方法进行mock
when(mock.eq(any(), any())).thenReturn(mock);
when(mock.ne(any(), any())).thenReturn(mock);
when(mock.in(any())).thenReturn(mock);
when(mock.orderByAsc(any())).thenReturn(mock);
when(mock.orderByDesc(any())).thenReturn(mock);
when(mock.last(any())).thenReturn(mock);
});
//apollo或nacos的配置
ReflectionTestUtils.setField(asyncTrigger, "mockValue", "mockValue");
}
/**
* mock私有方法
*/
@Test
void privateMethodTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
PlanExecRecord planExecRecord = PlanExecRecord.builder()
.id(0L)
.planId(0L)
.state(1)
.yn(0)
.build();
when(mockPlanExecRecordManager.getOne(any())).thenReturn(planExecRecord);
Class<? extends AsyncTrigger> clazz = asyncTrigger.getClass();
// 通过指定方法名称和参数类型的方法来获取Method对象(注意: 如果方法名称不存在或参数类型不正确的话,会报错,不会返回null)
// 注意:这里测试的是 private 修饰的私有方法,需要用 getDeclaredMethod
// setUserInfo 是需要测试的方法名,后面为该方法需要的参数类型
Method method = clazz.getDeclaredMethod("getPlanExecRecord", PlanContext.class);
//设置方法可访问
method.setAccessible(true);
AsyncPlanConfig asyncPlanConfig = new AsyncPlanConfig();
asyncPlanConfig.setSourceData(new SourceDataConfig());
PlanContext planContext = PlanContext.builder().planConfig(asyncPlanConfig).build();
PlanExecRecord result = (PlanExecRecord) method.invoke(asyncTrigger, planContext);
Assertions.assertEquals(planExecRecord, result);
}
}
异常
在对方法单元测试时,为了验证方法处理逻辑是否正常,需要将Execption场景也许覆盖到。可以采用doThrow()对执行的逻辑模拟抛异常
代码示例
当执行查询逻辑时让其抛异常,验证异常处理逻辑
private PlanExecRecord getPlanExecRecord(PlanContext planContext) {
return planExecRecordManager.getOne(new LambdaQueryWrapper<PlanExecRecord>()
.eq(PlanExecRecord::getPlanId, planContext.getPlanId())
.eq(PlanExecRecord::getYn, YesOrNoEnum.NO.getCode()).orderByDesc(PlanExecRecord::getId).last("limit 1"));
}
mock示例
@ExtendWith(MockitoExtension.class)
class AsyncTriggerTest {
@Mock
private PlanExecRecordManager mockPlanExecRecordManager;
@InjectMocks
private AsyncTrigger asyncTrigger;
@SneakyThrows
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
// Configure PlanExecRecordManager.getOne(...).
//mockConstruction 方法用于创建类的 mock 实例,并允许你配置这些 mock 实例的行为
mockConstruction(LambdaQueryWrapper.class, (mock, context) -> {
//对表达式中的方法进行mock
when(mock.eq(any(), any())).thenReturn(mock);
when(mock.ne(any(), any())).thenReturn(mock);
when(mock.in(any())).thenReturn(mock);
when(mock.orderByAsc(any())).thenReturn(mock);
when(mock.orderByDesc(any())).thenReturn(mock);
when(mock.last(any())).thenReturn(mock);
});
//apollo或nacos的配置
ReflectionTestUtils.setField(asyncTrigger, "mockValue", "mockValue");
}
/**
* mock私有方法
*/
@Test
void privateMethodTest() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
PlanExecRecord planExecRecord = PlanExecRecord.builder()
.id(0L)
.planId(0L)
.state(1)
.yn(0)
.build();
when(mockPlanExecRecordManager.getOne(any())).thenReturn(planExecRecord);
Class<? extends AsyncTrigger> clazz = asyncTrigger.getClass();
// 通过指定方法名称和参数类型的方法来获取Method对象(注意: 如果方法名称不存在或参数类型不正确的话,会报错,不会返回null)
// 注意:这里测试的是 private 修饰的私有方法,需要用 getDeclaredMethod
// setUserInfo 是需要测试的方法名,后面为该方法需要的参数类型
Method method = clazz.getDeclaredMethod("getPlanExecRecord", PlanContext.class);
//设置方法可访问
method.setAccessible(true);
AsyncPlanConfig asyncPlanConfig = new AsyncPlanConfig();
asyncPlanConfig.setSourceData(new SourceDataConfig());
PlanContext planContext = PlanContext.builder().planConfig(asyncPlanConfig).build();
PlanExecRecord result = (PlanExecRecord) method.invoke(asyncTrigger, planContext);
Assertions.assertEquals(planExecRecord, result);
// 模拟抛出异常
doThrow(new RuntimeException("mock error")).when(mockPlanExecRecordManager.getOne(any()));
result = (PlanExecRecord) method.invoke(asyncTrigger, planContext);
Assertions.assertEquals(planExecRecord, result);
}
}
通过以上方式,能够提高单元测试的场景覆盖率,可以更全面地覆盖代码的各种不同情况,从而提高测试的质量和可靠性,更好地验证的代码逻辑是否合理。这样能够帮助我们及早发现和解决潜在的问题,保证代码的质量和稳定性。