概述
软件的工程性体现在质量与效率。单测是构成软件质量的第一道防线,而单测覆盖率是软件质量的重要指标之一。 编写容易测试的代码,可带来更佳的单测覆盖率,间接提升开发效率。
为什么程序员不大写单测呢? 主要有如下原因:
- 习惯于将细小的重要业务点重复性地混杂在应用中。 结果是:难以对那些重要的业务点编写单测。
- 习惯于编写“一泻千里”的大函数大方法。往往需要花费至少1.5倍的力气去编写一段测试代码,合起来就是2.5倍的开发量。基于工期紧迫,又有多少人愿意费力不讨好呢?
- 习惯于编写耦合外部状态的方法。这是面向对象方法论的一个直接结果,但是也可以通过一个小技巧来改善。
- 习惯于将外部依赖耦合到方法中。这样就需要花费力气去mock外部依赖以及一堆单调乏味的mock代码,同样会使单测难度增加和开发量大增。
针对上述情况,使用“代码语义化”、“分离独立逻辑”、“分离实例状态”、“表达与执行分离”、“参数对象”、“分离纯函数”、“面向接口编程”的技巧,用于编写更容易测试的代码。
技巧
代码语义化
在工程中,常常多处看到类似无语义的代码:
if (state.equals(5)) { // code .... }
这段代码有两个问题:(1) 无语义,易重复; (2) 容易引起 NPE。 state.equals(5) 是想表达什么业务语义呢? 在不同领域里,有不同的含义。比如用于订单状态,可用于表达已付款。那么,代码里就应该明确表达这一含义,新建一个类 OrderStateUtil 及 isOrderPaid() ,把这段代码放进去;此外,如果 state = null,会引起 NPE,因此保险的写法是 Integer.valueOf(5).equals(state) 。 这段代码可写作:
public class OrderStateUtil { public static isOrderPaid() { return Integer.valueOf(State.ISPAID).equals(state); } }
这些,就可以对这段代码进行测试,并且多处放心引用。 像这样的代码,可称之“业务点”。 业务系统中充满着大量这样的细小的业务点。将业务点抽离出来,一则可以大量复用,二则可以任意组合, 就能避免系统重构时需要改多处的问题了。
将单纯的业务点从方法中分离出来。
分离独立逻辑
独立逻辑是不依赖于任何外部服务依赖的业务逻辑或通用逻辑,符合“相同输入运行任意次总是得到相同输出”的函数模型。独立逻辑容易编写单测,然而很多开发者却习惯把大段的独立逻辑放在一个大的流程方法里导致单测难写。来看这段放在流程方法里的代码:
if(!OrderUtils.isNewOrderNo(param.getOrderNo())){ deliveryParam.setItemIds(param.getItemIds().stream().map(itemId->itemId.intValue()).collect(Collectors.toList())); }else { deliveryParam.setItemIds(param .getItemIds() .stream() .map( x -> { if (orderItems.stream().anyMatch(orderItem -> x.equals(orderItem.getTcOrderItemId()))) { return orderItems .stream() .filter(orderItem -> x.equals(orderItem.getTcOrderItemId())) .map(orderItem -> orderItem.getId()) .collect(Collectors.toList()).get(0); } else { return x.intValue(); } } ).collect(Collectors.toList()) ); }
这段代码本质上就是获取itemIds并设置参数对象,由于嵌入到方法中,导致难以单测,且增大所在方法的长度。此外,不必要地使用stream的双重循环,导致代码难以理解和维护。如果这段逻辑非常重要,将一段未测的逻辑放在每日调用百万次的接口里,那简直是存侥幸心理,犯兵家之忌。应当抽离出来,创建成一个纯函数:
private List<Integer> getItemIds(DeliveryParamV2 param, List<OrderItem> orderItems) { if(!OrderUtils.isNewOrderNo(param.getOrderNo())){ return StreamUtil.map(param.getItemIds(), Long::intValue); } Map<Long, Integer> itemIdMap = orderItems.stream().collect( Collectors.toMap(OrderItem::getTcOrderItemId, OrderItem::getId)); return StreamUtil.map(param.getItemIds(), itemId -> itemIdMap.getOrDefault(itemId, itemId.intValue())); } public class StreamUtil { public static <T,R> List<R> map(List<T> dataList, Function<T,R> getData) { if (dataList == null || dataList.isEmpty()) { return new ArrayList(); } return dataList.stream().map(getData).collect(Collectors.toList()); } }
getItemIds 是纯函数,容易编写单测,而原来的一段代码转化为一行调用 deliveryParam.setItemIds(getItemIds(param, orderItems)); 缩短了业务方法的长度。这里封装了一个更安全的 StreamUtil.map , 是为了防止NPE。
将独立逻辑和通用逻辑从方法流程中分离出来。
分离实例状态
在博文 “使用Java函数接口及lambda表达式隔离和模拟外部依赖更容易滴单测” 的隔离依赖配置实际上已经给出了一个例子。 开发人员习惯于将类的实例变量在类方法中直接引用,而这样做的后果就是破坏了方法的通用性和纯粹性。改进的方法其实很简单:编写一个纯函数,将实例变量或实例对象作为参数传入,然后编写一个“外壳函数”,调用这个函数实现功能。这样既能保证对于外部一致的访问接口,又能保证内部实现的通用性和纯粹性,且更容易单测。
分离外部服务调用
现在我们进入正题。 一环扣一环的外部服务调用,正是使单测编写变得困难的主要因素。 在 “使用Java函数接口