值得收藏!Java单元测试典型案例集锦
- 错误地测试 : 利用测试返回节点占比来测试随机负载均衡策略;
- 不进行测试 : 没有人针对虚基类进行单独地测试;
- 利用集成测试 : 很多案例中,直接注入真实依赖对象,然后一起进行集成测试。
/*** 交易订单服务类*/@Servicepublic class TradeOrderService {/** 注入依赖对象 *//** 交易订单DAO */@Autowiredprivate TradeOrderDAO tradeOrderDAO;/*** 查询交易订单** @param orderQuery 订单查询* @return 交易订单分页*/public PageDataVO<TradeOrderVO> queryTradeOrder(TradeOrderQueryVO orderQuery) {// 查询交易订单// 查询交易订单: 总共数量Long totalSize = tradeOrderDAO.countByCondition(orderQuery);// 查询交易订单: 数据列表List<TradeOrderVO> dataList = null;if (NumberHelper.isPositive(totalSize)) {List<TradeOrderDO> tradeOrderList = tradeOrderDAO.queryByCondition(orderQuery);if (CollectionUtils.isNotEmpty(tradeOrderList)) {dataList = convertTradeOrders(tradeOrderList);}}// 返回分页数据return new PageDataVO<>(totalSize, dataList);}/*** 转化交易订单列表** @param tradeOrderList 交易订单DO列表* @return 交易订单VO列表*/private static List<TradeOrderVO> convertTradeOrders(List<TradeOrderDO> tradeOrderList) {// 检查订单列表if (CollectionUtils.isEmpty(tradeOrderList)) {return Collections.emptyList();}// 转化订单列表return tradeOrderList.stream().map(TradeOrderService::convertTradeOrder).collect(Collectors.toList());}/*** 转化交易订单** @param tradeOrder 交易订单DO* @return 交易订单VO*/private static TradeOrderVO convertTradeOrder(TradeOrderDO tradeOrder) {TradeOrderVO tradeOrderVO = new TradeOrderVO();tradeOrderVO.setId(tradeOrder.getId());// ...return tradeOrderVO;}}
由于方法convertTradeOrders(转化交易订单列表)传入的参数tradeOrderList(交易订单列表)不可能为空,所以“检查订单列表”这段代码是不可达代码。
// 检查订单列表if (CollectionUtils.isEmpty(tradeOrderList)) {return Collections.emptyList();}
1.2. 方案1:删除不可达代码(推荐) 最简单的方法,就是删除方法convertTradeOrders(转化交易订单列表)中的不可达代码。
1.3. 方案2:利用不可达代码(推荐) 还有一种方法,把不可达代码利用起来,可以降低方法queryTradeOrder(查询交易订单)的代码复杂度。/*** 转化交易订单列表** @param tradeOrderList 交易订单DO列表* @return 交易订单VO列表*/private static List<TradeOrderVO> convertTradeOrders(List<TradeOrderDO> tradeOrderList) {return tradeOrderList.stream().map(TradeOrderService2::convertTradeOrder).collect(Collectors.toList());}
1.4. 方案3:测试不可达代码(不推荐) 对于一些祖传代码,有些小伙伴不敢删除代码。在某些情况下,可以针对不可达代码进行单独测试。/*** 查询交易订单** @param orderQuery 订单查询* @return 交易订单分页*/public PageDataVO<TradeOrderVO> queryTradeOrder(TradeOrderQueryVO orderQuery) {// 查询交易订单// 查询交易订单: 总共数量Long totalSize = tradeOrderDAO.countByCondition(orderQuery);// 查询交易订单: 数据列表List<TradeOrderVO> dataList = null;if (NumberHelper.isPositive(totalSize)) {List<TradeOrderDO> tradeOrderList = tradeOrderDAO.queryByCondition(orderQuery);dataList = convertTradeOrders(tradeOrderList);}// 返回分页数据return new PageDataVO<>(totalSize, dataList);}
/*** 测试: 转化交易订单列表-交易订单列表为空** @throws Exception 异常信息*/@Testpublic void testConvertTradeOrdersWithTradeOrderListEmpty() throws Exception {List<TradeOrderDO> tradeOrderList = null;Assert.assertSame("交易订单列表不为空", Collections.emptyList(),Whitebox.invokeMethod(TradeOrderService1.class, "convertTradeOrders", tradeOrderList));}
二、如何测试内部的构造方法
在这次单元测试总决赛中,有一个随机负载均衡策略,需要针对Random(随机数)进行单元测试。 2.1. 代码案例 按照题目要求,编写了一个简单的随机负载均衡策略。2.2. 方法1:直接测试法(不推荐) 有些参赛选手,不知道如何测试随机数(主要原因是因为不知道如何Mock构造方法),所以直接利用测试返回节点占比来测试随机负载均衡策略。/*** 随机负载均衡策略类*/public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {/*** 选择服务节点** @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/@Overridepublic ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {// 检查节点列表if (CollectionUtils.isEmpty(serverNodeList)) {return null;}// 计算随机序号int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();int randomIndex = new Random().nextInt(totalWeight);// 查找对应节点for (ServerNode serverNode : serverNodeList) {int currentWeight = serverNode.getWeight();if (currentWeight > randomIndex) {return serverNode;}randomIndex -= currentWeight;}return null;}}
/*** 随机负载均衡策略测试类*/@RunWith(MockitoJUnitRunner.class)public class RandomLoadBalanceStrategyTest {/** 定义测试对象 *//** 随机负载均衡策略 */@InjectMocksprivate RandomLoadBalanceStrategy randomLoadBalanceStrategy;/*** 测试: 选择服务节点-随机** @throws Exception 异常信息*/@Testpublic void testSelectNodeWithRandom() throws Exception {int nodeCount1 = 0;int nodeCount2 = 0;int nodeCount3 = 0;ServerNode serverNode1 = new ServerNode(1L, 10);ServerNode serverNode2 = new ServerNode(2L, 20);ServerNode serverNode3 = new ServerNode(3L, 30);List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);ClientRequest clientRequest = new ClientRequest();for (int i = 0; i < 1000; i++) {ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);if (serviceNode == serverNode1) {nodeCount1++;} else if (serviceNode == serverNode2) {nodeCount2++;} else if (serviceNode == serverNode3) {nodeCount3++;}}Assert.assertEquals("节点1占比不一致", serverNode1.getWeight() / 60.0D, nodeCount1 / 1000.0D, 1E-3D);Assert.assertEquals("节点2占比不一致", serverNode2.getWeight() / 60.0D, nodeCount2 / 1000.0D, 1E-3D);Assert.assertEquals("节点3占比不一致", serverNode3.getWeight() / 60.0D, nodeCount3 / 1000.0D, 1E-3D);}}
这个测试用例主要存在3个问题:
- 执行时间长: 被测方法需要被执行1000遍;
- 不一定通过: 由于随机数是随机,并不一定保证比例,所以导致测试用例并不一定通过;
- 测试目标变更: 单测测试的测试目标应该是负载均衡逻辑,现在感觉测试目标变成了Random方法。
/*** 随机负载均衡策略测试类*/@RunWith(PowerMockRunner.class)@PrepareForTest(RandomLoadBalanceStrategy.class)public class RandomLoadBalanceStrategyTest {/** 定义测试对象 *//** 随机负载均衡策略 */@InjectMocksprivate RandomLoadBalanceStrategy randomLoadBalanceStrategy;/*** 测试: 选择服务节点-第一个节点** @throws Exception 异常信息*/@Testpublic void testSelectNodeWithFirstNode() throws Exception {// 模拟依赖方法Random random = Mockito.mock(Random.class);Mockito.doReturn(9).when(random).nextInt(Mockito.anyInt());PowerMockito.whenNew(Random.class).withNoArguments().thenReturn(random);// 调用测试方法ServerNode serverNode1 = new ServerNode(1L, 10);ServerNode serverNode2 = new ServerNode(2L, 20);ServerNode serverNode3 = new ServerNode(3L, 30);List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);ClientRequest clientRequest = new ClientRequest();ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);Assert.assertEquals("服务节点不一致", serverNode1, serviceNode);// 验证依赖方法int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();Mockito.verify(random).nextInt(totalWeight);}}
但是,这个测试用例也存在问题:需要把RandomLoadBalanceStrategy加到@PrepareForTest注解中,导致Jacoco无法统计单元测试的覆盖率。 2.4. 方法3:工具方法法(推荐) 其实,随机数生成,还有很多工具方法,我们可以利用工具方法RandomUtils.nextInt代替构造方法。
2.4.1. 重构代码
/*** 随机负载均衡策略类*/public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {/*** 选择服务节点** @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/@Overridepublic ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {// 检查节点列表if (CollectionUtils.isEmpty(serverNodeList)) {return null;}// 计算随机序号int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();int randomIndex = RandomUtils.nextInt(0, totalWeight);// 查找对应节点for (ServerNode serverNode : serverNodeList) {int currentWeight = serverNode.getWeight();if (currentWeight > randomIndex) {return serverNode;}randomIndex -= currentWeight;}return null;}}
2.4.2. 测试用例
/*** 随机负载均衡策略测试类*/@RunWith(PowerMockRunner.class)@PrepareForTest(RandomUtils.class)public class RandomLoadBalanceStrategyTest {/** 定义测试对象 *//** 随机负载均衡策略 */@InjectMocksprivate RandomLoadBalanceStrategy randomLoadBalanceStrategy;/*** 测试: 选择服务节点-第一个节点*/@Testpublic void testSelectNodeWithFirstNode() {// 模拟依赖方法PowerMockito.mockStatic(RandomUtils.class);PowerMockito.when(RandomUtils.nextInt(Mockito.eq(0), Mockito.anyInt())).thenReturn(9);// 调用测试方法ServerNode serverNode1 = new ServerNode(1L, 10);ServerNode serverNode2 = new ServerNode(2L, 20);ServerNode serverNode3 = new ServerNode(3L, 30);List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);ClientRequest clientRequest = new ClientRequest();ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);Assert.assertEquals("服务节点不一致", serverNode1, serviceNode);// 验证依赖方法PowerMockito.verifyStatic(RandomUtils.class);int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();RandomUtils.nextInt(0, totalWeight);}}
2.5. 方法4:注入对象法(推荐) 如果不愿意使用工具方法,也可以注入依赖对象,我们可以利用RandomProvider(随机数提供者)来代替构造方法。
2.5.1. 重构代码
/*** 随机负载均衡策略类*/public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {/** 注入依赖对象 *//** 随机数提供者 */@Autowiredprivate RandomProvider randomProvider;/*** 选择服务节点** @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/@Overridepublic ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {// 检查节点列表if (CollectionUtils.isEmpty(serverNodeList)) {return null;}// 计算随机序号int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();int randomIndex = randomProvider.nextInt(totalWeight);// 查找对应节点for (ServerNode serverNode : serverNodeList) {int currentWeight = serverNode.getWeight();if (currentWeight > randomIndex) {return serverNode;}randomIndex -= currentWeight;}return null;}}
2.5.2. 测试用例
/*** 随机负载均衡策略测试类*/@RunWith(MockitoJUnitRunner.class)public class RandomLoadBalanceStrategyTest {/** 模拟依赖方法 *//** 随机数提供者 */@Mockprivate RandomProvider randomProvider;/** 定义测试对象 *//** 随机负载均衡策略 */@InjectMocksprivate RandomLoadBalanceStrategy randomLoadBalanceStrategy;/*** 测试: 选择服务节点-第一个节点*/@Testpublic void testSelectNodeWithFirstNode() {// 模拟依赖方法Mockito.doReturn(9).when(randomProvider).nextInt(Mockito.anyInt());// 调用测试方法ServerNode serverNode1 = new ServerNode(1L, 10);ServerNode serverNode2 = new ServerNode(2L, 20);ServerNode serverNode3 = new ServerNode(3L, 30);List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);ClientRequest clientRequest = new ClientRequest();ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);Assert.assertEquals("服务节点不一致", serverNode1, serviceNode);// 验证依赖方法int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();Mockito.verify(randomProvider).nextInt(totalWeight);}}
三、如何测试虚基类和子类
在这次单元测试比赛中,很多选手都编写了虚基类,但是没有看到任何一个选手针对虚基类进行了单独的测试。 3.1. 案例代码 这里,以Diamond属性配置加载为例说明。3.1.1. 虚基类定义
首先,定义一个通用的虚基类,定义了需要子类实现的虚方法,实现了通用的配置解析方法。/*** 虚属性回调类** @param <T> 配置类型*/@Slf4jpublic abstract class AbstractPropertiesCallback<T> implements DiamondDataCallback {/** 注入依赖对象 *//** 环境 */@Autowiredprivate Environment environment;/** 转化服务 */@Autowiredprivate ConversionService conversionService;/*** 接收到数据** @param data 配置数据*/@Overridepublic void received(String data) {// 获取配置参数String configName = getConfigName();Assert.notNull(configName, "配置名称不能为空");T configInstance = getConfigInstance();Assert.notNull(configInstance, "配置实例不能为空");// 解析配置数据try {log.info("绑定属性配置文件开始: configName={}", configName);Properties properties = new Properties();byte[] bytes = Optional.ofNullable(data.getBytes()).orElseGet(() -> new byte[0]);InputStream inputStream = new ByteArrayInputStream(bytes);BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));properties.load(bufferedReader);Bindable<T> bindable = Bindable.ofInstance(configInstance);Binder binder = new Binder(ConfigurationPropertySources.from(new PropertiesPropertySource(configName, properties)),new PropertySourcesPlaceholdersResolver(environment), conversionService);BindResult<T> result = binder.bind(configName, bindable);if (!result.isBound()) {log.error("绑定属性配置文件失败: configName={}", configName);return;}log.info("绑定属性配置文件成功: configName={}, configInstance={}", configName, JSON.toJSONString(configInstance));} catch (IOException | RuntimeException e) {log.error("绑定属性配置文件异常: configName={}", configName, e);}}/*** 获取配置名称** @return 配置名称*/@NonNullprotected abstract String getConfigName();/*** 获取配置实例** @return 配置实例*/@NonNullprotected abstract T getConfigInstance();}
3.1.2. 子类实现
其次,定义了具体配置的子类,简单地实现了基类定义的虚方法。/*** 例子配置回调类*/@DiamondListener(groupId = "unittest-example", dataId = "example.properties", executeAfterInit = true)public class ExampleConfigCallback extends AbstractPropertiesCallback<ExampleConfig> {/** 注入依赖对象 *//** 例子配置 */@Resourceprivate ExampleConfig exampleConfig;/*** 获取配置名称** @return 配置名称*/@Overrideprotected String getConfigName() {return "example";}/*** 获取配置实例** @return 配置实例*/@Overrideprotected ExampleConfig getConfigInstance() {return exampleConfig;}}
3.2. 方法1:联合测试法(不推荐) 最简单的测试方法,就是通过子类对虚基类进行联合测试,这样同时把子类和虚基类都测试了。
/*** 例子配置回调测试类*/@RunWith(MockitoJUnitRunner.class)public class ExampleConfigCallbackTest {/** 定义静态常量 *//** 资源路径 */private static final String RESOURCE_PATH = "testExampleConfigCallback/";/** 模拟依赖对象 *//** 配置环境 */@Mockprivate ConfigurableEnvironment environment;/** 转化服务 */@Mockprivate ConversionService conversionService;/** 定义测试对象 *//** BOSS取消费配置回调 */@InjectMocksprivate ExampleConfigCallback exampleConfigCallback;/*** 测试: 接收-正常*/@Testpublic void testReceivedWithNormal() {// 模拟依赖对象ExampleConfig exampleConfig = new ExampleConfig();Whitebox.setInternalState(exampleConfigCallback, "exampleConfig", exampleConfig);// 调用测试方法String text = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.properties");exampleConfigCallback.received(text);// 验证依赖对象text = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.json");Assert.assertEquals("取消费用配置不一致", text, JSON.toJSONString(exampleConfig, SerializerFeature.MapSortField));}}
3.3. 方法2:独立测试法(推荐) 其实,更好的方法是对虚基类和子类独立单元测试。
3.3.1. 基类测试
虚基类的单元测试,专注于虚基类的通用配置解析。/*** 虚属性回调测试类*/@RunWith(MockitoJUnitRunner.class)public class AbstractPropertiesCallbackTest {/** 静态常量相关 *//** 资源目录 */private static final String RESOURCE_PATH = "testAbstractPropertiesCallback/";/** 模拟依赖对象 *//** 环境 */@Mockprivate ConfigurableEnvironment environment;/** 转化服务 */@Mockprivate ConversionService conversionService;/** 定义测试对象 *//** 虚属性回调 */@InjectMocksprivate AbstractPropertiesCallback<ExampleConfig> propertiesCallback =CastUtils.cast(Mockito.spy(AbstractPropertiesCallback.class));/*** 测试: 接收到-正常*/@Testpublic void testReceivedWithNormal() {// 模拟依赖方法// 模拟依赖方法: propertiesCallback.getConfigNameString configName = "example";Mockito.doReturn(configName).when(propertiesCallback).getConfigName();// 模拟依赖方法: propertiesCallback.getConfigInstanceExampleConfig configInstance = new ExampleConfig();Mockito.doReturn(configInstance).when(propertiesCallback).getConfigInstance();// 调用测试方法String text1 = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.properties");propertiesCallback.received(text1);String text2 = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.json");Assert.assertEquals("任务配置不一致", text2, JSON.toJSONString(configInstance));// 验证依赖方法// 验证依赖方法: propertiesCallback.receivedMockito.verify(propertiesCallback).received(text1);// 验证依赖方法: propertiesCallback.getConfigNameMockito.verify(propertiesCallback).getConfigName();// 验证依赖方法: propertiesCallback.getConfigInstanceMockito.verify(propertiesCallback).getConfigInstance();}}
3.3.2. 子类测试
子类的单元测试,专注于对虚基类定义虚方法的实现,避免了每个子类都要针对虚基类的通用配置解析进行测试。/*** 例子配置回调测试类*/@RunWith(MockitoJUnitRunner.class)public class ExampleConfigCallbackTest {/** 定义测试对象 *//** BOSS取消费配置回调 */@InjectMocksprivate ExampleConfigCallback exampleConfigCallback;/*** 测试: 获取配置实例*/@Testpublic void testGetConfigInstance() {Assert.assertEquals("配置实例不一致", exampleConfig, exampleConfigCallback.getConfigInstance());}/*** 测试: 获取配置名称*/@Testpublic void testGetConfigName() {Assert.assertEquals("配置名称不一致", "example", exampleConfigCallback.getConfigName());}}
四、如何测试策略模式的策略服务
4.1. 案例代码 在这次单元测试比赛中,很多选手都编写了策略服务类,但是没有看到任何一个选手针对策略服务类进行了单独的测试。这里,还是以负载均衡的策略服务为例说明。4.1.1. 策略接口
首先,定义一个负载均衡策略接口。/*** 负载均衡策略接口*/public interface LoadBalanceStrategy {/*** 支持策略类型** @return 策略类型*/LoadBalanceStrategyType supportType();/*** 选择服务节点** @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest);}
4.1.2. 策略服务
其次,实现一个负载均衡策略服务,根据负载均衡策略类型选择对应的负载均衡策略来执行。/*** 负载均衡服务类*/public class LoadBalanceService {/** 负载均衡策略映射 */private final Map<LoadBalanceStrategyType, LoadBalanceStrategy> strategyMap;/*** 构造方法** @param strategyList 负载均衡策略列表*/public LoadBalanceService(List<LoadBalanceStrategy> strategyList) {strategyMap = new EnumMap<>(LoadBalanceStrategyType.class);for (LoadBalanceStrategy strategy : strategyList) {strategyMap.put(strategy.supportType(), strategy);}}/*** 选择服务节点** @param strategyType 策略类型* @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/public ServerNode selectNode(LoadBalanceStrategyType strategyType,List<ServerNode> serverNodeList, ClientRequest clientRequest) {// 获取负载均衡策略LoadBalanceStrategy strategy = strategyMap.get(strategyType);if (Objects.isNull(strategy)) {throw new BusinessException("负载均衡策略不存在");}// 执行负载均衡策略return strategy.selectNode(serverNodeList, clientRequest);}}
4.1.3. 策略实现
最后,实现一个随机负载均衡策略实现类。/*** 随机负载均衡策略类*/public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {/*** 支持策略类型** @return 策略类型*/@Overridepublic LoadBalanceStrategyType supportType() {return LoadBalanceStrategyType.RANDOM;}/*** 选择服务节点** @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/@Overridepublic ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {// 检查节点列表if (CollectionUtils.isEmpty(serverNodeList)) {return null;}// 计算随机序号int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();int randomIndex = RandomUtils.nextInt(0, totalWeight);// 查找对应节点for (ServerNode serverNode : serverNodeList) {int currentWeight = serverNode.getWeight();if (currentWeight > randomIndex) {return serverNode;}randomIndex -= currentWeight;}return null;}}
4.2. 方法1:联合测试法(不推荐) 很多时候,策略模式是用来优化if-else代码的。所以,采用联合测试法(策略服务和策略实现同时测试),能够最大限度地利用原有的单元测试代码。
/*** 负载均衡服务测试类*/@RunWith(PowerMockRunner.class)@PrepareForTest(RandomUtils.class)public class LoadBalanceServiceTest {/*** 测试: 选择服务节点-正常*/@Testpublic void testSelectNodeWithNormal() {// 模拟依赖方法PowerMockito.mockStatic(RandomUtils.class);PowerMockito.when(RandomUtils.nextInt(Mockito.eq(0), Mockito.anyInt())).thenReturn(9);// 调用测试方法ServerNode serverNode1 = new ServerNode(1L, 10);ServerNode serverNode2 = new ServerNode(2L, 20);ServerNode serverNode3 = new ServerNode(3L, 30);List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);ClientRequest clientRequest = new ClientRequest();RandomLoadBalanceStrategy randomLoadBalanceStrategy = new RandomLoadBalanceStrategy();LoadBalanceService loadBalanceService = new LoadBalanceService(Arrays.asList(randomLoadBalanceStrategy));ServerNode serviceNode = loadBalanceService.selectNode(LoadBalanceStrategyType.RANDOM,serverNodeList, clientRequest);Assert.assertEquals("服务节点不一致", serverNode1, serviceNode);// 验证依赖方法PowerMockito.verifyStatic(RandomUtils.class);int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();RandomUtils.nextInt(0, totalWeight);}}
策略模式的联合测试法主要有以下问题:
- 策略服务依赖于策略实现,需要了解策略实现的具体逻辑,才能写出策略服务的单元测试;
- 对于策略服务来说,该单元测试并不关心策略服务的实现,这是黑盒测试而不是白盒测试。
- strategyMap没有根据strategyList生成;
- strategyMap.get(strategyType)为空时,初始化一个RandomLoadBalanceStrategy。
/*** 负载均衡服务类*/public class LoadBalanceService {/** 负载均衡策略映射 */private final Map<LoadBalanceStrategyType, LoadBalanceStrategy> strategyMap;/*** 构造方法** @param strategyList 负载均衡策略列表*/public LoadBalanceService(List<LoadBalanceStrategy> strategyList) {strategyMap = new EnumMap<>(LoadBalanceStrategyType.class);}/*** 选择服务节点** @param strategyType 策略类型* @param serverNodeList 服务节点列表* @param clientRequest 客户请求* @return 服务节点*/public ServerNode selectNode(LoadBalanceStrategyType strategyType,List<ServerNode> serverNodeList, ClientRequest clientRequest) {// 获取负载均衡策略LoadBalanceStrategy strategy = strategyMap.get(strategyType);if (Objects.isNull(strategy)) {strategy = new RandomLoadBalanceStrategy();}// 执行负载均衡策略return strategy.selectNode(serverNodeList, clientRequest);}}
4.3. 方法2:独立测试法(推荐) 现在,先假设策略实现RandomLoadBalanceStrategy(随机负载均衡策略)不存在,直接对策略服务LoadBalanceService(负载均衡服务)独立测试,而且是分别对构造方法和selectNode(选择服务节点)方法进行独立测试。其中,测试构造方法是为了保证strategyMap构造逻辑没有问题,测试selectNode(选择服务节点)方法是为了保证选择策略逻辑没有问题。
/*** 负载均衡服务测试类*/public class LoadBalanceServiceTest {/*** 测试: 构造方法*/@Testpublic void testConstructor() {// 模拟依赖方法LoadBalanceStrategy loadBalanceStrategy = Mockito.mock(LoadBalanceStrategy.class);Mockito.doReturn(LoadBalanceStrategyType.RANDOM).when(loadBalanceStrategy).supportType();// 调用测试方法LoadBalanceService loadBalanceService = new LoadBalanceService(Arrays.asList(loadBalanceStrategy));Map<LoadBalanceStrategyType, LoadBalanceStrategy> strategyMap =Whitebox.getInternalState(loadBalanceService, "strategyMap");Assert.assertEquals("策略映射大小不一致", 1, strategyMap.size());Assert.assertEquals("策略映射对象不一致", loadBalanceStrategy, strategyMap.get(LoadBalanceStrategyType.RANDOM));// 验证依赖方法Mockito.verify(loadBalanceStrategy).supportType();}/*** 测试: 选择服务节点-正常*/@Testpublic void testSelectNodeWithNormal() {// 模拟依赖方法LoadBalanceStrategy loadBalanceStrategy = Mockito.mock(LoadBalanceStrategy.class);// 模拟依赖方法: loadBalanceStrategy.supportTypeMockito.doReturn(LoadBalanceStrategyType.RANDOM).when(loadBalanceStrategy).supportType();// 模拟依赖方法: loadBalanceStrategy.selectNodeServerNode serverNode = Mockito.mock(ServerNode.class);Mockito.doReturn(serverNode).when(loadBalanceStrategy).selectNode(Mockito.anyList(), Mockito.any(ClientRequest.class));// 调用测试方法List<ServerNode> serverNodeList = CastUtils.cast(Mockito.mock(List.class));ClientRequest clientRequest = Mockito.mock(ClientRequest.class);LoadBalanceService loadBalanceService = new LoadBalanceService(Arrays.asList(loadBalanceStrategy));Assert.assertEquals("服务节点不一致", serverNode,loadBalanceService.selectNode(LoadBalanceStrategyType.RANDOM, serverNodeList, clientRequest));// 验证依赖方法// 验证依赖方法: loadBalanceStrategy.supportTypeMockito.verify(loadBalanceStrategy).supportType();// 验证依赖方法: loadBalanceStrategy.selectNodeMockito.verify(loadBalanceStrategy).selectNode(serverNodeList, clientRequest);}}
其实,不只是策略模式,很多模式下都不建议联合测试,而是推荐采用独立的单元测试。因为单元测试是白盒测试——一种专注于自身代码逻辑的测试。
五、如何测试Lambda表达式
在有些单元测试中,Lambda表达式并不一定被执行,所以导致Lambda表达式没有被测试。 5.1. 案例代码 这里,以从ODPS中查询用户交易订单为例说明。5.1.1. 被测代码
交易订单查询服务,其中有一段转化订单的Lambda表达式。/*** 交易订单服务*/@Servicepublic class TradeOrderService {/** 注入依赖对象 *//** 交易ODPS服务 */@Autowiredprivate TradeOdpsService tradeOdpsService;/*** 查询交易订单** @param userId 用户标识* @param maxCount 最大数量* @return 交易订单列表*/public List<TradeOrderVO> queryTradeOrder(Long userId, Integer maxCount) {String format = ResourceHelper.getResourceAsString(getClass(), "query_trade_order.sql");String sql = String.format(format, userId, maxCount);return tradeOdpsService.executeQuery(sql, record -> {TradeOrderVO tradeOrder = new TradeOrderVO();tradeOrder.setId(record.getBigint("id"));// ...return tradeOrder;});}}
5.1.2. 依赖代码
封装了通用的ODPS查询方法。/*** 交易ODPS服务类*/@Slf4j@Servicepublic class TradeOdpsService {/** 注入依赖对象 *//** 交易ODPS */@Resource(name = "tradeOdps")private Odps tradeOdps;/*** 执行查询** @param <T> 模板类型* @param sql SQL语句* @param dataParser 数据解析器* @return 查询结果列表*/public <T> List<T> executeQuery(String sql, Function<Record, T> dataParser) {try {// 打印提示信息log.info("开始执行ODPS数据查询...");// 执行ODPS查询Instance instance = SQLTask.run(tradeOdps, sql);instance.waitForSuccess();// 获取查询结果List<Record> recordList = SQLTask.getResult(instance);if (CollectionUtils.isEmpty(recordList)) {log.info("完成执行ODPS数据查询: totalSize=0");return Collections.emptyList();}// 依次读取数据List<T> dataList = new ArrayList<>();for (Record record : recordList) {T data = dataParser.apply(record);if (Objects.nonNull(data)) {dataList.add(data);}}// 打印提示信息log.info("完成执行ODPS数据查询: totalSize={}", dataList.size());// 返回查询结果return dataList;} catch (OdpsException e) {log.warn("执行ODPS数据查询异常: sql={}", sql, e);throw new BusinessException("执行ODPS数据查询异常", e);}}}
5.2. 方法1:直接测试法(不推荐) 按照通用的单元测试方法进行测试,发现Lambda表达式没有被测试到。
/*** 交易订单服务测试类*/@RunWith(MockitoJUnitRunner.class)public class TradeOrderServiceTest {/** 定义静态常量 *//** 资源路径 */private static final String RESOURCE_PATH = "testTradeOrderService/";/** 模拟依赖对象 *//** 交易ODPS服务 */@Mockprivate TradeOdpsService tradeOdpsService;/** 定义测试对象 *//** 交易订单服务 */@InjectMocksprivate TradeOrderService tradeOrderService;/*** 测试: 查询交易订单-正常*/@Testpublic void testQueryTradeOrderWithNormal() {// 模拟依赖方法// 模拟依赖方法: tradeOdpsService.executeQueryList<TradeOrderVO> tradeOrderList = CastUtils.cast(Mockito.mock(List.class));Mockito.doReturn(tradeOrderList).when(tradeOdpsService).executeQuery(Mockito.anyString(), Mockito.any());// 调用测试方法Long userId = 12345L;Integer maxCount = 100;Assert.assertSame("交易订单列表不一致", tradeOrderList, tradeOrderService.queryTradeOrder(userId, maxCount));// 验证依赖方法// 验证依赖方法: tradeOdpsService.executeQueryString path = RESOURCE_PATH + "testQueryTradeOrderWithNormal/";String text = ResourceHelper.getResourceAsString(getClass(), path + "queryTradeOrder.sql");Mockito.verify(tradeOdpsService).executeQuery(Mockito.eq(text), Mockito.any());}}
5.3. 方法2:联合测试法(不推荐) 有人建议,可以把TradeOrderService(交易订单服务)和TradeOdpsService(交易ODPS服务)联合测试,这样就可以保证Lambda表达式被测试到。
/*** 交易订单服务测试类*/@RunWith(PowerMockRunner.class)@PrepareForTest({SQLTask.class})public class TradeOrderServiceTest {/** 定义静态常量 *//** 资源路径 */private static final String RESOURCE_PATH = "testTradeOrderService/";/** 模拟依赖对象 *//** 交易ODPS */@Mock(name = "tradeOdps")private Odps tradeOdps;/** 定义测试对象 *//** 交易ODPS服务 */@InjectMocksprivate TradeOdpsService tradeOdpsService = Mockito.spy(TradeOdpsService.class);/** 交易订单服务 */@InjectMocksprivate TradeOrderService tradeOrderService;/*** 测试: 查询交易订单-正常** @throws OdpsException ODPS异常*/@Testpublic void testQueryTradeOrderWithNormal() throws OdpsException {// 模拟依赖方法PowerMockito.mockStatic(SQLTask.class);// 模拟依赖方法: SQLTask.runInstance instance = Mockito.mock(Instance.class);PowerMockito.when(SQLTask.run(Mockito.eq(tradeOdps), Mockito.anyString())).thenReturn(instance);// 模拟依赖方法: SQLTask.getResultRecord record1 = PowerMockito.mock(Record.class);Record record2 = PowerMockito.mock(Record.class);List<Record> recordList = Arrays.asList(record1, record2);PowerMockito.when(SQLTask.getResult(instance)).thenReturn(recordList);// 模拟依赖方法: record.getStringMockito.doReturn(1L).when(record1).getBigint("id");Mockito.doReturn(2L).when(record2).getBigint("id");// 调用测试方法Long userId = 12345L;Integer maxCount = 100;List<TradeOrderVO> tradeOrderList = tradeOrderService.queryTradeOrder(userId, maxCount);String path = RESOURCE_PATH + "testQueryTradeOrderWithNormal/";String text = ResourceHelper.getResourceAsString(getClass(), path + "tradeOrderList.json");Assert.assertEquals("交易订单列表不一致", text, JSON.toJSONString(tradeOrderList));// 验证依赖方法PowerMockito.verifyStatic(SQLTask.class);// 验证依赖方法: SQLTask.runtext = ResourceHelper.getResourceAsString(getClass(), path + "queryTradeOrder.sql");SQLTask.run(tradeOdps, text);// 验证依赖方法: SQLTask.getResultSQLTask.getResult(instance);// 验证依赖方法: instance.waitForSuccessMockito.verify(instance).waitForSuccess();// 验证依赖方法: record.getStringMockito.verify(record1).getBigint("id");Mockito.verify(record2).getBigint("id");}}
主要问题: 需要了解TradeOdpsService.executeQuery(执行查询)方法的逻辑并构建单元测试用例,导致TradeOrderService.queryTradeOrder(查询交易订单)方法的单测测试用例非常复杂。 5.4. 方法3:重构测试法(推荐) 其实,只需要把这段Lambda表达式提取成一个convertTradeOrder(转化交易订单)方法,即可让代码变得清晰明了,又可以让代码更容易单元测试。
5.4.1. 重构代码
提取Lambda表达式为convertTradeOrder(转化交易订单)方法。/*** 交易订单服务类*/@Servicepublic class TradeOrderService {/** 注入依赖对象 *//** 交易ODPS服务 */@Autowiredprivate TradeOdpsService tradeOdpsService;/*** 查询交易订单** @param userId 用户标识* @param maxCount 最大数量* @return 交易订单列表*/public List<TradeOrderVO> queryTradeOrder(Long userId, Integer maxCount) {String format = ResourceHelper.getResourceAsString(getClass(), "query_trade_order.sql");String sql = String.format(format, userId, maxCount);return tradeOdpsService.executeQuery(sql, TradeOrderService2::convertTradeOrder);}/*** 转化交易订单** @param record ODPS记录* @return 交易订单*/private static TradeOrderVO convertTradeOrder(Record record) {TradeOrderVO tradeOrder = new TradeOrderVO();tradeOrder.setId(record.getBigint("id"));// ...return tradeOrder;}}
5.4.2. 测试用例
针对queryTradeOrder(查询交易订单)方法和convertTradeOrder(转化交易订单)方法分别进行单元测试。/*** 交易订单服务测试类*/@RunWith(MockitoJUnitRunner.class)public class TradeOrderServiceTest {/** 定义静态常量 *//** 资源路径 */private static final String RESOURCE_PATH = "testTradeOrderService/";/** 模拟依赖对象 *//** 交易ODPS服务 */@Mockprivate TradeOdpsService tradeOdpsService;/** 定义测试对象 *//** 交易订单服务 */@InjectMocksprivate TradeOrderService tradeOrderService;/*** 测试: 查询交易订单-正常*/@Testpublic void testQueryTradeOrderWithNormal() {// 模拟依赖方法// 模拟依赖方法: tradeOdpsService.executeQueryList<TradeOrderVO> tradeOrderList = CastUtils.cast(Mockito.mock(List.class));Mockito.doReturn(tradeOrderList).when(tradeOdpsService).executeQuery(Mockito.anyString(), Mockito.any());// 调用测试方法Long userId = 12345L;Integer maxCount = 100;Assert.assertSame("交易订单列表不一致", tradeOrderList, tradeOrderService.queryTradeOrder(userId, maxCount));// 验证依赖方法// 验证依赖方法: tradeOdpsService.executeQueryString path = RESOURCE_PATH + "testQueryTradeOrderWithNormal/";String text = ResourceHelper.getResourceAsString(getClass(), path + "queryTradeOrder.sql");Mockito.verify(tradeOdpsService).executeQuery(Mockito.eq(text), Mockito.any());}/*** 测试: 转化交易订单** @throws Exception 异常信息*/@Testpublic void testConvertTradeOrder() throws Exception {// 模拟依赖方法Long id = 12345L;Record record = Mockito.mock(Record.class);Mockito.doReturn(id).when(record).getBigint("id");// 调用测试方法TradeOrderVO tradeOrder = Whitebox.invokeMethod(TradeOrderService2.class, "convertTradeOrder", record);Assert.assertEquals("订单标识不一致", id, tradeOrder.getId());// 验证依赖方法Mockito.verify(record).getBigint("id");}}
六、如何测试链式调用
在日常编码过程中,很多人都喜欢使用链式调用,这样可以让代码变得更简洁。 6.1. 案例代码 这里,通过修改后的添加跨域支持代码来举例说明(原方法没有返回值)。/*** 跨域辅助类*/public class CorsHelper {/** 定义静态常量 *//** 最大生命周期 */private static final long MAX_AGE = 3600L;/*** 添加跨域支持** @param registry 跨域注册器* @return 跨域注册*/public static CorsRegistration addCorsMapping(CorsRegistry registry) {return registry.addMapping("/**").allowedOrigins("*").allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS").allowCredentials(true).maxAge(MAX_AGE).allowedHeaders("*");}}
6.2. 方法1:普通测试法(不推荐) 正常情况下,每一个依赖对象及其调用方法都要mock,编写的代码如下:
/*** 跨域辅助测试类*/public class CorsHelperTest {/*** 测试: 添加跨域支持*/@Testpublic void testAddCorsMapping() {// 模拟依赖方法CorsRegistry registry = Mockito.mock(CorsRegistry.class);CorsRegistration registration = Mockito.mock(CorsRegistration.class);Mockito.doReturn(registration).when(registry).addMapping(Mockito.anyString());Mockito.doReturn(registration).when(registration).allowedOrigins(Mockito.any());Mockito.doReturn(registration).when(registration).allowedMethods(Mockito.any());Mockito.doReturn(registration).when(registration).allowCredentials(Mockito.anyBoolean());Mockito.doReturn(registration).when(registration).maxAge(Mockito.anyLong());Mockito.doReturn(registration).when(registration).allowedHeaders(Mockito.any());// 调用测试方法Assert.assertEquals("跨域注册不一致", registration, CorsHelper.addCorsMapping(registry));// 验证依赖方法Mockito.verify(registry).addMapping("/**");Mockito.verify(registration).allowedOrigins("*");Mockito.verify(registration).allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");Mockito.verify(registration).allowCredentials(true);Mockito.verify(registration).maxAge(3600L);Mockito.verify(registration).allowedHeaders("*");}}
6.3. 方法2:利用RETURNS_DEEP_STUBS参数法(推荐) 对于链式调用,Mockito提供了更加简便的单元测试方法——提供Mockito.RETURNS_DEEP_STUBS参数,实现链式调用返回对象的自动mock。利用Mockito.RETURNS_DEEP_STUBS参数编写的测试用例如下:
/*** 跨域辅助测试类*/public class CorsHelperTest {/*** 测试: 添加跨域支持*/@Testpublic void testAddCorsMapping() {// 模拟依赖方法CorsRegistry registry = Mockito.mock(CorsRegistry.class, Answers.RETURNS_DEEP_STUBS);CorsRegistration registration = Mockito.mock(CorsRegistration.class);Mockito.when(registry.addMapping(Mockito.anyString()).allowedOrigins(Mockito.any()).allowedMethods(Mockito.any()).allowCredentials(Mockito.anyBoolean()).maxAge(Mockito.anyLong()).allowedHeaders(Mockito.any())).thenReturn(registration);// 调用测试方法Assert.assertEquals("跨域注册不一致", registration, CorsHelper.addCorsMapping(registry));// 验证依赖方法Mockito.verify(registry.addMapping("/**").allowedOrigins("*").allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS").allowCredentials(true).maxAge(3600L)).allowedHeaders("*");}}
代码说明:
- 在mock对象时,需要指定Mockito.RETURNS_DEEP_STUBS参数;
- 在mock方法时,采用when-then模式,when内容是链式调用,then内容是返回的值;
- 在verify方法时,只需要验证最后1次方法调用,verify内容是前n次链式调用;如果验证时某个方法调用的某个参数指定错误时,最后一个方法调用验证将因为这个mock对象没有方法调用而抛出异常。
/*** 跨域辅助测试类*/public class CorsHelperTest {/*** 测试: 添加跨域支持*/@Testpublic void testAddCorsMapping() {// 模拟依赖方法CorsRegistry registry = Mockito.mock(CorsRegistry.class);CorsRegistration registration = Mockito.mock(CorsRegistration.class, Answers.RETURNS_SELF);Mockito.doReturn(registration).when(registry).addMapping(Mockito.anyString());// 调用测试方法Assert.assertEquals("跨域注册不一致", registration, CorsHelper.addCorsMapping(registry));// 验证依赖方法Mockito.verify(registry).addMapping("/**");Mockito.verify(registration).allowedOrigins("*");Mockito.verify(registration).allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");Mockito.verify(registration).allowCredentials(true);Mockito.verify(registration).maxAge(3600L);Mockito.verify(registration).allowedHeaders("*");}}
代码说明:
- 在mock对象时,对于自返回对象,需要指定Mockito.RETURNS_SELF参数;
- 在mock方法时,无需对自返回对象进行mock方法,因为框架已经mock方法返回了自身;
- 在verify方法时,可以像普通测试法一样优美地验证所有方法调用。
- 普通测试法: mock调用方法语句较多;
- 利用RETURNS_DEEP_STUBS参数法: mock调用方法语句较少,适合于链式调用返回不同值;
- 利用RETURNS_SELF参数法: mock调用方法语句最少,适合于链式调用返回相同值。
七、如何测试相同参数返回不同值
在有些场景下,存在相同参数多次调用返回不同值的情况,比如:读取文本文件的readLine方法。 7.1. 案例代码 这里,以ODPS的RecordReader为例,读取每一行数据记录。/*** 读取数据** @param <T> 模板类型* @param recordReader 记录读取器* @param dataParser 数据解析器* @return 数据列表* @throws IOException IO异常*/public static <T> List<T> readData(RecordReader recordReader, Function<Record, T> dataParser) throws IOException {Record record;List<T> dataList = new ArrayList<>();while (Objects.nonNull(record = recordReader.read())) {T data = dataParser.apply(record);if (Objects.nonNull(data)) {dataList.add(data);}}return dataList;}
7.2. 测试用例 为了mock相同参数返回不同值,需要使用到Mockito.doReturn的可变数组功能。
/*** 测试: 读取数据-正常** @throws IOException IO异常*/@Testpublic void testReadDataWithNormal() throws IOException {// 模拟依赖方法// 模拟依赖方法: recordReader.readRecord record1 = Mockito.mock(Record.class);Record record2 = Mockito.mock(Record.class);TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);Mockito.doReturn(record1, record2, null).when(recordReader).read();// 模拟依赖方法: dataParser.applyFunction<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));Object object1 = new Object();Object object2 = new Object();Mockito.doReturn(object1).when(dataParser).apply(record1);Mockito.doReturn(object2).when(dataParser).apply(record2);// 调用测试方法List<Object> dataList = OdpsHelper.readData(recordReader, dataParser);Assert.assertEquals("数据列表不一致", Arrays.asList(object1, object2), dataList);// 验证依赖方法// 验证依赖方法: recordReader.readMockito.verify(recordReader, Mockito.times(3)).read();// 验证依赖方法: dataParser.applyMockito.verify(dataParser).apply(record1);Mockito.verify(dataParser).apply(record2);}
八、如何测试已变更的方法参数值
在单元测试中,我们通常通过ArgumentCaptor进行方法参数捕获并验证。但是,在有些情况下,我们捕获的可能是已经变更的方法参数,所以无法对这些方法参数值进行验证。 8.1. 案例代码 这里,以分批读取并保存ODPS数据为例说明。其中,dataList在每次存储后,都进行了一次清除操作。/*** 读取数据** @param <T> 模板类型* @param recordReader 记录读取器* @param batchSize 批量大小* @param dataParser 数据解析器* @param dataStorage 数据存储器* @throws IOException IO异常*/public static <T> void readData(RecordReader recordReader, int batchSize,Function<Record, T> dataParser, Consumer<List<T>> dataStorage) throws IOException {// 依次读取数据Record record;List<T> dataList = new ArrayList<>(batchSize);while (Objects.nonNull(record = recordReader.read())) {// 解析添加数据T data = dataParser.apply(record);if (Objects.nonNull(data)) {dataList.add(data);}// 批量存储数据if (dataList.size() == batchSize) {dataStorage.accept(dataList);dataList.clear();}}// 存储剩余数据if (CollectionUtils.isNotEmpty(dataList)) {dataStorage.accept(dataList);dataList.clear();}}
8.2. 问题测试 通常情况下,我们利用ArgumentCaptor编写的测试用例如下:
/*** 测试: 读取数据-正常** @throws IOException IO异常*/@Testpublic void testReadDataWithNormal() throws IOException {// 模拟依赖方法// 模拟依赖方法: recordReader.readRecord record1 = Mockito.mock(Record.class);Record record2 = Mockito.mock(Record.class);TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);Mockito.doReturn(record1, record2, null).when(recordReader).read();// 模拟依赖方法: dataParser.applyFunction<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));Object object1 = new Object();Object object2 = new Object();Mockito.doReturn(object1).when(dataParser).apply(record1);Mockito.doReturn(object2).when(dataParser).apply(record2);// 调用测试方法int batchSize = 2;Consumer<List<Object>> dataStorage = CastUtils.cast(Mockito.mock(Consumer.class));OdpsHelper.readData(recordReader, batchSize, dataParser, dataStorage);// 验证依赖方法// 验证依赖方法: recordReader.readMockito.verify(recordReader, Mockito.times(3)).read();// 验证依赖方法: dataParser.applyMockito.verify(dataParser).apply(record1);Mockito.verify(dataParser).apply(record2);// 验证依赖方法: dataStorage.testArgumentCaptor<List<Object>> dataListCaptor = CastUtils.cast(ArgumentCaptor.forClass(List.class));Mockito.verify(dataStorage).accept(dataListCaptor.capture());Assert.assertEquals("数据列表不一致", Arrays.asList(object1, object2), dataListCaptor.getValue());}
执行该单元测试后,会出现以下错误:
java.lang.AssertionError: 数据列表不一致 expected:<[java.lang.Object@7eaa2bc6, java.lang.Object@6dae70f9]> but was:<[]>
因为,我们捕获的方法参数dataList只是一个对象引用,其数据内容早已被clear方法清除干净了。
8.3. 正确测试
对于这种情况,我们可以利用Mockito.doAnswer来保存这些临时值,最后再进行统一的数据验证。
/*** 测试: 读取数据-正常** @throws IOException IO异常*/@Testpublic void testReadDataWithNormal() throws IOException {// 模拟依赖方法// 模拟依赖方法: recordReader.readRecord record1 = Mockito.mock(Record.class);Record record2 = Mockito.mock(Record.class);TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);Mockito.doReturn(record1, record2, null).when(recordReader).read();// 模拟依赖方法: dataParser.applyFunction<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));Object object1 = new Object();Object object2 = new Object();Mockito.doReturn(object1).when(dataParser).apply(record1);Mockito.doReturn(object2).when(dataParser).apply(record2);// 模拟依赖方法: dataStorage.testList<Object> dataList = new ArrayList<>();Consumer<List<Object>> dataStorage = CastUtils.cast(Mockito.mock(Consumer.class));Mockito.doAnswer(invocation -> dataList.addAll(invocation.getArgument(0))).when(dataStorage).accept(Mockito.anyList());// 调用测试方法int batchSize = 2;OdpsHelper.readData(recordReader, batchSize, dataParser, dataStorage);Assert.assertEquals("数据列表不一致", Arrays.asList(object1, object2), dataList);// 验证依赖方法// 验证依赖方法: recordReader.readMockito.verify(recordReader, Mockito.times(3)).read();// 验证依赖方法: dataParser.applyMockito.verify(dataParser).apply(record1);Mockito.verify(dataParser).apply(record2);// 验证依赖方法: dataStorage.testMockito.verify(dataStorage).accept(Mockito.anyList());}
九、如何测试相同返回值的代码分支
在业务代码中,经常会出现不同的代码分支返回相同值的情况。这个时候,仅通过验证返回值是没法判断是否命中了对应的代码分支的。那么,这种情况如何进行单元测试呢? 9.1. 案例代码 这里,以灰度发布服务判定方法为例说明。/*** 灰度发布服务类*/@Slf4j@Servicepublic class GrayReleaseService {/** 定义静态常量 *//** 灰度发布分子 */private static final long GRAY_NUMERATOR = 0L;/** 灰度发布分母 */private static final long GRAY_DENOMINATOR = 10000L;/** 注入依赖对象 *//** 灰度发布配置 */@Autowiredprivate GrayReleaseConfig grayReleaseConfig;/*** 是否灰度发布** @param key 主键* @param channel 渠道* @param userId 用户标识* @param value 取值* @return 判断结果*/public boolean isGrayRelease(String key, String channel, String userId, Object value) {// 判断灰度发布取值if (Objects.isNull(value)) {log.info("命中灰度取值为空");return false;}// 获取灰度发布映射Map<String, GrayReleaseItem> grayReleaseMap = grayReleaseConfig.getGrayReleaseMap();if (MapUtils.isEmpty(grayReleaseMap)) {log.info("命中灰度发布映射为空");return false;}// 获取灰度发布项GrayReleaseItem grayReleaseItem = grayReleaseMap.get(key);if (Objects.isNull(grayReleaseItem)) {log.info("命中灰度发布映项为空: key={}", key);return false;}// 判断渠道白名单Set<String> channelWhiteSet = grayReleaseItem.getChannelWhiteSet();if (CollectionUtils.isNotEmpty(channelWhiteSet) && channelWhiteSet.contains(channel)) {log.info("命中渠道白名单灰度: key={}, channel={}", key, channel);return true;}// 判断用户白名单Set<String> userIdWhiteSet = grayReleaseItem.getUserIdWhiteSet();if (CollectionUtils.isNotEmpty(userIdWhiteSet) && userIdWhiteSet.contains(userId)) {log.info("命中用户白名单灰度: key={}, userId={}", key, userId);return true;}// 判断灰度发布比例long grayNumerator = Optional.ofNullable(grayReleaseItem.getGrayNumerator()).orElse(GRAY_NUMERATOR);long grayDenominator = Optional.ofNullable(grayReleaseItem.getGrayDenominator()).orElse(GRAY_DENOMINATOR);boolean isGray = Math.abs(Objects.hashCode(value)) % grayDenominator <= grayNumerator;log.info("命中灰度发布比例: key={}, value={}, isGray={}", key, value, isGray);return isGray;}}
9.2. 普通测试法(不推荐) 这里,只测试了命中渠道白名单的情况。
/*** 灰度发布服务测试类*/@RunWith(MockitoJUnitRunner.class)public class GrayReleaseServiceTest {/** 模拟依赖方法 *//** 灰度发布配置 */@Mockprivate GrayReleaseConfig grayReleaseConfig;/** 定义测试对象 *//** 灰度发布服务 */@InjectMocksprivate GrayReleaseService grayReleaseService;/*** 测试: 是否灰度发布-命中渠道白名单*/@Testpublic void testIsGrayReleaseWithChannelWhiteSet() {// 模拟依赖方法GrayReleaseItem grayReleaseItem = new GrayReleaseItem();grayReleaseItem.setChannelWhiteSet(Sets.newHashSet("alipay"));grayReleaseItem.setUserIdWhiteSet(Sets.newHashSet("123456"));Map<String, GrayReleaseItem> grayReleaseMap = Maps.newHashMap();grayReleaseMap.put("test", grayReleaseItem);Mockito.doReturn(grayReleaseMap).when(grayReleaseConfig).getGrayReleaseMap();// 调用测试方法String key = "test";String channel = "alipay";String userId = "123456";Object value = 1234567890L;Assert.assertTrue("判断结果不为真", grayReleaseService.isGrayRelease(key, channel, userId, value));// 验证依赖方法Mockito.verify(grayReleaseConfig).getGrayReleaseMap();}}
在一次代码重构中,把"判断用户白名单"放在"判断渠道白名单"之前,这个单元测试是无法检测出来的。 9.3. 验证测试法(推荐) 通过对mock方法的验证,可以相对准确地确定命中的代码分支。
/*** 灰度发布服务测试类*/@RunWith(MockitoJUnitRunner.class)public class GrayReleaseServiceTest {/** 模拟依赖方法 *//** 灰度发布配置 */@Mockprivate GrayReleaseConfig grayReleaseConfig;/** 定义测试对象 *//** 灰度发布服务 */@InjectMocksprivate GrayReleaseService grayReleaseService;/*** 测试: 是否灰度发布-命中渠道白名单*/@Testpublic void testIsGrayReleaseWithChannelWhiteSet() {// 模拟依赖方法// 模拟依赖方法: grayReleaseItem.getChannelWhiteSetGrayReleaseItem grayReleaseItem = Mockito.mock(GrayReleaseItem.class);Mockito.doReturn(Sets.newHashSet("alipay")).when(grayReleaseItem).getChannelWhiteSet();// 模拟依赖方法: grayReleaseItem.getUserIdWhiteSetMockito.doReturn(Sets.newHashSet("123456")).when(grayReleaseItem).getUserIdWhiteSet();// 模拟依赖方法: grayReleaseConfig.getGrayReleaseMapMap<String, GrayReleaseItem> grayReleaseMap = Maps.newHashMap();grayReleaseMap.put("test", grayReleaseItem);Mockito.doReturn(grayReleaseMap).when(grayReleaseConfig).getGrayReleaseMap();// 调用测试方法String key = "test";String channel = "alipay";String userId = "123456";Object value = 1234567890L;Assert.assertTrue("判断结果不为真", grayReleaseService.isGrayRelease(key, channel, userId, value));// 验证依赖方法// 验证依赖方法: grayReleaseConfig.getGrayReleaseMapMockito.verify(grayReleaseConfig).getGrayReleaseMap();// 验证依赖方法: grayReleaseItem.getChannelWhiteSetMockito.verify(grayReleaseItem).getChannelWhiteSet();// 验证依赖对象Mockito.verifyNoMoreInteractions(grayReleaseConfig, grayReleaseItem);}}
如果把"判断用户白名单"放在"判断渠道白名单"之前,这个单元测试会报出以下错误日志:
Wanted but not invoked:grayReleaseItem.getChannelWhiteSet();
错误日志告诉我们,grayReleaseItem.getChannelWhiteSet方法并没有被调用,所以不可能命中渠道白名单代码分支。 9.4. 日志测试法(推荐) 对于有日志打印的代码,可以通过验证日志方法来确定命中的代码分支,而且这种验证方法是非常简单直白的。如果没有日志打印,我们也可以添加日志打印(可能会涉及日志存储成本的增加)。
/*** 灰度发布服务测试类*/@RunWith(MockitoJUnitRunner.class)public class GrayReleaseServiceTest {/** 模拟依赖方法 *//** 日志器 */@Mockprivate Logger log;/** 灰度发布配置 */@Mockprivate GrayReleaseConfig grayReleaseConfig;/** 定义测试对象 *//** 灰度发布服务 */@InjectMocksprivate GrayReleaseService grayReleaseService;/*** 在测试前*/@Beforepublic void beforeTest() {FieldHelper.writeStaticFinalField(GrayReleaseService.class, "log", log);}/*** 测试: 是否灰度发布-命中渠道白名单*/@Testpublic void testIsGrayReleaseWithChannelWhiteSet() {// 模拟依赖方法GrayReleaseItem grayReleaseItem = new GrayReleaseItem();grayReleaseItem.setChannelWhiteSet(Sets.newHashSet("alipay"));grayReleaseItem.setUserIdWhiteSet(Sets.newHashSet("123456"));Map<String, GrayReleaseItem> grayReleaseMap = Maps.newHashMap();grayReleaseMap.put("test", grayReleaseItem);Mockito.doReturn(grayReleaseMap).when(grayReleaseConfig).getGrayReleaseMap();// 调用测试方法String key = "test";String channel = "alipay";String userId = "123456";Object value = 1234567890L;Assert.assertTrue("判断结果不为真", grayReleaseService.isGrayRelease(key, channel, userId, value));// 验证依赖方法// 验证依赖方法: grayReleaseConfig.getGrayReleaseMapMockito.verify(grayReleaseConfig).getGrayReleaseMap();// 验证依赖方法: log.infoMockito.verify(log).info("命中渠道白名单灰度: key={}, channel={}", key, channel);// 验证依赖对象Mockito.verifyNoInteractions(log, grayReleaseConfig);}}
如果把"判断用户白名单"放在"判断渠道白名单"之前,这个单元测试会报出以下错误日志:
Argument(s) are different! Wanted:log.info("命中渠道白名单灰度: key={}, channel={}","test","alipay");-> at ...Actual invocations have different arguments:log.info("命中用户白名单灰度: key={}, userId={}","test","123456");
错误日志告诉我们,我们期望命中 渠道白名单 灰度 代码分支,实际却命中的是 用户白名单灰度 代码分支。
十、如何测试多线程并发编程
Java多线程并发编程,就是通过多个线程同时执行多个任务来缩短执行时间、提高执行效率的方法。在JDK1.8中,新增了CompletableFuture类,实现了对任务编排的能力——可以轻松地组织不同任务的运行顺序、规则及方式。 10.1. 案例代码 这里,以并行获取批量交易订单为例说明。/*** 交易订单服务类*/@Slf4j@Servicepublic class TradeOrderService {/** 定义静态常量 *//** 等待时间(毫秒) */private static final long WAIT_TIME = 1000L;/** 注入依赖对象 *//** 交易订单DAO */@Autowiredprivate TradeOrderDAO tradeOrderDAO;/** 执行器服务 */@Autowiredprivate ExecutorService executorService;/*** 获取交易订单列表** @param orderIdList 订单标识列表* @return 交易订单列表*/public List<TradeOrderVO> getTradeOrders(List<Long> orderIdList) {// 检查订单标识列表if (CollectionUtils.isEmpty(orderIdList)) {return Collections.emptyList();}// 获取交易订单期望List<CompletableFuture<TradeOrderVO>> futureList = orderIdList.stream().map(this::getTradeOrder).collect(Collectors.toList());// 聚合交易订单期望CompletableFuture<List<TradeOrderVO>> joinFuture =CompletableFuture.allOf(futureList.toArray(new CompletableFuture[0])).thenApply(v -> futureList.stream().map(CompletableFuture::join).collect(Collectors.toList()));// 返回交易订单列表try {return joinFuture.get(WAIT_TIME, TimeUnit.MILLISECONDS);} catch (InterruptedException e) {Thread.currentThread().interrupt();log.warn("获取订单中断异常", e);throw new BusinessException("获取订单中断异常", e);} catch (ExecutionException | TimeoutException | RuntimeException e) {log.warn("获取订单其它异常", e);throw new BusinessException("获取订单其它异常", e);}}/*** 获取交易订单** @param orderId 订单标识* @return 交易订单期望*/private CompletableFuture<TradeOrderVO> getTradeOrder(Long orderId) {return CompletableFuture.supplyAsync(() -> tradeOrderDAO.get(orderId), executorService).thenApply(TradeOrderService::convertTradeOrder);}/*** 转化交易订单** @param tradeOrder 交易订单DO* @return 交易订单VO*/private static TradeOrderVO convertTradeOrder(TradeOrderDO tradeOrder) {TradeOrderVO tradeOrderVO = new TradeOrderVO();tradeOrderVO.setId(tradeOrder.getId());// ...return tradeOrderVO;}}
10.2. 测试用例 对于多线程并发编程,如果采集mock静态方法的方式进行单元测试,将会使单元测试用例变得非常复杂。通过实践总结,采用注入线程池的方式,将会使单元测试用例变得非常简单。
/*** 交易订单服务测试类*/@RunWith(MockitoJUnitRunner.class)public class TradeOrderServiceTest {/** 定义静态常量 *//** 资源路径 */private static final String RESOURCE_PATH = "testTradeOrderService/";/** 模拟依赖对象 *//** 交易订单DAO */@Mockprivate TradeOrderDAO tradeOrderDAO;/** 执行器服务 */@Spyprivate ExecutorService executorService = Executors.newFixedThreadPool(10);/** 定义测试对象 *//** 交易订单服务 */@InjectMocksprivate TradeOrderService tradeOrderService;/*** 测试: 获取交易订单列表-正常*/@Testpublic void testGetTradeOrdersWithNormal() {// 模拟依赖方法// 模拟依赖方法: tradeOrderDAO.getString path = RESOURCE_PATH + "testGetTradeOrdersWithNormal/";String text = ResourceHelper.getResourceAsString(getClass(), path + "tradeOrderMap.json");Map<Long, TradeOrderDO> tradeOrderMap = JSON.parseObject(text, new TypeReference<Map<Long, TradeOrderDO>>() {});Mockito.doAnswer(invocation -> tradeOrderMap.get(invocation.getArgument(0))).when(tradeOrderDAO).get(Mockito.anyLong());// 调用测试方法text = ResourceHelper.getResourceAsString(getClass(), path + "orderIdList.json");List<Long> orderIdList = JSON.parseArray(text, Long.class);List<TradeOrderVO> tradeOrderList = tradeOrderService.getTradeOrders(orderIdList);text = ResourceHelper.getResourceAsString(getClass(), path + "tradeOrderList.json");Assert.assertEquals("交易订单列表不一致", text, JSON.toJSONString(tradeOrderList));// 验证依赖方法// 验证依赖方法: tradeOrderDAO.getArgumentCaptor<Long> orderIdCaptor = ArgumentCaptor.forClass(Long.class);Mockito.verify(tradeOrderDAO, Mockito.atLeastOnce()).get(orderIdCaptor.capture());Assert.assertEquals("订单标识列表不一致", orderIdList, orderIdCaptor.getAllValues());}}
十一、附录
11.1. 引入Maven单测包<dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.1</version><scope>test</scope></dependency><dependency><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId><version>3.3.3</version><scope>test</scope></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>
11.2. 使用到的工具方法
11.2.1. 以字符串方式获取资源
ResourceHelper.getResourceAsString(以字符串方式获取资源)通过Apache的IOUtils.toString方法实现,提供以字符串方式获取资源的功能。/*** 资源辅助类*/public final class ResourceHelper {/*** 以字符串方式获取资源** @param clazz 类* @param name 资源名称* @return 字符串*/public static <T> String getResourceAsString(Class<T> clazz, String name) {try (InputStream is = clazz.getResourceAsStream(name)) {return IOUtils.toString(is, StandardCharsets.UTF_8);} catch (IOException e) {throw new IllegalArgumentException(String.format("以字符串方式获取资源(%s)异常", name), e);}}}
11.2.2. 写入静态常量字段
FieldHelper.writeStaticFinalField(写入静态常量字段)通过Apache的FieldUtils相关方法实现,提供写入静态常量字段的功能。/*** 字段辅助类*/public final class FieldHelper {/*** 写入静态常量字段** @param clazz 类* @param fieldName 字段名称* @param fieldValue 字段取值*/public static void writeStaticFinalField(Class<?> clazz, String fieldName, Object fieldValue) {try {Field field = clazz.getDeclaredField(fieldName);FieldUtils.removeFinalModifier(field);FieldUtils.writeStaticField(field, fieldValue, true);} catch (NoSuchFieldException | IllegalAccessException e) {throw new UnsupportedOperationException("写入静态常量字段异常", e);}}}
后记
其实在很久之前,有人就希望我整理一个单元测试案例库。我迟迟没有行动,主要原因如下:- 单元测试案例是无穷无尽的,如何系统化地呈现给读者是个大工程;
- 单元测试案例必须典型、合理、有意义,如何构建这些案例也很消耗精力。
《单测案例》 单元测试百家说,往期系列文章: 收藏!Java编程技巧之单元测试用例编写流程 Java单元测试技巧之JSON序列化
案例总结方法多。
芳草满园花满目,
绿肥红瘦自斟酌。
那些年,我们写过的无效单元测试
Java编程技巧之单元测试用例简化方法(内含案例)
Java工程师必读手册工匠追求“术”到极致,其实就是在寻“道”,且离悟“道”也就不远了,亦或是已经得道,这就是“工匠精神”——一种追求“以术得道”的精神。如果一个工匠只满足于“术”,不能追求“术”到极致去悟“道”,那只是一个靠“术”养家糊口的工匠而已。作者根据多年来的实践探索,总结了大量的Java编码之“术”,试图阐述出心中的Java编码之“道”。
点击阅读原文查看详情。