单元测试及代码的可测试性
单元测试
什么是单元测试?
单元测试是一组独立的测试集合,集合中每一个测试针对一个单独的软件组件。软件组件是指一个软件系统中最为“原子”的行为单元(原子指的是比较独立的一块,无法再拆分的)。比如类,函数。
优秀的单元测试应该满足如下几点要求
- 运行快
- 可以帮助开发人员定位问题
- 隔离性。不依赖于外部环境,外部类。
隔离性具体是指不应该依赖于外部环境例如:
- 跟数据库有交互
- 进行了网络通信
- 调用了文件系统
- 需要你对环境做特定的准备(如编辑配置文件)才能运行的
- 依赖不可控组件
依赖于外部环境的测试不属于单元测试的范畴,而应该属于集成测试。
为什么要做单元测试
开发软件如同盖楼,楼房使用一块块砖头,钢筋,混凝土进行组合搭建。类,函数就相当于砖头这些,单元测试就是对我们的砖头,钢筋进行质量检测,如果我们不能保证我们用的砖头,钢筋是合格的,那我们如何能保证我们盖的楼房是合格的呢?
单元测试可以让我们带着反馈工作。良好的单元测试就像是一张安全网,可以确保系统代码的正确性,并防止糟糕的改动泄露出去影响到系统的其它部分。良好的单元测试可以让我们放心的修改代码,因为单元测试运行非常快,开发人员在修改后可以快速校验出修改是否正确。
如果没有单元测试,仅靠测试人员做功能测试,首先最直观的体现就是要花费大量的时间,因为哪怕一个小小的改动,也要经过代码的提交,集成,部署,并与测试人员沟通,以及回归测试,这将花费大量时间。此外还会存在测试无法覆盖所有代码的问题,比如批处理的代码,如果开发人员忘记告诉测试人员,测试人员可能会忽略测试批处理的问题。以及一些跟时间有关的测试,到特定时间才会执行,测试人员也可能会忽略掉进而缺失测试。
总体来说单元测试可以校验正确性还可以校验变化。
如何编写单元测试
编写单元测试有很多测试框架,比方说最常见的Junit,TestNG。
如果代码中依赖了外部系统或者不可控组件,比如上述隔离性提到的几点,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“mock”。所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。进而让单元测试不需要依赖其他情况也可独立测试代码。比较常用的 Mock 框架是 Mockito。
在编写的过程中,我们会遇到很多问题,发现有的代码无法测试,比如 private 方法 或者 方法内直接依赖于实现。无法测试的代码不是因为单元测试框架功能少不支持,而是这种代码不满足设计原则,不具备可测试性,所以我们也应该学习如何编写具有可测试性的代码。
mock 误区。不应该依靠单元测试框架中很高级的特性。如果要依赖高级特性例如反射内省甚至是字节码编织等才可以实现mock,则说明代码设计不具备可测试性,此时应该修改代码而不是继续使用高级特性。因为这种通过反射或者其它方式运行时访问私有变量或修改程序行为,虽然是很好用,但实际上有欺骗的味道,如果大量运用这种高级特性编写单元测试,会蒙蔽团队的眼睛,让团队成员看不到代码边的有多糟糕。
代码的可测试性
综上所述,我们要保证每个软件组件的正确性以及可以校验变化,其实我们是希望将代码的质量保障提前,保证每个软件组件在开发阶段能够测试,而想要每个软件组件能够测试,在设计过程中,就要保证每个模块是可以测试的,而这就是可测试性。
如何在设计中考虑可测试性呢?
我们需要在设计时想一下,这个代码要怎么测。我们在设计以及编写代码时,必须将可测试性纳入考量,我们还需要使用 mock 框架减少对外部环境的依赖。
此外我们还应该知道什么样的代码不可测试,在编写代码的时候脑海里过一遍不可测试代码checklist清单,如果对应上了某种不可测模型,则我们应停下来思考修改为可测试的代码。
什么样的代码不可测试
1. 直接依赖于实现
如下代码所示,OrderMapper 是访问数据库的代码,query 方法很简单 就是使用 orderMapper去访问数据库查询订单。但是这里直接依赖于实现,无法进行mock模拟替换,直接去访问数据库了,就没办法进行单元测试。如反例所示。要修改为可测试也很简单,只要在构造时,或者使用时,从外部传入具体实现即可,或者通过依赖注入的方式,在测试时,注入Mock对象,通过上述手段进行解依赖操作,来达到代码的可测试的目的。如正例所示。
反例:
public class OrderService {
private OrderMapper orderMapper = new OrderMapper();
public Order query(String id) {
return orderMapper.query(id);
}
}
正例:
public class OrderService {
private OrderMapper orderMapper;
public OrderService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public Order query(String id) {
return orderMapper.query(id);
}
}
2. 未决行为
未决行为 就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。例如代码中有一个逻辑是根据每个月计算每个月的工作日。如果直接使用系统时间,那么这个函数返回结果就无法测试。我们无法验证其返回是否是按我们要求处理的。解决方法依然是通过外部注入的方式解耦依赖,不要依赖于具体的实现,例如反例中的 new Date,就是直接 依赖了具体的实现,产生了未决的行为。
反例:
public class MonthService {
public String queryCurrentMonth() {
return DateFormatUtils.format(new Date(), "yyyy-MM");
}
}
正例:
public class MonthService {
public String queryCurrentMonth(Date date) {
return DateFormatUtils.format(date, "yyyy-MM");
}
}
3. 全局变量
全局变量是一种面向过程的编程风格。全局变量在多线程中处理复杂,同时也不具备可测试性。单元测试应该彼此独立,隔离,不应该依赖于执行顺序。单元测试的执行不应该有先后顺序,甚至应该允许并发同时执行所有单元测试进而更快速。但是如果出现代码中依赖全局变量的先后执行顺序的逻辑,可能会因单元测试的执行顺序不固定而导致单元测试失败。
单例模式实际上也是一种全局变量,也可能出现上述问题,所以代码中不应该直接依赖于单例模式,如果实在要用单例,也应该在单例之上实现一层接口,代码依赖于接口。
4. 静态方法
静态方法本身不容易 mock ,因为静态方法本身并非面向对象的而是面向过程的,本身跟全局变量类似。我们编写的静态方法不该依赖于外部环境,系统时间,网络等。
5. 复杂继承
设计原则中有个原则是 优先使用组合而非继承。相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护、不易测试。
如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类以及其子类的子类在编写单元测试的时候,都要 mock 这个依赖对象。层次越深越底层的子类要 mock 的对象可能就会越多,其实这也是高耦合的一种体现,很难编写单元测试。
6. 高耦合代码
如果一个类职责不明确,不满足单一职责原则,需要依赖一大堆外部组件才能完成工作,这样的代码耦合度非常高,那我们在编写单元测试的时候,可能需要 mock 一堆依赖的对象。编写单元测试很费劲,几乎无法编写。
所以从可测试性角度,我们也需要编写高内聚,低耦合的代码。
7. 私有方法
私有方法无法被外部的类方法,也无法从其他类中调用该类的私有方法。无法直接被测试,实际上如果出现这种情况,应该考虑的是这个方法是否应该是私有方法,是否是设计的不合理,是否应该将这个私有方法单独拿出一个接口一个类实现为公有方法。如果实在确实应该是私有方法,为了测试,可以考虑改为 protected 访问权限,并添加@VisibleForTesting 注解。