深入探究单元测试编写
单元测试是确保软件质量、抵挡BUG枪林弹雨的最基本而有效的第一道防线和盾牌。那么,如何更好地编写单测来确保代码质量呢?
单测覆盖范围###
数据访问层dao测试####
对于使用了 ORM 或 Semi-ORM 来直接访问数据库的应用来说,DAO 测试是必要的,用来验收数据访问框架与SQL语句的联合正确性(通过联合正确性通常也意味着数据访问框架及SQL语句都是OK的)。严格来说,由于依赖外部环境,DAO测试不能算是单测。不过考虑到数据访问层是应用的基石,从实践意义上最好将其归类为必不可少的单测组成之一。
独立方法的测试####
比如参数校验、不依赖外部服务的业务判断逻辑、工具方法等;独立方法的测试采用“输入-输出模型”,即给定一组输入测试数据集,调用指定方法应当生成指定的输出数据集。 可以通过工具自动生成独立方法的单测类文件,然后填充参数和断言修改成真正有效的单测。
含外部服务依赖的测试####
通常会采用 Mock 框架来做相关单测。有一种技巧可以避免使用 Mock, 即:将含外部服务依赖的方法拆分为三段式: 构建参数子方法 buildParams, 调用服务子方法 callService, 构建返回结果子方法 buildResult 。 其中 buildParams,buildResult 通常是不含依赖服务的方法,可以按照独立方法的测试来进行,而且很可能是可复用的方法,不需要写和测试; 而 callService 是单行调用,用接口测试覆盖更好一些。这样就将 (3) 转化为 (2) 。当然,这种拆分粒度很细,对于喜欢写长方法的筒鞋可能不太适应。
另一种技巧是,可以通过函数接口来隔离外部调用依赖,通过 lambda 表达式来模拟外部依赖的返回值。详情可参阅:“使用Java函数接口及lambda表达式隔离和模拟外部依赖更容易滴单测”
不含条件和循环分支的纯顺序逻辑####
通过接口用例测试来确保是正确的,不通过单测来检验。
单测与测试效率###
短链测试与长链测试####
如果存在一种方法,能够不费事地很快检测出系统中的代码问题,那么何乐而不为呢?单测正是这样一种测试方法。
测试可分为“短链测试”和“长链测试”。其中短链测试不需要依赖外部服务,仅对短小的一段代码进行测试,写完后几乎不需要准备就能立即运行完成,而且当运行出错时,几乎可以很快定位出问题的位置;长链测试则在写完后需要花费时间来准备数据、环境和外部服务,运行时间更长,而且运行出错时,往往要花更多时间来进行调试。
单测是一种短链测试,而接口测试则是一种长链测试。显然,如果单测覆盖得越密集,那么就能更快速有效地覆盖到更多代码; 因此, 优先推荐编写单测而非接口测试。
单测与接口测试的平衡####
过犹不及。并不是越多单测效果就越好。当单测密集度达到一定程度时,所起的质量检测效果就会趋于平缓。这是由于: (1) 依赖外部服务或组件的完整流程是单测难以覆盖的; (2) 性能和累积性问题是单测无法覆盖的; (3) 有些情况下,一个接口测试可以起到十个单测的作用。 因此,适当地使用接口测试作为单测的补充,是非常重要的。
一般来说,单测用于密集覆盖条件分支和循环分支,而接口测试用于覆盖顺序流程和完整流程。在时间比较紧迫的情形下,单测覆盖核心方法和主要流程,其它可暂用接口测试来覆盖。
单测与动态语言####
可以选择动态语言能够更加便捷地编写测试。动态语言更好地支持 Map, List 等常见容器创建和遍历的简洁语法,更容易地访问对象及其属性与方法,更方便地操控字符串、文件、SQL等。
与Java集成比较友好的是Groovy语言。Groovy 语法类似 Python 的简洁,又可以几乎无缝访问Java类库,避免了不必要的适配和迁移工作。详情可参阅: “使用Groovy+Spock轻松写出更简洁的单测”。
单测框架####
JUnit 和 TestNg 是 Java 阵营的两个不分伯仲的单测框架。不过,我所接触的开发同学似乎更倾向于使用 TestNg , 更倾向于后起之秀。后起之秀通常有着新的设计理念和做法,比如 TestNg 推出时基于注解的测试(去掉不必要的规则)、参数化测试、 根据配置文件对测试集合分类运行、依赖测试、支持并发运行等, 对大型测试套件更友好的支持和灵活性,恐怕当时让老牌劲旅 JUnit 大惊失色吧!
当选择单测框架时,除了考虑实用性和新颖性,更多也要入乡随俗,考虑团队成员的意愿。比如使用 JUnit 编写了一个很好的业务测试框架, 可是团队成员都倾向于使用 TestNg , 那么, 当推广这个业务测试框架时,就会受到不必要的阻力; 而如果使用 TestNg 开发,首先从心理上就得到了认同。
当然,也要关注更优秀的测试框架。 比如 TestNg 本来已经让 JUnit “大惊失色”了,可是杀出个基于JUnit 框架的 Spock , 能够用更简洁的方式来编写单测,那么,也可以“回归一下”的! 事情就是这样曲折螺旋上升式地发展的啊!
能够将一项新技术推广到团队,是技术人员引以为傲的一件事儿; 即使由于种种原因未能推广,也能从中学到新的思想和做法。尝试新技术,是技术人员的优秀品质之一。
单测技巧###
空边界与异常####
空、边界、异常是三种常见的单测点,是BUG多发地。因此编写代码和单测时一定要注意覆盖这三种情况;当对象为空会怎样?涉及容器操作时,算法是否会超过边界? 是否抛出了指定异常?
值得注意的是, 在检测方法调用抛出异常时,在调用方法时,要加一行 fail(NOT_THROW_EXCEPTION)。 这是由于, 当方法抛出异常时,会被捕获,然后 assert 异常的返回码和返回消息,这是正确的一方面; 当方法应该抛出异常却没有抛出异常时,就应该让单测失败; 如果没有 fail(NOT_THROW_EXCEPTION) 这一行, 单测就会通过。
配置式编程####
编写单测时,最好能够批量进行测试,将测试数据集与测试代码分离。当新增测试用例时,只要新增测试数据集即可,测试主体代码完全不用动。Spock 的 expect-where 子句就实现了测试用例的配置化。expect 子句是测试代码,where 子句是测试用例集合。同样,TestNg 的 DataProvider 也是为了实现测试用例集合与测试代码分离。
编写更易测试的短小方法####
前面谈到,单测属于短链测试,其特色就是“短、平、快”。 如果单测也要写婆婆妈妈的一堆Mock,那么编写单测很快就会令人厌烦、费时不讨好。因此,尽可能识别和分离每个微小业务点,形成短小方法,然后再进行单测。
编写短小方法也可以增进可扩展能力的提升。当持续不止地识别和分离每个微小业务点,就会对业务中的可变与不变更加敏感,而软件可扩展性的本质就是预见、识别和分离可变与不变。
分离易测试的部分####
开发同学写代码时常常将通用性的技术逻辑与定制的业务逻辑混在一起,将独立方法和外部依赖混在一起,这增加了编写单测的很多困难。
如下代码所示:
/**
* 发送消息
*/
public void sendMsg(String msg) {
int count = 3;
while (count > 0) {
LogUtils.info(log, "push msg : {}", msg);
try {
producer.publish(msg.getBytes(IOUtil.DEFAULT_CHARSET), topicProduce);
break;
} catch (Exception ex) {
LogUtils.error(log, "failed to push msg : {} count={} Reason={}", msg, count, ex.getMessage());
LogUtils.error(log, msg, ex);
count--;
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
}
}
}
}
明明就是把一件事重复三次(通用技术逻辑),只不过这件事正好是推送消息给中间件(定制业务逻辑),偏偏中间多了个外部依赖服务的调用,导致单测需要Mock. 我讨厌写一堆无聊的Mock语句!有没有办法呢?有的。只要将定制业务方法抽离出来即可。
public void sendMsg2(String msg) {
repeat(
(msgTosend, topicProduce) -> {
try {
producer.publish(msgTosend.getBytes(IOUtil.DEFAULT_CHARSET), topicProduce);
} catch (NSQException e) {
throw new RuntimeException(e);
}
},
msg, topicProduce, 3
);
}
/**
* 重复做一件事指定次数
*/
public void repeat(BiConsumer<String, String> consumer, String msg, String topicProduce, int count) {
while (count > 0) {
LogUtils.info(log, "call {}({}, {})", consumer.getClass().getSimpleName(), msg, topicProduce);
try {
consumer.accept(msg, topicProduce);
break;
} catch (Exception ex) {
LogUtils.error(log, "failed to do : {} count={} Reason={}", msg, count, ex.getMessage());
LogUtils.error(log, msg, ex);
count--;
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
}
}
}
}
这样, repeat 方法是可测试的; 而 sendMsg2 只是个单行调用,可以在整体流程中验证(也可以通过Mock producer 服务做单测)。repeat 的单测如下:
@Test
public void testRepeat() {
FunctionUtils.repeat(
(msg, topicProducer) -> {
throw new RuntimeException("NSQ exception msg=" + msg + " topicProducer=" + topicProducer);
},
"haha", "you are pretty", 3
);
}
使用 mockito 对 sendMsg2 编写单测得到:
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
/**
* Created by shuqin on 17/2/17.
*/
public class SimpleMsgPublisherTest extends BaseTester {
SimpleMsgPublisher msgPublisher = new SimpleMsgPublisher();
@Test
public void testSendMsg2() throws NSQException {
Producer mock = mock(Producer.class);
msgPublisher.setProducer(mock);
String msg = "biz message";
doThrow(new NSQException("nsq exception")).
when(mock).publish(msg.getBytes(IOUtil.DEFAULT_CHARSET), "biz_topic");
msgPublisher.sendMsg2(msg);
doThrow(new NSQException("nsq exception")).
when(mock).publish(msg.getBytes(IOUtil.DEFAULT_CHARSET), null);
msgPublisher.sendMsg2(msg);
}
}
私有方法的单测####
一些私有方法无法直接调用,只有通过反射的方法来调用生成单测。
import java.lang.reflect.Method;
/**
* Created by shuqin on 16/11/2.
*/
public class ReflectionUtil {
public static Object invokePrivateMethod(Class c, String methodName, Object... args) {
Method targetMethod = getTargetMethod(c, methodName);
return invokeMethod(c, targetMethod, null, args);
}
public static Object invokePrivateMethod(Object inst, String methodName, Object... args) {
Method targetMethod = getTargetMethod(inst.getClass(), methodName);
return invokeMethod(inst.getClass(), targetMethod, inst, args);
}
private static Method getTargetMethod(Class c, String methodName) {
Method targetMethod = null;
for (Method m: c.getDeclaredMethods()) {
if (m.getName().equals(methodName)) {
targetMethod = m;
}
}
return targetMethod;
}
private static Object invokeMethod(Class c, Method targetMethod, Object inst, Object... args) {
try {
Object instance = inst;
if (inst == null) {
instance = c.newInstance();
}
targetMethod.setAccessible(true);
Object result = targetMethod.invoke(instance, args);
targetMethod.setAccessible(false);
return result;
} catch (Exception ex) {
return null;
}
}
}
使用方法:
public void testGetParamListStr1S1861() {
String string1 = (String)ReflectionUtil.invokePrivateMethod(methodGenerateUtils, "getParamListStr", new Object[] { });
Assert.assertEquals(null, string1);
}
并发的单测####
为了提高性能,应用中常常会使用线程池来并发完成一些数据拉取工作。比如
/*
* 将订单号列表分成2000一组, 并发拉取报表数据
*/
private <T> List<T> getReportItems(List<String> rowkeyList) {
List<String> parts = TaskUtil.divide(rowkeyList.size(), 2000);
ExecutorService executor = Executors.newFixedThreadPool(parts.size());
CompletionService<List<T>> completionService = new ExecutorCompletionService<List<T>>(executor);
for (String part: parts) {
int start = Integer.parseInt(part.split(":")[0]);
int end = Integer.parseInt(part.split(":")[1]);
if (end > rowkeyList.size()) {
end = rowkeyList.size();
}
List<String> tmpRowkeyList = rowkeyList.subList(start, end);
completionService.submit(new GetResultJob(tmpRowkeyList));
}
// 这里是先完成先加入, 不保证结果顺序
List<T> result = new ArrayList<>();
for (int i=0; i< parts.size(); i++) {
try {
result.addAll(completionService.take().get());
} catch (Exception e) {
logger.error("error get result ", e);
}
}
executor.shutdown();
return result;
}
可以看到,这个将通用流程(并发处理)与定制业务(生成报表的任务)耦合在一起。需要先将通用流程抽取出来:
public static <T> List<T> handleConcurrently(List<String> rowkeyList, Function<List<String>, Callable> getJobFunc, int divideNumber) {
List<String> parts = TaskUtil.divide(rowkeyList.size(), divideNumber);
ExecutorService executor = Executors.newFixedThreadPool(parts.size());
CompletionService<List<T>> completionService = new ExecutorCompletionService<List<T>>(executor);
for (String part: parts) {
int start = Integer.parseInt(part.split(":")[0]);
int end = Integer.parseInt(part.split(":")[1]);
if (end > rowkeyList.size()) {
end = rowkeyList.size();
}
List<String> tmpRowkeyList = rowkeyList.subList(start, end);
completionService.submit(getJobFunc.apply(tmpRowkeyList));
}
// 这里是先完成先加入, 不保证结果顺序
List<T> result = new ArrayList<>();
for (int i=0; i< parts.size(); i++) {
try {
result.addAll(completionService.take().get());
} catch (Exception e) {
logger.error("error get result ", e);
}
}
executor.shutdown();
return result;
}
private <T> List<T> getReportItems2(List<String> rowkeyList) {
return TaskUtil.handleConcurrently( rowkeyList,
(keylist) -> new GetResultJob(keylist), 2000
);
}
这样,就可以针对 handleConcurrently 方法进行单测。
需要注意的是,由于并发方法执行的不确定性,并发方法存在不稳定性的可能。在编写单测的时候,最好能提取必定能成立的契约反复执行多次验证每次都是OK的。如下所示:
@Test
public void testHandleConcurrently() {
for (int count=0; count < 50; count++) {
List<String> orderNoList = new ArrayList<>();
for (int i=0; i< 100; i++) {
orderNoList.add("E00" + i);
}
List<String> result = TaskUtil.handleConcurrently(orderNoList,
(keylist) -> new Callable() {
@Override
public Object call() throws Exception {
TimeUnit.MILLISECONDS.sleep(100);
return keylist.stream().map(orderNo -> "HAHA"+orderNo).collect(Collectors.toList());
}
}, 5);
Collections.sort(orderNoList);
Collections.sort(result);
Assert.assertEquals(orderNoList.size(), result.size());
for (int i=0; i < 100; i++) {
Assert.assertEquals("HAHA"+orderNoList.get(i), result.get(i));
}
}
}
自动生成单测###
对于不含外部服务依赖的独立方法的测试,可以通过程序自动生成单测类模板,然后填入测试数据即可。自动生成单测程序通过反射机制获取待测试类与方法的参数类型、方法签名、返回类型,根据含有占位符的单测类模板文件自动生成可执行的最终的单测类模板源代码。
可参阅:“输入输出无依赖型函数的GroovySpock单测模板的自动生成工具(上)”
小结###
单测是保障软件质量的第一道重要防线。本文梳理和探讨了单元测试的多个方面:
- 单测的覆盖范围:DAO方法测试、独立方法的测试、含外部依赖的测试;
- 单测的测试效率:长链测试与锻炼测试、单测与接口测试的平衡、单测的动态语言、单测框架选用;
- 单测的编写技巧:空边界与异常处理、配置式编程、编写短小方法、分离易测试的通用逻辑、私有方法的单测、并发的单测;
- 自动生成单测。