作为一名质量管理人员,从刚入行时就接触到单元测试:需求提测时要保证一定的单元测试覆盖率作为提测准入;进行线上问题case study时会问,这个bug单测是否可以发现;还有各种质量度量。对于单元测试的意义,绝非一个指标或几个指标可以度量的,或许你看到的指标只是一个Trick。
单元测试测什么
对于单元测试测什么,怎么写,貌似很多同学甚至研发也搞不清楚,每次要进行CI发现好多单测不通过,错误原因五花八门,诸如:依赖的下游服务调用失败,mysql连不上或者由于脏数据引发的失败,读写文件找不到,一些中间件连不上等等。这些都造成单测维护困难,修复无果,大多数情况都被注释掉了。
对于单元测试测什么,那我们先弄清单元是什么的问题。笼统的说,单元是具有独立功能的最小单位,如果你使用函数式编程,它可能是一个函数,如果是面向对象语言,如java、c++,它可能是一个方法、一个类。如果是一个java web项目,单元可以是一个util类、一个dao方法、一个service方法、甚至一个controller方法。 单元测试就是从基本功能、边界值、状态、异常处理等角度设计测试用例对单元进行测试。
有人或许有疑问,service层一般会调用util层,那我是不是直接测试service就可以了,不用再测试service了?当然不是,单元测试需要是自下而上的测试,依赖基础功能不能保障,肯定不能保障上层的测试。另外通常是先编写util再编写service,util编写完成时就应该同时完成util的单元测试,这是设计上的原则。再就是util可能被多个service调用,每个service的场景输入或许不同,通过单个service的测试不能保障util功能测试场景的完备性。
为了避免对于单元定义的分歧,google用小型测试(见下图)描述单元测试,所以当你写单元测试时,可以自检确定是否是单元测试。
单元测试的意义
当我们提倡单元测试时,经常有些质疑的声音:“本来开发任务就重,哪有时间写单元测试,我们写了你们测试还做啥?”,有时甚至我也质疑:“我们单元测试覆盖率有80+%了,为啥还这么多bug?”
其实要让单元测试充分发挥它的作用,需要多种因素:写,研发要有种高度自驱的精神尽量保证单测覆盖;review,团队之间要互相敦促,创建一个美好的研发习惯和氛围;维护,单测失败了或代码修改了一定要像哺育自己的孩子一样牵挂着单测;统计,在看板上对于单测的覆盖情况和运行情况实时更新,一目了然。这几个因素,只有review可以忽略外,其它因素缺一都会无法保障单测的意义和效果。
单元测试的核心意义在于,良好的可测性代码设计,研发代码质量保障。
关于可测性设计,我们先来说TDD,基于测试驱动的研发模式,是要研发人员在写代码前先完成单元测试,之后为了保证单元测试通过,进而去完善代码逻辑。这种研发模式比较理想,现实中很难有研发按照这种模式去做。但是尽管不能做到测试先行,退而求其次,在开发过程中也要铭记测试,为了能较好的进行测试,要保证逻辑高内聚,低耦合的同时,尽量为测试提供灵活的支持。例如下例就不具备一个较好的可测性设计。
public static String getTimeOfDay() {
Calendar calendar = GregorianCalendar.getInstance();
calendar.setTime(new Date());
int hour = calendar.get(Calendar.HOUR_OF_DAY);
if (hour >= 0 && hour < 6) {
return "Night";
}
if (hour >= 6 && hour < 12) {
return "Morning";
}
if (hour >= 12 && hour < 18) {
return "Afternoon";
}
return "Evening";
}
对单元测试、集成测试、系统测试等诸多测试而言,单元测试首当其冲,从实现成本、问题修复成本角度看单元测试也是最低的。据统计85%的bug可以在单元测试阶段发现。对于单元测试的整体收益看,要大于研发中编写单元测试耗费的代价。
如何编写单测
单元测试应该是简单无依赖的,包括数据库、网络、磁盘、下游接口、中间件服务等,都不应该阻碍单元测试的运行。所以代码可测性需要解决的问题之一就是要求要解决方便对这些依赖进行隔离或者mock。例如下例不是具备好的可测性,由于UserService的耦合关系,导致不方便构造userservice的stub服务。
Public class TaskService {
public List<Task> getTask(User user) {
UserService userService = new UserService();
if (!userService.isValidUser(user)){
return null;
}
return taskDao.getTask(user);
}
}
在mock工具不是很完备的很长一段时间,开发人员需要通过代码编写此类的stub服务,造成单元测试编写和维护代价非常高,这也是单元测试覆盖率不高的原因。
Public class TaskService {
UserService userService;
Public TaskService(UserService service) {
this.userService = service;
}
public List<Task> getTask(User user) {
if (!userService.isValidUser(user)){
return null;
}
return taskDao.getTask(user);
}
}
Public class UserServiceStub implements UserServiceInterface{
public boolean isValidUser(User user) {
return true;
}
}
所幸我们站在各种巨人的肩膀上写代码,现在各类语言都有自己的mock工具,比如java就有mockito、powermock等很方便的mock。尽管如此,还是要以代码可测性为前提。
单测的局限
通常单元测试的行覆盖率可以达到80%甚至90%,那么我们是不是可以不进行其它测试了呢?当然不是,如果真是那岂不是测试人员要失业了。在敦促单元测试的同时我们必须认识几点:
- 单纯的高行覆盖率是无效的
除了行覆盖率外,通常的单测覆盖率插件会提供多种覆盖率统计数据,其中包括分支覆盖率(圈覆盖率),是指代码分支的覆盖 / 所有代码分支情况。也就是同一段逻辑,虽然保证行覆盖率,但不能保证所有情况都能准确走到该代码块中,也就是高的行代码覆盖率,不一定具备高的分支覆盖率,这也是为什么高的行覆盖率情况下依然有bug的原因之一。 - 单元测试的局限性
上面说过,单元测试应该是不依赖数据库、多线程、网络、接口、文件等,在单元测试中我们尽量使用mock将这些隔离,假的终究是假的,所以总会因为其中的差异导致bug钻了空子,例如我们用h2内存数据库mock mysql数据库,那我们要从sql语法规则、mock数量、数据场景(分库分布)、数据分布等去考虑,所以单元测试通常是发现简单少量数据场景下的基本功能问题,对于大量数据复杂场景的并发问题需要进行接口测试、系统测试、异常测试、压力测试、安全测试。
测试人员在单测中的职责
单元测试是研发人员编写,那测试人员就无所事事了么?当然不是。测试人员同样要具备编写单元测试的能力,提供单元测试所需的各种脚手架,对单测的有效性进行review,对单元测试覆盖进行监督和度量。
当你所在的团队都认可单测的意义、不再为写单测找借口时,当你不再经常对基础的本该单测覆盖的功能反复测试时,当团队不再为重构而担忧时,单测才走上一个较成熟的阶段。