我是一个着迷于产品和运营的技术人,乐于跨界的终身学习者。欢迎关注我的个人公众号「跨界架构师」
每周五11:45 按时送达~
我的第「167」篇原创敬上
大家好,我是Z哥。
提起单元测试,很多人对它的态度是,我知道它有用,但是我不想写。大多数人的理由是没时间写,任务太多。
但是说实话,是真的没时间吗?Z哥认为真是由于没时间而不写单元测试的人绝对是少数。况且,导致没时间很大原因可能就是花了太多时间在处理bug上。
所以,很多人没有把单元测试当作一个“工具”,而把它看作是一种“负担”。
在这种心态下,就算要写单元测试,也是为了写而写。更可怕的是,通过mock工具,还真能给任意代码写单元测试。
但是这样的做法其实是“买椟还珠”,真正的浪费时间。
最典型的情况是,很多人一开始写测试代码就错了,看上去写了很多Mock、Assert,但是到底想验证什么,测试什么其实并不明白。一个不留神,测试代码就变成验证某个RPC接口对不对,某个第三方系统库的函数对不对等等,这明显就跑偏了。
对于这种情况,无论多么牛逼的工具都帮不了你,只能提高自己对单元测试的理解。
还有一种情况是,写代码的时候并没有考虑这代码要怎么测,因此写完了以后发现写单元测试很难,没有现成的测试入口。这时候项目交付的deadline又快到了,唉,要不先放着改天再写吧。当然我们都知道,这个改天大概率再也不会做。
我们有一万个理由可以不做单元测试。但是这就好比,组装一架飞机不用测试各个零件的运作是否符合预期,直接让它飞起来再看有哪些问题。
以后如果谁说单元测试不重要的时候,你不妨问他“你敢坐没有检查过零件的飞机么?”
另外,单元测试除了对软件质量有提升外,对软件的开发效率提升也很明显。
在《实用软件度量》一书中提到了微软内部的统计数据,单元测试的成本效率是系统测试的3倍。
在《单元测试的艺术》中也提到过一个案例,找了开发能力相近的两个团队,同时开发相近的需求。进行单测的团队在编码阶段时长增长了一倍,从7天到14天,但是,这个团队在集成测试阶段的表现非常顺畅,bug量小,定位bug迅速等。最终的效果,整体交付时间和缺陷数,均是单元测试团队最少。
单元测试还有一个好处,就是让我们嘴上说的写「高内聚低耦合」的代码有了一条统一的实现路径。因为代码到底算不算高内聚低耦合,其实每个人的主观标准都不同。但是是否容易做单元测试,这却是一个相对更客观的标准。
所以,如果有人跟你说他这段代码设计得非常好,但就是不好写单元测试,相信你知道该怎么做了:D
那么,正儿八经的单元测试应该怎么写呢?我来分享一些我的经验和思考,希望能让更多的人参与到编写单元测试的队伍中来。
/01 怎么才算“单元”?/
相信很多人和Z哥一样,刚接触单元测试的时候觉得单元测试就是用来测某个方法的。其实并不是这样,这里的「单元」如何定义取决你如何定义“一件事”。只要这个「单元」里做的是“同一件事”,那么哪怕其中包含了3个方法,它也可以是一个「单元」。
比如,你写了一个下订单的单元测试,你可以把生成订单方法和扣减红包方法放在一起做单测,这样比两个方法分别做单测还可以多做一些关联验证。比如,订单上的红包金额是否与扣减的红包金额一致?
/02 如何判断单元测试的好坏/
单元测试和大多数技术工作不同,写得越好的单元测试往往用到的工具越简单,甚至不需要额外的工具。
在我的概念里,单元测试的好坏分为以下几个等级。
第一级,大部分代码不需要 Mock 就可以测试。这是最优秀的。
第二级,大部分代码需要Mock才能测试,但都不是静态方法。
第三级,大部分代码需要Mock才能测试,而且包含大量静态方法。(一般的Mock工具还无法Mock静态方法)
可能你会有疑问,为什么Mock静态方法是不好的?这个后面讲具体做法的时候会说。
说了这么多,具体怎么写呢?写单元测试其实就是做以下三件事。
/01 确定写单元测试的范围/
做任何的事都得回归到价值本身,单元测试也是如此。比如,你给一个固定返回字符串“Hello World”的方法写单元测试就是一个浪费时间的事情。
一般来说,哪些类型的代码适合写单元测试?
1.公用组件库。这些代码变更不会特别频繁,所以覆盖率需要尽量达到100%。
2.被调用频次越高的代码。
/02 怎么写?/
具体怎么写其实就是确定你要通过代码验证的东西是什么。这里你可以根据以下这4个标准来,不同重要度的方法,可以选择适合的标准来写。
■ L1:输入正确的参数时,会有正确的输出。(测试正确的处理逻辑是否符合预期)
■ L2:输入错误的参数时,不能抛出系统级的异常。(测试错误的处理逻辑是否符合预期)
■ L3:极端情况和边界数据可用。可能一开始无法考虑到很多边界条件和极端情况,所以这是一个需要长期维护的部分。
■ L4:覆盖率达到100%。
Z哥我对这4个标准的运用场景是:
■ L1,实在时间紧迫并且代码对应的功能不是核心部分。
■ L2,非核心模块大部分时候应该要达到的标准。
■ L3,核心模块要达到的标准。
■ L4,全局基础框架、封装的非业务型类库要达到的标准。
/03 单元测试的数据从哪来?/
很多人觉得写单元测试麻烦,主要的原因就是觉得构造测试数据费时间。所以,取巧的方法是直接连到DB,基于DB里的数据做单元测试。
但是这样的数据是不稳定的,一旦某个前置方法的逻辑有问题,导致数据库里的数据出现异常,那么后续的测试方法都会连续出错。
所以我认为单元测试的测试数据应该人为地在测试代码里构造。如此不但能让数据变得稳定,而且单元测试的运行效率也会更高,毕竟少了多次连接数据库的操作。
《Google软件测试之道》中提到谷歌的做法也是如此。在谷歌,单元测试被划分为「小型测试」类型,对于小型测试的特点就是不需要外部依赖,所以涉及到的外部服务需通过Mock或Fack来实现。(Mock、Fake、Stub都是单元测试中的基本概念,可以自行搜索了解)
再分享两个最佳实践给你,让你可以更容易编写单元测试。
/01/
涉及到I/O的代码和业务代码尽量分开。这里的I/O不仅仅是磁盘I/O还有网络I/O。
Pascal之父——Niklaus Wirth提出过一个著名的公式:程序 = 算法 + 数据。数据的操作和获取就是通过I/O进行的,一旦剥离后,剩下的代码就是算法,也就是“逻辑”,我们写单元测试要验证的恰好就是它。
实现方式也很简单,将I/O部分抽象出接口,通过依赖注入方式调用。这样你在写单元测试的时候可以通过Mock方式来提供一个I/O方法的实现。
/02/
测试数据与用例分离。在你写单元测试的时候,因为需要考虑很多种情况,所以需要构造好几套测试数据。
为了便于管理和维护这些数据,你可以避免将数据与单元测试代码写在一起。举个例子,你可能平时是这么写的。
@Test
Public void testAdd(){
assertEquals(expect: 2, MethodAdd(a: 1,b: 1));
assertEquals(expect: 0, MethodAdd(a: -1,b: 1));
assertEquals(expect: 0, MethodAdd(a: 0,b: 0));
}
以后你可以试试这样写:
@Test
void testAdd(){
for(Object[] s : data()){
assertEquals(s[2], (int)s[0]+(int)s[1]);
}
}
public static Iterable<Object[]> data(){
List<Object[]> list = new ArrayList<Object[]>();
list.add(new Object[]{1,1,2});
list.add(new Object[]{-1,1,0});
list.add(new Object[]{0,0,0});
return list;
}
这样,后续维护测试参数只要在data()方法里进行就好了(当然你也可以使用junit之类的工具来简化这个写法)。毕竟做单元测试是一件长期的事情,需要根据新发现的bug保持测试数据的更新,以确保已发生的bug总是被覆盖在单元测试范围内。
另外,易于做单元测试的代码,其实它的性能也会不错。因为耗时的I/O操作不会隐藏在各个方法里,让你无意间就重复调用了。相反,你可以直观的看到每个方法里有哪些I/O操作,能合并请求的可以在调用这些方法之前合并掉。
好了总结一下。
这篇呢,Z哥和你分享了我对写单元测试这件事的看法。
首先,我们应该把它当作“工具”而不是“负担”。因为单元测试除了可以提升软件质量,还可以提高开发效率,以及优化代码设计。
然后,实际在做的时候,我从「确定写单元测试的范围」、「怎么写?」、「单元测试的数据从哪来?」三个方面给了我的建议。并且分享了两个有效的最佳实践给你。
以我的亲身经历告诉你,当你每次改完代码run一遍单元测试,看到那些success和failurel列表的时候,你会觉得“真香”。不信你试试。
如今,好像一个团队不说自己在敏捷开发就落伍了。然而类似于测试驱动开发(TDD)之类的开发方式恰恰是敏捷开发实践的重要组成部分,但是我们却嫌弃它拖慢迭代速度。
那么我们到底是不是在敏捷呢?
推荐阅读:
如果你喜欢这篇文章,可以点一下右下角的「爱心」,支持我的创作~
定期发表原创内容:架构设计丨分布式系统丨产品丨运营丨一些深度思考。