大家都知道单元测试的重要,网上也有很多讲单元测试的文章,但是脱离网上的case实际维护用例的时候就不是那么回事了。我们团队从17年开始摸索单测的实行,发现了在落地过程中的一些问题。
首先测试的类型是不一样的,先讲几种对单测比较友好的类型:第一种DAO层的单测,DBunit +SpringTest 事务回滚算是豪华套餐了,需要解决的问题就是验证sql是否符合预期。这种类型的单测成本低,效果好,性价比很高。第二种针对中间件的单测,主要是验证逻辑是否符合预期。这类场景因为需求明确,逻辑这部分很少变动,加上对中间件的严格要求,单测基本上是标配。
日常中接触到的很多时候是另一种场景:业务层(Service层)的逻辑,需要调用DAO层另外可能还有一些远程服务再加上本身的业务逻辑。业界有比较成熟的mock框架可以解决远程服务的依赖,当然也可以解决DAO层的,但是有一个很重要的问题,数据之间往往都是有关联的,也就是说在构造mock数据的时候,你需要维护他们之间的关系,如果mock的对象有很多字段的时候,你会发现维护mock的代码比实际验证的代码要长很多,再加上为了验证逻辑里的调用关系,需要通过verify来验证mock 的远程服务是否如预期的被调用。可以看到要想实现一套业务层的单测成本还是略(hen)高的。类似下面的单测代码:
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
init();
}
private void init () {
timeout.setBizId(BIZ_ID);
timeout.setAppName(APP_NAME);
timeout.setType(TimeOutTypeEnum.TOC_TYPE_TERMINATOR.getId());
timeout.setTopicKey("test-topic-key");
timeout.setTopicTag("test-topic-tag");
timeout.setTopicName(TEST_TOPIC_NAME);
timeout.setCronExp("*/5 * * * * ?");
timeout.setTaskType(11);
}
@Test
public void testAddAndStartCronTask() {
timeout.setTaskType(null);
int taskTypeId = 112211221;
CronTaskBO cronTaskBO = new CronTaskBO();
long taskId = new Date().getTime();
long cronTaskId = new Date().getTime();
cronTaskBO.setId(taskId);
TaskTypeBO taskTypeBO = new TaskTypeBO();
taskTypeBO.setId(taskTypeId);
when(taskTypeProxyManager.getTaskTypeBOByTopicName(TEST_TOPIC_NAME)).thenReturn(taskTypeBO);
when(cronTaskManager.getCronTaskByUniqQuery(isA(CronTaskUniqQuery.class))).thenReturn(cronTaskBO);
//spy 使用when还是会真实调用,要想不走方法体可以使用doReturn
//when(cronTaskService.insertOrUpdate(isA(CronTaskForm.class))).thenReturn(cronTaskId);
doReturn(cronTaskId).when(cronTaskService).insertOrUpdate(isA(CronTaskForm.class));
CronTaskVO cronTaskVO = new CronTaskVO();
doReturn(cronTaskVO).when(cronTaskService).startCronTask(cronTaskId);
CronTaskVO result = cronTaskService.addAndStartCronTask(timeout);
verify(taskTypeProxyManager).getTaskTypeBOByTopicName(TEST_TOPIC_NAME);
//这里使用参数捕获器来进行参数校验
ArgumentCaptor<CronTaskUniqQuery> queryCaptor = ArgumentCaptor.forClass(CronTaskUniqQuery.class);
verify(cronTaskManager).getCronTaskByUniqQuery(queryCaptor.capture());
CronTaskUniqQuery cronTaskUniqQuery = TimeOutBO.toQueryFromBO(timeout);
//使用unitils 的反射断言
assertReflectionEquals(cronTaskUniqQuery, queryCaptor.getValue());
// assertThat(cronTaskVO).isEqualTo(result);
}
其实就是为了测试cronTaskService.addAndStartCronTask ,对应的代码是
@Override
@Transactional
public CronTaskVO addAndStartCronTask(TimeOutBO timeOutBO) {
// 设置任务类型
if (Objects.isNull(timeOutBO.getTaskType()) && StringUtils.isNotBlank(timeOutBO.getTopicName())) {
TaskTypeBO taskTypeBO = taskTypeProxyManager.getTaskTypeBOByTopicName(timeOutBO.getTopicName());
timeOutBO.setTaskType(taskTypeBO.getId());
}
// cron任务唯一性校验
CronTaskBO cronTaskBO = cronTaskManager.getCronTaskByUniqQuery(TimeOutBO.buildQueryFromBO(timeOutBO));
CronTaskForm cronTaskForm = TimeOutBO.buildFormFromBO(timeOutBO);
if (cronTaskBO != null) {
cronTaskForm.setId(cronTaskBO.getId());
}
//fixit 跟上面getTaskTypeByTopicName的部分内容有重复
Long cronTaskId = insertOrUpdate(cronTaskForm);
return startCronTask(cronTaskId);
}
粗略的估算要想有较好的覆盖率,单测的代码量是业务代码的2~3倍。而且业务层的代码往往容易重构和调整,一旦代码发生变化测试用例基本上就废的七七八八了。这个对快节奏的互联网公司来说太奢侈了,所以会看到就算一时投入“巨资”维护好的单测,经过几次日常之后就毁的差不多了。这个也是为什么业务层的单测一直没能推行起来的原因,不是说单测解决不了问题,而是这个成本实在太高。可以看到这类的单测跟上面提到的二类单测明显存在的区别:单纯的DAO层的单测因为只依赖数据而且是简单的数据,没有数据之间的关联,中间件的单测主要核心是代码逻辑,可以看到他们基本上都是一维的,就是要么跟数据相关要么跟逻辑相关。一维的简单性也是白盒测试比较容易解决的,但是业务代码就不一样,很多业务代码都是二维的,就是跟逻辑和数据都有关系,而且数据之间还存在关联。这就导致白盒测试的复杂度大大增加。
实际工程中,往往不会写单纯的单元测试,通常会将业务层整个串起来,跑一个功能测试来验证最后的结果是否正确。现在代码中的很多“单测”就是这样的。为什么会出现这样的单测,因为考虑到严格意义的单测成本巨大,但是后端开发同学又要保证自己提供服务的正确性,往往会进行相应的折衷, 在一个容器中调用一下服务看下整条链路是不是通的, 最终的返回结果有没有值。这样存在的问题是测试往往是一次性的,因为代码里硬编码了数据(比如商品id),这个数据关系对应到数据库中下次可能就会发生变化(商品修改了)。通常是开发用来冒烟自己功能,这样的测试用例在工程中没有第二次作用,对覆盖率没有任何帮助。而且就算是这样的一次性冒烟用例,维护的成本也在不断上升。首先是时间问题:随着工程大了,Spring加载的内容越来越多,要跑一个用例花的时间不少。还有一个是依赖问题,有时候你不需要依赖外部的一个服务,但是配置都在spring的上下文,对不起你也必须等那个服务加载好,遇到外部服务挂了,你的spring也会启动不了。这个时候有人会说可以维护一份独立的spring上下文给Test 代码用,是的确实可以这样,但是你这样做了之后你会发现依赖是嵌套的,你想要测试的代码是不依赖那个外部服务,但是那个bean依赖了,你不依赖就报错了,最后的结果是你为了让spring 容器起来不得不依赖那个你不需要的外部服务,当然你也可以mock掉,但是有很多这样的外部服务时,工作量不小。
总结一下,我们的单测遭遇的尴尬:1.实际的业务场景依赖数据以及数据之间的关联关系;2.白盒测试对业务层代码来说成本太高,而且很容易过时;3.开发自己折腾的“冒烟单测”能验证当时程序的正确性,属于一次性消费品,而且依赖外部服务spring 容器启动时间较长。针对上面的这些问题,我们通过什么方法进行解决,请期待下一篇《单元测试的解答》