本文部分内容及示例来自Google Testing Blog,有兴趣可以在文末点击链接查看原文。
Preface
本篇的内容是单元测试,看标题可能会奇怪,为什么是Clean Code在前面,因为我要说的并不是怎么写单元测试,写单元测试的方法在网上随便一搜就有很多,并且因为各种框架的关系,写起来非常简单,可以说基本上是没有任何难度,但是可能你看了很多文档或者教程之后,觉得单元测试很简单了,然后打开项目准备写,却发现不知道从何下手,或者写出一下看起来像是单元测试,其实却不太一样的代码,或者觉得很难就放弃了。然而单元测试其实非常简单,事实上这正是本篇内容要解决的问题,如何写出可测试的代码,虽说是为了测试,实质上即是clean code,注意这里clean是动词。
那我们先说clean code,什么叫clean code,简单理解就是如何写出整洁的代码,或者说如何写出高质量的代码,好代码。那么,首先说什么样的代码是好的代码?
易于阅读
易于修改
没有bug
鲁棒性
...
上面只是举一些例子,实际上不只这些优点。
先看一张经典图片:
我所接触到的大部分项目,很明显是右边。
那么如何解决这个问题呢?
答案是不断的学习和实践。写出高质量代码是相当困难的事情,首先要学习各种理论知识,但是这就如同骑自行车,哪怕给你讲的再好,第一次骑总是会摔倒,还需要实践,阅读大量的代码,观察并思考他人的失败和成功,甚至是从自己的失败中的出经验,当你因为自己混乱的代码而付出代码,一定会牢记在心。二者缺一不可。这里要描述的只是其中的一小部分。
写出好代码,关注软件的内部质量是很重要的,如果只关注外部质量而不关注内部质量,随着项目不断的开发,需求日益复杂,或因时间而遗忘细节,会导致代码越来越难以理解,维护,修改,积攒的bug越来越多,逐渐变成人人避之不及的大坑。
一开始就关注代码质量是很有效的解决办法,
Later equals never
这一点我们在实际开发中应该深有体会,所有想要留到以后解决的基本上都会随着时间变多,然后被遗忘。
这里引用一下代码整洁之道里面的一段总结
代码能工作还不够。能工作的代码经常会严重崩溃,满足于仅仅让代码能工作的程序员不够专业。他们会害怕没时间改进代码的结构和设计。没什么能比糟糕的代码给开发项目带来更深远和长期的损害了。进度可以重订,需求可以重新定义,团队动态可以修正,但糟糕的代码只是一直腐败发酵,无情的拖着团队的后腿。
当然,糟糕的代码可以清理。不过成本高昂,随着代码腐败下去,模块之间互相渗透,出现大量隐藏纠结的依赖关系。找到和破处陈旧的依赖关系又费时间又费劲。另一方面,保持代码整洁却相对容易。早晨在模块中制造出一堆混乱,下午就能轻易清理掉。更好的情况是,5分钟之前制造出混乱,马上就能清理掉。
Overview
回到正题,单元测试。
说起单元测试,可能很多人的第一印象是,会写单元测试的都是大神,单元测试很有用,但是我不会写,也不需要,我的代码能跑起来,也没什么bug,为什么还要花时间写单元测试。
说了这么多,那么到底为什么要写单元测试?
Why
首先,单元测试可以给我们最直接的反馈,哪里出错了,哪里写的不对,只要运行一下,马上就会知道,现代开发工具都提供的对单元测试的集成,点一下就可以运行并知道结果,这要比代码在整个项目中实际运行时要简单的多。
其次,单元测试可以对我们提供一层保护措施,让你可以随意的重构,修改功能,优化代码,非常清晰直观的让你知道到底有没有问题,你的改动到底有没有破坏原有的功能,是否产生产生了隐藏的问题,一切尽在掌握之中。
最后,单元测试也是对了解业务提供了重要的帮助。好的单元测试逻辑简单,通过单元测试了解代码中实际实现的业务是非常容易的,尤其对很多没有详细需求文档的项目。在新接手一个项目,或当你忘记的以前写的内容时,阅读单元测试是快速了解业务的重要途径。
What
下面看什么是单元测试,先看一张图。
在软件测试中有这样一个金字塔概念,可以看到分了三层,每一层代码一种不同类型的测试。
最底层的就是unit test,是我们最经常会大量写到的功能专一的测试,直接运行在本地环境;integration test和UI test 则是需要运行在真实的环境上,也就是手机或虚拟机上。对应在Android项目中的是test和andoridTest两种测试。后两种可以告诉你你的软件是不是实际上功能正常,相对来说速度要慢,因为需要打包成apk,安装到真实环境中,通过类似于用户交互的方式来测试。
单元测试,顾名思义是在一个相对独立的状态下对单元(Unit)进行测试。
编写单元测试有几个特点:
深入透彻,但是避免过度设计,缺乏设计且频繁变更的功能很难编写详尽的单元测试
隔离外部环境,可重复运行,外部因素需要排除,如接口访问网络和数据库,在有无网络时测试结果应相同
功能单一,单元测试需要保持功能简洁单一,一个测试只专注于单一功能
-
验证行为而非实现,避免在实现改动后需要重新编写测试代码。在Andorid中,因为大部分单元测试是没有实际的ui的,这一方法更是尤为重要,我们通常需要使用MVP或MVVM等类似的架构来实现。
看下面的代码,我们需要测试一个登录后显示提示的功能是否正常,这里使用MVP架构,通过mockito框架轻松mock一个View接口,在登录方法被调用之后验证是否调用了一次
showLoginHint
方法。之后无论view的实现如何改变,这段测试代码都不需要被修改了。public class LoginPresenter { private View view; public LoginPresenter(View view) { this.view = view; } public void login() { this.view.showLoginHint(); } interface View { void showLoginHint(); } } public class LoginPresenterTest { public void testLogin() { LoginPresenter.View view = mock(LoginPresenter.View.class); new LoginPresenter(view).login(); verify(view).showLoginHint(); } }
快速,编写测试代码需要频繁的运行,所以速度很重要,这也是为什么会使用robolectric来在本地JDK上模拟Android运行环境的原因
简洁,避免复杂逻辑,测试代码应该一目了然,这也是很多测试库所实现的目的,虽然编写单元测试可能会多写一些代码,但大多数应该只具备简单逻辑,即输入-输出-校验
提起单元测试,另一个经常被放在一起的就是Test-driven development (TDD)
TDD简单的说就是边写测试边写代码,流程如下图
- 增加测试
- 运行测试找出错误
- 编写代码使测试能通过
- 运行测试
- 重构代码,提高代码质量
- 重复上述操作
通过快速迭代的开发流程,来确保每一步的正确性,其实和我们平时写几行代码就运行一下程序试一试效果是差不多的,把上面的编写测试和运行测试的步骤,换成"在手机上运行一下程序,点一点新功能或看控制台的日志,确定功能是否正确"是不是很像平时的操作,当然大部分人可能省略了第5条。还记得上面单元测试的几个特点么,点一点测试和看日志可不具备这么多功能。
科普时间
behavior-driven development (BDD)
行为驱动开发,由TDD衍生而来,鼓励让开发人员和非技术人员协作,通过自然语言实现非技术人员也能看懂的测试流程。
BDD的测试用例就像讲故事,开发人员和测试,项目经理等非技术人员经过讨论将一系列需求写成故事中的一个个场景。首先定义一套模板,让非技术人员通过模板写出要测试的内容,然后有开发人员完成实现的代码。测试的流程大致是这样:
Feature: Book Search
Scenario: Search books by author
Given there's a book called "Tips for job interviews" written by "John Smith"
And there's a book called "Bananas and their many colors" written by "James Doe"
And there's a book called "Mama look I'm a rock star" written by "John Smith"
When an employee searches by author "John Smith"
Then 2 books should be found
And Book 1 has the title "Tips for job interviews"
And Book 2 has the title "Mama look I'm a rock star"
How to write testable code part 1 相关知识补充
在开始说如何写出可测试的代码之前,先补充一些理论知识。
S.O.L.I.D
软件开发中的5个原则,当这些原则被一起应用时,它们使得一个程序员开发一个容易进行软件维护和扩展的系统变得更加可能。这一概念由 Robert C. Martin 提出。
Robert C. Martin 应该是非常著名的专注于面向对象,敏捷开发,提高代码质量的专家,还有一个名字是Uncle Bob,他写的书有很多经典著作,比如我最近在看的代码整洁之道。
-
单一职责原则 Single responsibility principle
单一职责模式告诉我们,A class should have only one reason to change. 一个类有且仅有一个原因使其被修改。说人话是我简单,我快乐。该原则说明了两点,一是一个类只能有一个职责,二是只能有一个修改的理由。举个例子,我们常用的日志模块,如果一个类既包含打印日志内容的功能,又包含日志格式的功能,那么在修改其中任何一个功能时,势必会影响到另外一个功能,在复杂的真实环境中,这种影响可能会造成很严重的潜在问题。
单一职责原则是面向对象中极为重要的一条,非常容易理解和遵循,然而却时常遭到破坏的原则。我们经常忙于应付多变的需求和即将临近deadline,而忽略对代码的组织结构的保持。有的人会觉得过多的类会导致系统过于复杂,想要找一个功能需要在好多类之间跳来跳去。如果你的项目只有很少的代码,的确放在一起要更容易找到自己的目标,然而大部分真实项目都具备相当复杂的逻辑,包含各种庞杂的功能,把多个功能放在一起并不会简化你的代码,问题在于,在你有很多东西时,你想要用仅有的几个大抽屉装下所有的东西,在找的时候翻的乱七八糟还不一定能找到,还是想要有多个只装一类东西并且具有良好的分类信息的小抽屉呢?大部分时间我们开发或者维护某个功能时,仅仅只需要关心相关的一些逻辑而已,并不需要知道其余任何无关功能。每个达到一定规模的系统都具有相当的复杂性,对这种复杂性的良好管理使我们能快速准确的找到需要的类,而不是在一大堆无关的功能中反复寻找自己需要的代码。
遵循单一职责原则要求我们在开发前就要对职责进行划分,分而治之,无论是提前规划好,还是当发现一个类有多个职责时进行修改,都会帮助我们更好的理解和创建抽象,我们常说高内聚低耦合,单一职责模式就是对高内聚很好的诠释。
-
开闭原则 Open/closed principle
开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的"
这个原则个人感觉是最不好实现的,也是最难理解的,对修改封闭不代表完全没有修改,那么什么情况应该允许修改,什么情况需要去扩展,首先对于设计是一个巨大的考验,前期设计的失败可能导致后期扩展时花费大量时间修改,当然没有一蹴而就的代码,开发过程中需要权衡利弊,想要达成最优效果,需要具备良好的抽象能力,掌握大量的设计模式,其他几条原则也同样可以帮助我们更好的实现开闭原则。
其实开闭原则的关键,是用你对各种设计模式的理解程度和抽象能力,符合其他原则及各种设计模式,自然也就符合开闭原则,其他的原则及设计模式恰恰是告诉你如何实现开闭模式的细节。
-
里氏替换原则 Liskov substitution principle
子类对象使用的地方都可以替换为父类对象,且能保持逻辑不变。
作为5个原则里唯一以人名命名的,是由两个非常厉害的人于1993年提出的。
Barbara Liskov, 美国计算机科学家,2008年图灵奖得主,2004年约翰·冯诺依曼奖得主。现任麻省理工学院电子电气与计算机科学系教授。
周以真,微软全球资深副总裁,美国计算机科学家。卡内基梅隆大学教授。美国国家自然基金会计算与信息科学工程部助理部长。ACM和IEEE会士。
继承,多态等是面向对象语言的特性,都是经常使用的。符合里氏替换原则的实现包含这样一层含义,父类中实现好的方法,都是符合一定的契约和规范的,子类实现的时候不能违反,否则会对整个继承体系造成破坏。难点在于既要提前规划好后续可能发生的情况,对类的功能进行抽象,又要防止过度设计,避免设计的过于复杂影响后续开发维护。比较稳妥的方式通常是只实现父类的抽象方法,这要求我们在初期设计时就要考虑到这些,或者干脆不使用继承,而是组合的方式来实现某些功能来避免耦合。
使用继承的优点在于减少重复代码,直接给子类提供了父类的功能,正所谓龙生龙,凤生凤,老鼠的儿子会打洞,然而有点也即是缺点,子类同样耦合了父类的功能,降低了灵活性,子类要考虑是否违反了父类的规范,父类修改时要考虑是否对子类造成影响,稍有不慎,可能就会搞出龙生耗子这样的问题,届时就需要对原有代码大量的重构,尤其在缺乏规范的情况下更是如此。
一个经典例子是长方形和正方形,看下面你的代码,正方形继承了长方形,为了保持正方形的宽高一致,我们重写了父类的方法,看起来是没什么问题。但是如果我们基于"长方形的面积=长*宽"这一规则来测试正方形,在设置了长或宽之后,area方法就不符合预期结果了。
public class Rectangle { private int width; private int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int area() { return this.height * this.width; } } public class Square extends Rectangle { @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); } @Override public void setHeight(int height) { super.setWidth(height); super.setHeight(height); } }
下面我们来进行修改,一个可行的办法是像下面这样修改,将width和height的set方法去掉,由constructo直接传入,这样就符合了父类的契约。
public class Rectangle { private int width; private int height; public Rectangle(int width, int height) { this.width = width; this.height = height; } public int getHeight() { return height; } public int getWidth() { return width; } public int area() { return this.height * this.width; } } public class Square extends Rectangle { public Square(int width) { super(width, width); } }
Java中实现的非常经典的例子:
Collection
如使用List的,不管实现类是ArrayList,还是LinkedList,变量类型全用List就可以。
Stream
很多方法参数或返回值给一个OutpuStream或InputStream,什么实现类并不影响代码逻辑,你甚至都不知道实现类是什么
-
接口隔离原则 Interface segregation principle
接口隔离原则规定,使用者不应该被强制依赖不需要的方法。大而全的接口应当被拆分长更细粒度的接口,这样使用者可以仅仅依赖需要的部分,减少耦合,使其更易于重构和修改。当然使用的时候同样需要权衡,拆分接口并不意味着越细粒度越好,应该根据需要,选择合适的方法进行组合。
从来自wiki的说明我们得知,
这条原则最初的构想来自Robert C. Martin在施乐公司期间的一个打印机程序,在他们发现这个程序几乎无法被维护时,试图找出原因。有一个Job类贯穿了整个系统,打印机需要执行的每个任务,都要调用Job类,这导致一个fat class 包含了大量的各种功能不同功能的方法。由于这个设计,任何一个任务都要知道全部的方法,即使根本不需要。解决办法是按照依赖倒置原则,增加了一层接口层,把巨大的Job类替换为一个个接口,对应每个任务,任务去调用对应的接口来实现功能,当然这些接口的实现都在Job类中。
我根据这个例子画了个图:
改动前:
改动后:
可以看出后面的改动很好的通过接口将巨大的job类分割成了一个一个小功能,屏蔽掉了调用者不需要知道的功能。
-
依赖倒置原则 Dependency inversion principle
依赖抽象而非实现,这个在依赖注入里面说过了,这里就不再重复了。
迪米特法则 / 最少知识原则 Law of Demeter (LoD) or principle of least knowledge
得墨忒耳定律(Law of Demeter,缩写LoD)亦称为“最少知识原则(Principle of Least Knowledge)”。
在198x年,有一群程序员开发了一个系统来研究如何使面向对象语言开发的软件更易于维护和修改,名字是The Demeter Project,一个关于Aspect-Oriented Software Development (AOSD)的项目,在此期间发现了得墨忒尔定律。Law of Demeter 的名字由此而来。Demeter是希腊神话中的农业女神,得墨忒耳。
遵循得墨忒耳定律可以使你的代码松耦合,提高代码的复用程度,更易于维护,更易于测试。关键点是,只和自己直接的朋友交流。
简单的来说就是:
- 你的方法只能调用自己相同类中的方法(Java: this)
- 你的方法只能调用自己所属对象中的属性的方法(Java: fields)
- 如果你的方法有参数传递进来,可以调用参数的方法
- 如果你的方法创建了一个本地对象,可以调用这个对象的方法
- 不应该调用全局对象,但是可以作为参数传递进来
- 不应该有链式调用,a.getB().getC().doSomething()应该放在a对象里
注意最后一条,如果把a.getB().getC().doSomething()改成a.doSomething(),仍然违反了得墨忒耳定律,因为a里面会有b.getC().doSomething(),所以应该有一个doSomething方法在b类中,a.doSomething()调用b.doSomethine()。
按照上述方式,一个对象的内部结构,运作方式只有它自己才知道,因此也称最少知识原则。通过封装的方式显著降低了代码的耦合程度,类与类之间的影响降到了最低,降低了修改的风险。
在分层架构中,遵循得墨忒耳定律意味着每一层的代码仅能调用自己本层或下一层中的方法。越俎代庖这个词用来形容违反了LoD原则最合适不过了。
然而,遵循得墨忒耳定律可能会产生大量的wrapper类和方法用来传递跨越多个多个对象间方法的调用(propagation patterns),增加开发维护的成本。一个相关的技术是AOP,这里简单的说一下aspect-oriented programming (AOP),AOP框架可以在你的代码中插入一些代码片段,而这个过程都是自动生成的,避免了手工书写代码的繁琐。因此,得墨忒尔定律配合AOP食用更佳。
另一个和得墨忒尔定律相关的是访问者模式(VisitorPattern),当你需要深入一个对象的内部结构去调用一系列方法时,用访问者模式对其进行封装是一个很好的方式。
如果看了上面的不是很理解,某Android群大神给我说了一个更通俗的例子:
你和你未来的老婆不认识,但是你认识她的闺蜜,你和你未来的老婆不能直接交流,因为你不认识她。有一天你突然想和你未来的老婆认识认识,那么你首先要通过她的闺蜜把你介绍给她,然后有两个方案,一个是始终通过闺蜜给她传话,或者把她变成你的老婆,这样她就是和你具有直接关系的对象了,也就可以直接和她交流了。
Summary
上面的6个规则只是程序设计中的一部分,想要写出高质量的代码,除了学习设计模式之外,还需要大量的实践,理论毕竟只是理论,在实际应用中达成的效果可能千差万别,任何模式的应用,都需要仔细权衡利弊,避免过度设计,也要防止设计过于简陋或干脆不进行任何设计,导致代码难以维护和扩展。
其实我们平时所做的程序设计,大都离不开三步,抽象,分解,组合,也既是计算思维。上述6个原则及各种设计模式,只不过是过去无数人对以往经验的总结提炼而出的几种最优解,让你站在巨人的肩膀上,更好的实现这三步而已。
How to write testable code part 2 Best practice
很多时候我们看着代码却不知道怎么写单元测试,想要测试一个单一的方法,却有无数的耦合对象影响,下面列举一些让我们更难进行单元测试的关键点。
-
区分创建对象代码和逻辑代码
编写测试代码通常是,初始化应用的一部分功能,然后执行某个操作,判断这个功能的现象是否符合预期。对单元测试而言,简单的说就是创建一个对象,调用一个方法,根据返回结果或某些现象进行断言,以确定功能是否符合预期结果。前面我们说过,单元测试的是在隔离的环境中对类(Unit)进行测试,换言之就是不能让其他对象对我们的运行结果产生随机影响,这就需要我们要测试的对象没有在内部自行创建(new)其他对象,否则就可能会对结果造成未知的影响。还记得上面的单一职责原则么,我们可以将代码按内容分为两种职责,一种是创建对象(因为大部分情况我们不会创建一个完全独立的对象,大多数对象都存在一些依赖关系,因此称之为 object graph)的代码,一种是功能逻辑的代码。将这两种代码分别封装在两种类中,负责创建对象的类,根据应用的设计模式不同,可能为factory/provider等,和负责掌管功能逻辑类。当然也可以使用其他手段,如依赖注入框架来辅助我们实现这样的功能。经过这样修改之后,我们就可以自由的创建需要测试的对象,把其他对象可以轻易的被替换为"假"的对象,使其的行为符合我们预设的逻辑。使用mock框架会使这一步骤更为简单。
-
遵循Law of Demeter
如果你按照上面第一条的进行改造,避免自己new新对象,就会发现一个问题,我不在类里面自己new对象了,那我要用到其他的类怎么办呢?当然是伸手要(作为构造参数传进来)。如果你使用过依赖注入,这个思路就很熟悉了:我们不创建对象,而是像别人要对象。
注意别忘了德墨忒尔定律,
违反得墨忒尔定律就像在干草堆里寻找一根针。
只能使用自己有直接接触的对象,不要传递一个万能的对象进来(kitchen sink / fat class / god class),否则你可能需要创造大量的"假"对象来实现单元测试。还记得单元测试的特点么,简洁,简洁,简洁,重要的事情说三遍,复杂的逻辑和冗余的代码会极大破坏单元测试的效果。违反得墨忒尔定律可能会使你的创造"假"对象的代码量翻上好几倍。
虽然mockito之类的mock框架提供了非常简单的方法来mock对象一个context之类的god class,然而
即便使用框架,每个mock对象仍然要写几行代码,越深入object graph,mock对象代码就要翻倍。
由于这些对象的耦合关联很强,导致测试代码非常不健壮,一旦对这些对象之间的内部联系做了某些修改,很可能就要重写单元测试
即时只需要context中的某一两个类,仍然需要mock整个类,会存在大量冗余代码,且遍布各个测试中,严重降低单元测试的可读性。
学一个单词kitchen sink,直译时厨房水槽,在这里表示任何东西,可能的出处是第二次世界大战期间军队中使用的俚语,原文是'Out for blood, our Navy throws everything but the kitchen sink at Jap vessels, warships and transports alike.',表示除了厨房水槽之外的其他东西。
上述内容都是为了构建一个隔离的环境供我们实现单元测试,我们来看下面的例子,创建一个House并进行测试是非常简单的,只需要new一个对象出来,然后调用方法,对结果进行断言,就可以了。
class House { private boolean isLocked; private boolean isLocked() { return isLocked; } private void lock() { isLocked = true; } }
测试House类如此简单是因为这是一个叶子类,叶子类的意思就是这个类处于依赖树的终点,它不依赖任何类。所有的叶子类都可以非常方便的进行单元测试,但是其他类就不这么轻松了,尤其是在内部自行new对象的类,因为它使单元测试不再处于一个隔离的环境中。
这样修改还有另一个好处,可以使你构建object graph的过程更加清晰直观。
-
不要在Constructor中做太多事
经过上面两条的修改,我们已经去除了可能存在于Constructor中的new对象的逻辑,但是还不止于此。
一个类的单元测试可能多达几十个,测试中我们做的事情就是,每次创建一个对象不太一样的对象,执行一个操作,断言结果。可以看到,最常做的的是创建对象,为了使测试更快,应该避免在Constructor中做除了赋值之外的任何事,有的时候可能无所谓,但如果在其中做了过于复杂的事情,如从硬盘或者网络读取一些初始化设置,一方面我们无法排除这些外部因素造成的不稳定影响,另一方也会花费大量的时间。
如果我们的类像这样,就无法排除Door对测试带来的影响。
class House { private Door door; public House() { this.door = new Door(); } // ... }
可行的改进办法是:
class House { private Door door; public House(Door door) { this.door = door; } // ... }
这种方式我们就可以在测试中mock一个假door,并通过其行为来进行判断。
同样一些其他类似的操作也应当杜绝,如initialization block,static block。如果真的需要一个复杂的初始化流程,可以增加一个方法来手动调用。不过这里偶尔会有一些极为个别的特例,通常是对一些read-only或write-only的对象或功能做简单的初始化,例如调用
System.loadLibrary
来加载so库,或者日志类的初始化,需要注意的是,这种场景极为罕见,大部分情况当你试图写在这里的代码,都不是如此还需要注意的是,避免在初始化时访问全局状态,具体的原因可以看下面一条。
-
全局状态
全局状态的作用域是整个应用,我们上面讲了这么多的模式,其目的最终都是为了达到高内聚低耦合的效果,这就要求我们限制变量的作用域减少对全局状态的使用。此外,全局状态经常是难以维护的,可能会增加阅读理解代码的难度。在单元测试是,多个测试如果访问同一个全局状态,可能会导致非预期效果,产生偶然变化。虽然我们也可以将相关的测试按顺序一个一个执行,并且在每个测试开始前重置全局状态,但是这无疑极大降低了测试的速度,增加了无关代码和复杂程度,而且也不能保证测试的准确性,例如某个功能依赖于全局状态变化后的值,但是测试开始前全局状态被重置了。
常量作为例外,是可以被允许存在的,因为常量永远不会被修改。
-
单例
其实单例也属于全局状态之一,属于披着羊皮的狼。既然我们不提倡使用全局状态,如果你读懂了上面一条,自然也就明白使用单例的问题所在了。可能有些人觉得单例和全局变量不一样,那么换个思路,关键点是单例中保存这可修改的变量,这实际上等同于单个全局变量的聚合体!
同样有例外,不可变对象(immutable object)是被允许的,因为就和常量一样,都是不会被改变的。还有一种情况read-only/write-only的对象,注意上面的说的情况,全局状态可能在任何位置被改变也可以在任何位置被读取,这意味这读取和修改是不被控制的,而此种对象的单例只存在读或写一种操作,并不存在这种现象,一个常见的例子是日志类,日志类只输出日志,我们在程序中不关心输出的内容,除非你要根据输出的日志内容做某些其他的操作,否则无论输出是什么,都不影响代码的逻辑功能。
-
static method
可以发现,单元测试大多是针对一个类的方法进行测试,多个类通过依赖关系互相协作使我们可以从中截取一部分逻辑进行单元测试。静态方法则破坏了这种方式。我们无法分离某一部分的逻辑进行测试。回想一下面向对象语言和传统过程式语言的优劣,不难理解为什么不提倡使用静态方法。如果你的代码有一大堆的复杂逻辑完全使用静态方法实现,使用传统方式几乎无法进行单元测试。mockito,easymock等框架均不支持mock静态方法,虽然powermock、dexmaker等框架的出现,通过修改编译后生成的字节码或android的dex文件使mock静态方法成为可能,但考虑到面向对象语言提供的种种优势,将静态方法中的逻辑封装到一个或几个对象中的做法无疑具有更好的扩展性,同时减少了可能存在的对全局状态的访问,降低了维护的成本。
对于叶子类,静态方法是不存在任何问题的,因为叶子类不依赖任何对象,可以轻易编写单元测试。
-
使用组合而非继承
组合提供了比继承更灵活的方式来进行扩展,参考里氏替换原则,实现继承无疑更具有挑战性,缺乏足够清晰的契约约束,或者存在错误的设计,继承可能会导致更混乱的代码。对单元测试而言,子类耦合了父类的功能,错误的继承可能将不同的功能混在一起,导致我们无法单独对某一个功能进行单元测试。
-
使用多态而非条件语句
有时我们会在一个方法中大量使用条件语句,if/else/switch,如果一个类中有很多类似的功能,应该考虑使用多态,将一个类将其分成几个小一些的具有类似行为的类,这样我们可以针对每种状态编写更简单更详细的单元测试。
-
混合Service-objects和Value-objects类
通常代码中会存在两种类型的对象,一种存储数据的类,如bean类,也叫Value-objects,pojo,dto等,或者是Map,List等,通常不需要实现接口,很容易被创建,也不需要被mock。另一种是封装业务逻辑的类,我们称之为Service-Objects,这种类在单元测试经常需要被mock,经常需要实现接口,使用各种复杂的设计模式来设计结构。
Value-objects也即是上面提到的叶子类,这些类在测试时不需要被mock,直接new一个就可以,因此它们不能在内部使用Service-objects。Service-objects通常更难以创建,因为它们有更复杂的逻辑关系,像上面第一条所描述的,这种类不应该在其内部被自行创建新对象,应当通过构造函数传递进来,可以使用factory或者依赖注入框架实现。从测试的角度来看,我们更喜欢Value-objects,因为可以自由创建且易于测试,Service-objects则由于复杂的依赖关系而变得难以测试,使得我们必须使用mock框架来模拟所有的依赖。将这两种类的功能混合在一起对两者都没有任何的好处。
-
小细节
大部分时候如果我们的代码遵循前面的6个原则,编写单元测试都会相对更容易。如果你的类或方法包含过多功能(违反单一职责原则),假如你的方法名字叫 xxxAndxxx,那就要注意了,既无法让他人快速的读懂,也会增加测试的难度。同样,简洁的代码也更有利于单元测试,还要再说一遍,单元测试,顾名思义,是要对 单元 进行的独立的,无干扰的测试,我们所有的目的,最终都是为了构筑功能单一的单元,和排除其他不确定因素的干扰。
按照上面的小技巧来修改代码,可以让代码变的更容易进行单元测试,更多的详细的代码示例可以看AngularJS的作者Miško Hevery大神的一篇blog,其中详细列举了各种会影响单元测试的代码范例。
扩展知识
Code Smells
如果觉得上面的各种原则还是有点抽象,不好理解,那么直接学习Code Smells是一个很好的办法。
Code Smells 表示代码中可能会导致潜在问题的某些特征。这里简单列举一些典型的Code Smell。
方法名过长,单个类代码过多,嗜好基本类型,参数列表过长,无用的Field,等。其实使用代码检查工具就可以有效减少Code Smells。
思考:为什么我们钟爱依赖注入框架
因为依赖注入框架可以帮助我们更好的实现几乎上述所有内容,既能提高代码质量,又能轻松的编写单元测试,何乐而不为。
Reference:
http://wiki.c2.com/?LawOfDemeter
https://testing.googleblog.com/2008/08/by-miko-hevery-so-you-decided-to.html
https://testing.googleblog.com/2008/07/how-to-think-about-new-operator-with.html
https://testing.googleblog.com/2008/07/breaking-law-of-demeter-is-like-looking.html
https://youtu.be/pK7W5npkhho
http://misko.hevery.com/attachments/Guide-Writing%20Testable%20Code.pdf