前言
单元测试,只是测试吗?单元测试除了是一种测试手段外,更是一种改善代码设计的工具,容易写单测的代码往往也具有更加良好的设计。这里需要强调一下 “工具” 属性,工具能放大人的智力或者体力,让干活的时候不会这么累,比如你去种树带把铲子,你肯定不会把铲子当成负担的,因为他是你种树的工具,你写 Java,肯定不会因为 IDEA 启动时间长,就把它当成一种负担,因为 IDEA 也是你写 Java 的一个工具,很多人把写单测当成一种负担,往往就是没有意识到”单测”是一种工具,单纯把他当成一种测试。
许式伟:我们不把推广单元测试看作是让大家去多做一件额外的事情,而是规范大家做单元测试的方法。为什么这么说?因为实际上单元测试大家都会去做,很少有人会不经验证就直接交付。但是验证方式上可能有各种 “土” 方法,比如用 print,用可视化的界面做输入测试,用调试工具做单步跟踪等等。但是这些方法代价其实一样不低,但是却不可回归,正确与否还需要人脑临时去判断。更重要的是,这些方法最大的问题是没有办法去固化已知的 Bug,最大程度保留下来我们的测试案例。这其实才是最核心的一个认知问题:我们应当重视我们的测试代码,它同样也是我们的开发成果,理应获得和模块的功能代码同等重要的地位,理应被保留下来。
为了单元测试而重构
含有核心业务的代码应该首先思考如何让主体业务逻辑可以写无 Mock 单测
@Service
public class ShopService{
@Resource
private CreditCardService creditCardService;
public void buyBread(CreditCard creditCard){
Bread bread = new Bread();
creditCardService.change(creditCard,bread.getPrice());
}
}
假设这是一个商店系统,里面有一个买面包的方法,里面会调用银行提供的信用卡服务 creditCardService 来扣除传入的信用卡的钱。这段程序如果使用 Mockito 的话,估计你很快就能写出测试了,只需要把 creditCardService 给 Mock 掉,然后验证它传入的参数就可以了。如果总是像上面这样思考的话,单测对于你改善代码设计就没什么帮助了。我们在给代码写单测的时候不应该上来就思考用什么样的工具来测试代码,而是应该思考如何重构代码,才能让代码变得更加容易测试。
@Service
public class ShopService{
public Payment buyBread(CreditCard creditCard){
Bread bread = new Bread();
return new Payment(creditCard,bread.getPrice());
}
}
public class Payment{
private final CreditCard creditCard;
private int amount;
}
上面这段代码,我们换个角度,思考下如何重构代码,才能让这段逻辑不需要 mock 就能测试?其实非常简单的一个办法是,返回一个计划,而不是立即就执行外部调用。此时这一段逻辑不需要 Mock 就可以测试了,只要校验方法返回的 Payment 对象里面的属性是否正确即可。可以把 Payment 按照银行卡分组统一扣钱,这样就可以减少 rpc 调用的次数,以后如果有需要的话,甚至可以直接将 Payment 作为消息发出去,到另一个系统执行,业务层根本无需关心 Payment 最后是怎么执行,只需要在付款的时候生成一个 Payment 就可以了。
如果你的系统大部分代码都一定要 Mock 才能测试的话,或者根本无法测试的话,就像右图一样,说明你的业务根本就没有自己的核心逻辑,而是和各种外部调用缠绕在一起。
另外需要说明的是,图中红色的部分才是单测真正能够起作用的场景,因为它是比较稳定的业务逻辑,而且红色部分的单测也比较好些,只需要传几个参数进去,然后校验一下返回值就行了。灰色的外部调用部分理论上不写单测也无所谓,因为外部调用是不稳定的,即使你跟对方约定好了出入参数,他依旧有可能返回不符合约定的参数,或者直接就发生了网络错误,这一部分是集成测试发挥的场景。为什么在我们的系统里,大家都觉得单测没用,其实我也觉得单测对我们现在的系统没什么用,因为我们现在系统的主体代码就像右图一样,大部分都是灰色的外部调用,单测能够发挥作用的领域少之又少,即使写了覆盖率 80% 的测试用例,又能测出来啥?
为什么单测能够验证代码结构的合理性?
上面这三种评价代码的方式其实都是比较“主观”的,什么样的代码才能叫“高内聚”,在每个人看来可能都不一样。但是对于是否易于写单测,大家的标准基本是一样的,难写单测的系统给谁都很难写。而好写单测的代码一般都满足编程范式所倡导的原则,所以写单测的难易程度可以作为一个非常客观的代码质量评价指标。
如果有个程序员跟你说我程序的性能达到了多少 QPS,你肯定会立马拿起测试工具就去测,看到底能不能到达这个 QPS。但是如果有程序员画了框框图说他的代码分成了 A B C 模块,要怎么验证他的代码真的分成了这几个模块呢?很简单,你看看每一个模块能否脱离其他模块单独测试就可以了,如果单独测试非常困难,那就说明模块并没有真的分开,而是或多或少耦合在了一起。
单元测试的运行速度重要吗?
很多人会觉得单测反正也不是系统中的代码,运行的快慢无所谓,然后写出很多其慢无比的单测,以至于系统全量跑一次单测要几十分钟。这样的话就完全偏离了单测的定位,单测的目的就是为了方便快速迭代,改了两行代码就可以在本地用 30 秒到几分钟的时间全量跑一次单测来确定影响范围,而不是每次都要通读系统源码才能知道改动的影响范围,这样新人很快就可以大胆改代码了,而不是先花几个月通读系统源码,或者先踩好几个坑,才能上手干活。那些全量跑单测要几十分钟的系统,他的开发者根本就不会在本地全量运行单测,每次都在 aone 上跑半天才知道单测不过,这样的单测就形同虚设了。
违背这个原则的典型反例,就是在单测中启动 Spring。
数据驱动测试
用例数据尽量和测试逻辑分离:使用多组测试数据是否就意味着多写很多代码呢?并不是,我们只要注意将测试用例的逻辑与数据分离就可以,测试代码依次读取测试数据,校验其是否符合预期。这样的逻辑与数据分离的测试一般称做 “数据驱动测试”,常见的单元测试框架都会提供这种支持。
public void testAdd(){
assertEquals(2,AddUtil.add(1,1));
assertEquals(4,AddUtil.add(2,2));
assertEquals(0,AddUtil.add(1,null));
assertEquals(0,AddUtil.add(null,1));
assertEquals(0,AddUtil.add(null,null));
}
基于 Spock 的数据驱动测试。大家所熟悉的 junit 框架也是可以做的
def testAdd(Integer a,Integer b,int expect){
expect:
assert expect == AddUtil.add(a,b)
where:
a | b | expect
1 | 1 | 2
2 | 2 | 4
1 | null | 0
null| 1 | 0
null| null | 0
}
其它
事故驱动开发TDD 为什么落不了地?大多数业务系统的生命周期都很短,日常的迭代就像是在没完没了地做 MVP-Minimum Viable Product 版本的系统。连稳定的业务逻辑都没有,想要维护和业务保持一致的测试还是比较难的。解决浮出水面的问题才是大老板的 KPI,也是帮助你和老板一起升官发财的不二法宝。而那些还潜伏在水面下的,没有发生的问题,提前做规划去预防?这不是给你老板添堵吗?没有问题就做预防?最后沦为无用功。